diff --git a/jest.config.js b/jest.config.js index 4aac3c7204..6d31320649 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,7 +3,7 @@ export default { globalSetup: './global-setup.js', setupFilesAfterEnv: ['./setupTests.js'], moduleNameMapper: { - '\\.(css|less)$': 'identity-obj-proxy', + '\\.(css|less)$': '/__mocks__/styleMock.js', // Strip .js extension for FA dynamic imports (webpack needs .js, Jest doesn't) '^@fortawesome/free-solid-svg-icons/(.+)\\.js$': '@fortawesome/free-solid-svg-icons/$1', diff --git a/src/lib/components/searchinput/SearchInput.component.tsx b/src/lib/components/searchinput/SearchInput.component.tsx index 7a5127c2d7..573072e09e 100644 --- a/src/lib/components/searchinput/SearchInput.component.tsx +++ b/src/lib/components/searchinput/SearchInput.component.tsx @@ -19,6 +19,8 @@ export type Props = { searchIconColor?: keyof CoreUITheme; className?: string; }; +export const SEARCH_INPUT_MIN_WIDTH = convertSizeToRem('1/2'); + const SearchInputContainer = styled.div.withConfig({ componentId: 'sc-searchinput', })<{ @@ -36,7 +38,7 @@ const SearchInputContainer = styled.div.withConfig({ the layout around it to adapt. */ width: max-content; max-width: 100%; - min-width: min(${convertSizeToRem('1/2')}, ${(props) => props.$width}); + min-width: min(${SEARCH_INPUT_MIN_WIDTH}, ${(props) => props.$width}); input[value] { max-width: calc(100% - 1rem - ${spacing.f8} - 1rem); diff --git a/src/lib/organisms/attachments/AttachmentTable.test.tsx b/src/lib/organisms/attachments/AttachmentTable.test.tsx new file mode 100644 index 0000000000..47892785fb --- /dev/null +++ b/src/lib/organisms/attachments/AttachmentTable.test.tsx @@ -0,0 +1,96 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { getWrapper } from '../../testUtils'; +import { AttachmentProvider, AttachmentTable } from './AttachmentTable'; +import { AttachmentAction, AttachmentOperation } from './AttachmentTypes'; + +// This table imports the library's own barrels, which re-export the editor and so +// pull CodeMirror's ESM build into a suite that never renders it. +jest.mock('@uiw/react-codemirror', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('codemirror-json-schema', () => ({ jsonSchema: () => [] })); + +// jsdom reports 0 for every offsetWidth/scrollWidth; without this stub `ConstrainedText` +// always takes its fitting branch, never the overflow branch a narrow panel renders. +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 10, + }); + Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { + configurable: true, + value: 1000, + }); +}); + +const { Wrapper } = getWrapper(); + +const entities = [ + { name: 'User A', id: 'a', type: 'USER' }, + { name: 'User B', id: 'b', type: 'USER' }, +]; + +const renderAttachmentTable = () => { + const onAttachmentsOperationsChanged = jest.fn(); + + render( + + {}} + onAttachmentsOperationsChanged={onAttachmentsOperationsChanged} + /> + , + { wrapper: Wrapper }, + ); + + const attachedNames = (): string[] => + onAttachmentsOperationsChanged.mock.calls + .flatMap(([operations]: [AttachmentOperation[]]) => operations) + .filter((operation) => operation.action === AttachmentAction.ADD) + .map((operation) => operation.entity.name); + + return { attachedNames }; +}; + +describe('AttachmentTable', () => { + it('attaches the suggestion the user picks', async () => { + const { attachedNames } = renderAttachmentTable(); + + await userEvent.click(screen.getByRole('searchbox')); + await userEvent.click(await screen.findByText('User B')); + + await waitFor(() => expect(attachedNames()).toContain('User B')); + }); + + it('gives a suggestion its full name when the list is too narrow to show it', async () => { + renderAttachmentTable(); + + await userEvent.click(screen.getByRole('searchbox')); + await userEvent.hover(await screen.findByText('User B')); + + expect(await screen.findByRole('tooltip')).toHaveTextContent('User B'); + }); + + it('attaches nothing when the field loses focus on a highlighted suggestion', async () => { + const { attachedNames } = renderAttachmentTable(); + + await userEvent.click(screen.getByRole('searchbox')); + await screen.findByText('User B'); + await userEvent.keyboard('{ArrowDown}{ArrowDown}'); + + await userEvent.tab(); + + expect(attachedNames()).toEqual([]); + }); +}); diff --git a/src/lib/organisms/attachments/AttachmentTable.tsx b/src/lib/organisms/attachments/AttachmentTable.tsx index 34475a7113..19c58873d9 100644 --- a/src/lib/organisms/attachments/AttachmentTable.tsx +++ b/src/lib/organisms/attachments/AttachmentTable.tsx @@ -23,8 +23,9 @@ import { Text, Tooltip, } from '../../index'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { spacing, Stack, Wrap } from '../../spacing'; +import { SEARCH_INPUT_MIN_WIDTH } from '../../components/searchinput/SearchInput.component'; import { AttachableEntity, AttachmentOperation, @@ -75,35 +76,31 @@ export type AttachmentTableProps< const rowHeight = 'h48'; const MenuContainer = styled.ul<{ - $width: string; $isOpen: boolean; - $searchInputIsFocused: boolean; }>` background-color: ${(props) => props.theme.backgroundLevel1}; background-clip: content-box; padding: 0; list-style: none; position: absolute; - width: ${(props) => props.$width}; + left: 0; + right: 0; z-index: 1; margin: 0; ${(props) => - props.$isOpen - ? ` + props.$isOpen && + css` border-top-left-radius: 0; border-top-right-radius: 0; - border-bottom-right-radius: 4px; - border-bottom-left-radius: 4px; - border: 1px solid ${props.theme.selectedActive}; - ` - : props.$searchInputIsFocused - ? `border-bottom: 1px solid ${props.theme.selectedActive};` - : ''} + border-bottom-right-radius: ${spacing.r4}; + border-bottom-left-radius: ${spacing.r4}; + border: ${spacing.r1} solid ${props.theme.selectedActive}; + `} border-top: 0; li { padding: ${spacing.r8}; cursor: pointer; - border-top: 1px solid ${(props) => props.theme.backgroundLevel2}; + border-top: ${spacing.r1} solid ${(props) => props.theme.backgroundLevel2}; &[aria-selected='true'] { background: ${(props) => props.theme.highlight}; } @@ -111,28 +108,38 @@ const MenuContainer = styled.ul<{ `; const SearchBoxContainer = styled.div` - position: relative; padding: ${spacing.r16}; `; +/** The list is sized from this anchor, so it has to stop where the field stops: a narrower anchor draws the list narrower than the field. */ +const SearchAnchor = styled.div` + position: relative; + width: fit-content; + max-width: 100%; + min-width: ${SEARCH_INPUT_MIN_WIDTH}; +`; + /** * `flex-grow: 1` is inert on the normal path -- `SearchBoxContainer` is a block, so * there is no flex line to grow along. It is kept for the error path, where the * input sits in a `Stack` beside a `Loader`. * - * So the box is a fixed 287px, and this component cannot change that: `SearchInput` - * pins `width: max-content` and does not forward `fluid`. That is this table's next - * floor, at a 301px container. + * `&&` doubles the class so this rule outranks `Input`'s own `:hover`/`:focus-within` + * border, which carry a pseudo-class that a plain `& > div` cannot beat. */ -const StyledSearchInput = styled(SearchInput)<{ $searchInputIsFocused }>` +const StyledSearchInput = styled(SearchInput)<{ $isOpen: boolean }>` flex-grow: 1; - & > div:focus-within { - border-color: ${(props) => props.theme.selectedActive}; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - border-bottom: 0; - } + ${(props) => + props.$isOpen && + css` + && > div { + border-color: ${props.theme.selectedActive}; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom: 0; + } + `} `; /** @@ -492,6 +499,17 @@ export const AttachmentTable = < onInputValueChange: ({ inputValue }) => { onEntitySearchChange(inputValue); }, + // Blur must close the list without committing the highlighted row: selecting + // here runs `onSelectedItemChange`, which attaches the entity outright, so + // tabbing out of a list with a row under the cursor would attach it. + stateReducer: (state, { type, changes }) => + type === useCombobox.stateChangeTypes.InputBlur + ? { + ...changes, + selectedItem: state.selectedItem, + inputValue: state.inputValue, + } + : changes, }); useMemo(() => { @@ -499,10 +517,6 @@ export const AttachmentTable = < resetRef.current = reset; }, [reset]); - // UI styling states - const [searchWidth, setSearchWidth] = useState('0px'); - const [searchInputIsFocused, setSearchInputIsFocused] = useState(false); - return ( /* `iconOnly` is a `@container responsive` query and needs an ancestor declaring that container -- this table's own box, not whatever the consumer provides. @@ -605,132 +619,109 @@ export const AttachmentTable = < }))} defaultSortingKey="name" > - { - if (element?.firstElementChild) { - setSearchWidth( - element.firstElementChild.getBoundingClientRect().width - - 2 + - 'px', - ); - } - }, - }} - > - {filteredEntities.status === 'error' ? ( - We failed to load the entities, hence search is disabled - } - > - - { - if (element) searchInputRef.current = element; - }, - })} - onFocus={() => { - openMenu(); - setSearchInputIsFocused(true); - }} - onBlur={() => { - setSearchInputIsFocused(false); - }} - disabled={filteredEntities.status === 'error'} - /> - - - - ) : ( - { - if (element) searchInputRef.current = element; - }, - })} - onFocus={() => { - openMenu(); - setSearchInputIsFocused(true); - }} - onBlur={() => { - setSearchInputIsFocused(false); - }} - $searchInputIsFocused={searchInputIsFocused} - /> - )} - - {isOpen && - filteredEntities.status === 'success' && - filteredEntities.data?.entities.map((item, index) => ( -
  • - {item.name} -
  • - ))} - {isOpen && filteredEntities.status === 'loading' && ( -
  • - Searching... -
  • - )} - {isOpen && filteredEntities.status === 'error' && ( -
  • - - An error occured while searching - -
  • + + + {filteredEntities.status === 'error' ? ( + We failed to load the entities, hence search is disabled + } + > + + { + if (element) searchInputRef.current = element; + }, + })} + onFocus={openMenu} + disabled={filteredEntities.status === 'error'} + $isOpen={isOpen} + /> + + + + ) : ( + { + if (element) searchInputRef.current = element; + }, + })} + onFocus={openMenu} + $isOpen={isOpen} + /> )} - {isOpen && - filteredEntities.status === 'success' && - (filteredEntities.data?.number || 0) > - filteredEntities.data?.entities.length && ( -
  • - + {isOpen && + filteredEntities.status === 'success' && + filteredEntities.data?.entities.map((item, index) => ( +
  • - There{' '} - {(filteredEntities.data?.number || 0) - - filteredEntities.data?.entities.length === - 1 - ? 'is' - : 'are'}{' '} - {(filteredEntities.data?.number || 0) - - filteredEntities.data?.entities.length}{' '} - more{' '} - {(filteredEntities.data?.number || 0) - - filteredEntities.data?.entities.length === - 1 - ? entityName.singular - : entityName.plural}{' '} - matching your search. Suggestion: try more specific search - expression. - + +
  • + ))} + {isOpen && filteredEntities.status === 'loading' && ( +
  • + Searching...
  • )} - {isOpen && - filteredEntities.status === 'success' && - filteredEntities.data?.entities.length === 0 && ( + {isOpen && filteredEntities.status === 'error' && (
  • - - No {entityName.plural} found matching your search. + + An error occured while searching
  • )} -
    + {isOpen && + filteredEntities.status === 'success' && + (filteredEntities.data?.number || 0) > + filteredEntities.data?.entities.length && ( +
  • + + There{' '} + {(filteredEntities.data?.number || 0) - + filteredEntities.data?.entities.length === + 1 + ? 'is' + : 'are'}{' '} + {(filteredEntities.data?.number || 0) - + filteredEntities.data?.entities.length}{' '} + more{' '} + {(filteredEntities.data?.number || 0) - + filteredEntities.data?.entities.length === + 1 + ? entityName.singular + : entityName.plural}{' '} + matching your search. Suggestion: try more specific search + expression. + +
  • + )} + {isOpen && + filteredEntities.status === 'success' && + filteredEntities.data?.entities.length === 0 && ( +
  • + + No {entityName.plural} found matching your search. + +
  • + )} + +