From d3dcc089f7d8be9f9e1acc7ed1c360b79b17c517 Mon Sep 17 00:00:00 2001 From: Jean-Marc Millet Date: Wed, 9 Sep 2026 11:42:14 +0200 Subject: [PATCH 1/7] bugfix(attachments): keep the search dropdown aligned with its field The suggestion menu took its width from a measurement of the search box, read in a ref callback and held in state. That refreshed on every render but never on a resize that causes no render -- a container query, or a side panel narrowing the content box -- so the menu kept a width the field no longer had. It could not bite while the box was pinned to one width; making the box shrinkable is what exposed it. Anchor the menu to the search container's padding box with left/right insets instead, so it follows the field with no measurement and no state. Both insets set means the used width falls out of the over-constrained equation, which already accounts for the menu's own borders -- so the `- 2` goes too, and the menu now lines up with the field's outer edge instead of sitting a pixel short on each side. Also drops two notes that stopped being true when the box became shrinkable: it is no longer a fixed 287px, and no longer this table's narrowest point. --- .../organisms/attachments/AttachmentTable.tsx | 30 ++++++------------- stories/attachment.stories.tsx | 4 +-- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/src/lib/organisms/attachments/AttachmentTable.tsx b/src/lib/organisms/attachments/AttachmentTable.tsx index 34475a7113..5342116276 100644 --- a/src/lib/organisms/attachments/AttachmentTable.tsx +++ b/src/lib/organisms/attachments/AttachmentTable.tsx @@ -74,8 +74,13 @@ export type AttachmentTableProps< const rowHeight = 'h48'; +/** + * Anchored to `SearchBoxContainer`'s padding box with `left`/`right` rather than given a + * width, so the menu follows the field through a resize that causes no re-render -- a + * container query, or a side panel opening. Both insets also let the used width resolve + * from the over-constrained equation, so the borders need no compensating. + */ const MenuContainer = styled.ul<{ - $width: string; $isOpen: boolean; $searchInputIsFocused: boolean; }>` @@ -84,7 +89,8 @@ const MenuContainer = styled.ul<{ padding: 0; list-style: none; position: absolute; - width: ${(props) => props.$width}; + left: ${spacing.r16}; + right: ${spacing.r16}; z-index: 1; margin: 0; ${(props) => @@ -119,10 +125,6 @@ const SearchBoxContainer = styled.div` * `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. */ const StyledSearchInput = styled(SearchInput)<{ $searchInputIsFocused }>` flex-grow: 1; @@ -500,7 +502,6 @@ export const AttachmentTable = < }, [reset]); // UI styling states - const [searchWidth, setSearchWidth] = useState('0px'); const [searchInputIsFocused, setSearchInputIsFocused] = useState(false); return ( @@ -605,19 +606,7 @@ export const AttachmentTable = < }))} defaultSortingKey="name" > - { - if (element?.firstElementChild) { - setSearchWidth( - element.firstElementChild.getBoundingClientRect().width - - 2 + - 'px', - ); - } - }, - }} - > + {filteredEntities.status === 'error' ? ( diff --git a/stories/attachment.stories.tsx b/stories/attachment.stories.tsx index f0ff33c74d..da65d03625 100644 --- a/stories/attachment.stories.tsx +++ b/stories/attachment.stories.tsx @@ -140,8 +140,8 @@ export const ConfirmationModal = { * * Drag `frameWidth` down. At **360px** the row's `Remove` button drops its label for * a tooltip; without it the row bleeds out of the panel with nothing to show it, - * since these panels are `overflow: visible`. The next floor is the search box at - * **301px**, which is `SearchInput`'s fixed width and not fixable from here. + * since these panels are `overflow: visible`. The search box below it now shrinks + * with the panel rather than pinning a width of its own. */ export const NarrowPanel = { argTypes: { From 8b557f0d78970417e2be8f4805650c85628a4b1e Mon Sep 17 00:00:00 2001 From: Jean-Marc Millet Date: Wed, 9 Sep 2026 17:12:40 +0200 Subject: [PATCH 2/7] bugfix(attachments): anchor the search menu to the field, and close it with the seam The previous commit anchored the menu to `SearchBoxContainer`'s padding box, which is wider than the field, since the field hugs its own content. A `SearchAnchor` wrapper with `width: fit-content` becomes the menu's containing block instead, so `left: 0; right: 0` resolves to the field's width. Measured over one menu held open while the panel resized underneath it with no re-render, 600px down to 220px and back: the menu tracked the field to 0px at every step, against an overhang reaching 95px before. The field's seam with the open menu moves off `:focus-within` onto the menu's own open state. An outside click blurs the input on `mousedown` while the menu closes on the `mouseup` that follows, so a focus-driven seam snapped back under a menu that was still open. `&&` is required on the new rule: `Input` paints its border from `:hover` and `:focus-within`, both of which outrank a single-class `& > div`. Dropping the consumer's `onBlur` also restores downshift's own, which it had been overriding by being spread after `getInputProps()`, so tabbing out of the field now closes the menu. Co-Authored-By: Claude Opus 5 (1M context) --- .../organisms/attachments/AttachmentTable.tsx | 258 +++++++++--------- 1 file changed, 132 insertions(+), 126 deletions(-) diff --git a/src/lib/organisms/attachments/AttachmentTable.tsx b/src/lib/organisms/attachments/AttachmentTable.tsx index 5342116276..25cfababb9 100644 --- a/src/lib/organisms/attachments/AttachmentTable.tsx +++ b/src/lib/organisms/attachments/AttachmentTable.tsx @@ -23,7 +23,7 @@ import { Text, Tooltip, } from '../../index'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { spacing, Stack, Wrap } from '../../spacing'; import { AttachableEntity, @@ -75,22 +75,22 @@ export type AttachmentTableProps< const rowHeight = 'h48'; /** - * Anchored to `SearchBoxContainer`'s padding box with `left`/`right` rather than given a - * width, so the menu follows the field through a resize that causes no re-render -- a - * container query, or a side panel opening. Both insets also let the used width resolve - * from the over-constrained equation, so the borders need no compensating. + * Anchored to `SearchAnchor`, which is exactly as wide as the field, with `left`/`right` + * rather than given a width -- so the menu follows the field through a resize that causes + * no re-render: a container query, or a side panel opening. The old `-2px` border + * compensation is gone with it -- an auto width between two insets already lands on the + * border box. */ const MenuContainer = styled.ul<{ $isOpen: boolean; - $searchInputIsFocused: boolean; }>` background-color: ${(props) => props.theme.backgroundLevel1}; background-clip: content-box; padding: 0; list-style: none; position: absolute; - left: ${spacing.r16}; - right: ${spacing.r16}; + left: 0; + right: 0; z-index: 1; margin: 0; ${(props) => @@ -102,9 +102,7 @@ const MenuContainer = styled.ul<{ border-bottom-left-radius: 4px; border: 1px solid ${props.theme.selectedActive}; ` - : props.$searchInputIsFocused - ? `border-bottom: 1px solid ${props.theme.selectedActive};` - : ''} + : ''} border-top: 0; li { padding: ${spacing.r8}; @@ -117,24 +115,48 @@ const MenuContainer = styled.ul<{ `; const SearchBoxContainer = styled.div` - position: relative; padding: ${spacing.r16}; `; +/** + * The menu's containing block, sized to the field rather than to the padding box around + * it: the field hugs its own content, so a menu spanning the whole box would be wider + * than the field it belongs to. + */ +const SearchAnchor = styled.div` + position: relative; + width: fit-content; + max-width: 100%; +`; + /** * `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`. + * + * The seam with the open menu is keyed on the menu's own open state rather than on + * `:focus-within`. Both edges of the seam then move on one state change: an outside + * click blurs the input on `mousedown`, while the menu closes on the `mouseup` that + * follows, so a focus-driven seam reappeared under a menu that was still open. + * + * `&&` is load-bearing. `Input` paints its own border from `:hover` and + * `:focus-within`, both of which carry a pseudo-class and so outrank a plain + * `& > div`; the class has to be doubled to win. The old `& > div:focus-within` + * outranked them for free, which is why dropping the pseudo-class needed this. */ -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; + } + `} `; /** @@ -501,9 +523,6 @@ export const AttachmentTable = < resetRef.current = reset; }, [reset]); - // UI styling states - 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. @@ -607,118 +626,105 @@ export const AttachmentTable = < defaultSortingKey="name" > - {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. - + {item.name} +
  • + ))} + {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. + +
  • + )} + +
    Date: Thu, 1 Oct 2026 15:14:08 +0200 Subject: [PATCH 3/7] bugfix(attachments): do not attach an entity when the search field loses focus Handing blur back to downshift also handed it the selection it performs there: on InputBlur it commits the highlighted row, and committing one runs onSelectedItemChange, which attaches the entity. Tabbing out of an open list with a row under the cursor attached it. A state reducer keeps the close and drops the selection, so blur does the one thing it is wanted for. Three comments come down with it. Two restated what the CSS already says, and the third narrated the event ordering behind the seam; the specificity bump on the seam rule is the one part a reader cannot derive, and it stays. --- .../organisms/attachments/AttachmentTable.tsx | 34 +++++++------------ stories/attachment.stories.tsx | 3 +- 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/src/lib/organisms/attachments/AttachmentTable.tsx b/src/lib/organisms/attachments/AttachmentTable.tsx index 25cfababb9..68ce97a2e8 100644 --- a/src/lib/organisms/attachments/AttachmentTable.tsx +++ b/src/lib/organisms/attachments/AttachmentTable.tsx @@ -74,13 +74,6 @@ export type AttachmentTableProps< const rowHeight = 'h48'; -/** - * Anchored to `SearchAnchor`, which is exactly as wide as the field, with `left`/`right` - * rather than given a width -- so the menu follows the field through a resize that causes - * no re-render: a container query, or a side panel opening. The old `-2px` border - * compensation is gone with it -- an auto width between two insets already lands on the - * border box. - */ const MenuContainer = styled.ul<{ $isOpen: boolean; }>` @@ -118,11 +111,6 @@ const SearchBoxContainer = styled.div` padding: ${spacing.r16}; `; -/** - * The menu's containing block, sized to the field rather than to the padding box around - * it: the field hugs its own content, so a menu spanning the whole box would be wider - * than the field it belongs to. - */ const SearchAnchor = styled.div` position: relative; width: fit-content; @@ -134,15 +122,8 @@ const SearchAnchor = styled.div` * 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`. * - * The seam with the open menu is keyed on the menu's own open state rather than on - * `:focus-within`. Both edges of the seam then move on one state change: an outside - * click blurs the input on `mousedown`, while the menu closes on the `mouseup` that - * follows, so a focus-driven seam reappeared under a menu that was still open. - * - * `&&` is load-bearing. `Input` paints its own border from `:hover` and - * `:focus-within`, both of which carry a pseudo-class and so outrank a plain - * `& > div`; the class has to be doubled to win. The old `& > div:focus-within` - * outranked them for free, which is why dropping the pseudo-class needed this. + * `&&` 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)<{ $isOpen: boolean }>` flex-grow: 1; @@ -516,6 +497,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(() => { diff --git a/stories/attachment.stories.tsx b/stories/attachment.stories.tsx index da65d03625..fb390f62a6 100644 --- a/stories/attachment.stories.tsx +++ b/stories/attachment.stories.tsx @@ -140,8 +140,7 @@ export const ConfirmationModal = { * * Drag `frameWidth` down. At **360px** the row's `Remove` button drops its label for * a tooltip; without it the row bleeds out of the panel with nothing to show it, - * since these panels are `overflow: visible`. The search box below it now shrinks - * with the panel rather than pinning a width of its own. + * since these panels are `overflow: visible`. */ export const NarrowPanel = { argTypes: { From e3db9ae5689b52bdb430723e6a0160ee18049a92 Mon Sep 17 00:00:00 2001 From: Jean-Marc Millet Date: Wed, 7 Oct 2026 14:53:41 +0200 Subject: [PATCH 4/7] bugfix(attachments): size the search list from the field, down to its floor The anchor the list hangs off tracked the field with `fit-content`, which follows it only while the field is still shrinking. Past its own `min-width` the field holds its width and overflows its container, while the anchor kept shrinking with the panel, so the list came out up to 48px narrower than the field it belongs to. `SearchInput` exports that floor now, and the anchor reads it rather than spelling it out a second time. The list's outline also carried a literal 1px border and 4px radius against the field's `spacing.r1` and `spacing.r4`. At a 14px root those are 0.875px and 3.5px, so on a 2x display the list drew two device pixels against the field's one, along the same frame. --- .../components/searchinput/SearchInput.component.tsx | 5 ++++- src/lib/organisms/attachments/AttachmentTable.tsx | 11 +++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/lib/components/searchinput/SearchInput.component.tsx b/src/lib/components/searchinput/SearchInput.component.tsx index 7a5127c2d7..0c1b0c22f6 100644 --- a/src/lib/components/searchinput/SearchInput.component.tsx +++ b/src/lib/components/searchinput/SearchInput.component.tsx @@ -19,6 +19,9 @@ export type Props = { searchIconColor?: keyof CoreUITheme; className?: string; }; +/** Exported so a dependent layout can match the field's floor directly, instead of duplicating the value. */ +export const SEARCH_INPUT_MIN_WIDTH = convertSizeToRem('1/2'); + const SearchInputContainer = styled.div.withConfig({ componentId: 'sc-searchinput', })<{ @@ -36,7 +39,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.tsx b/src/lib/organisms/attachments/AttachmentTable.tsx index 68ce97a2e8..8e6d51c739 100644 --- a/src/lib/organisms/attachments/AttachmentTable.tsx +++ b/src/lib/organisms/attachments/AttachmentTable.tsx @@ -25,6 +25,7 @@ import { } from '../../index'; 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, @@ -91,16 +92,16 @@ const MenuContainer = styled.ul<{ ? ` 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}; + 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.f1} solid ${(props) => props.theme.backgroundLevel2}; &[aria-selected='true'] { background: ${(props) => props.theme.highlight}; } @@ -111,10 +112,12 @@ const SearchBoxContainer = styled.div` padding: ${spacing.r16}; `; +/** Matches SearchInput's min-width: the list is sized from this anchor, so 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}; `; /** From de63546b682ed4f904c567010c3badb4585c2f81 Mon Sep 17 00:00:00 2001 From: Jean-Marc Millet Date: Fri, 16 Oct 2026 18:57:00 +0200 Subject: [PATCH 5/7] test(attachments): cover attaching from the search list, and not attaching on blur The css mapper named identity-obj-proxy, which is in neither package.json nor the lockfile, so nothing that imports src/lib/index.ts could be tested -- and this table does. It now points at the stub already sitting in __mocks__. --- jest.config.js | 2 +- .../attachments/AttachmentTable.test.tsx | 74 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 src/lib/organisms/attachments/AttachmentTable.test.tsx 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/organisms/attachments/AttachmentTable.test.tsx b/src/lib/organisms/attachments/AttachmentTable.test.tsx new file mode 100644 index 0000000000..267fb09949 --- /dev/null +++ b/src/lib/organisms/attachments/AttachmentTable.test.tsx @@ -0,0 +1,74 @@ +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: () => [] })); + +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('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([]); + }); +}); From d7b6b2195db5f5a7be791d070779dc3be5ab22c2 Mon Sep 17 00:00:00 2001 From: Jean-Marc Millet Date: Fri, 16 Oct 2026 18:57:06 +0200 Subject: [PATCH 6/7] refactor(attachments): draw the suggestion list with the repo's border token spacing.f1 is a fixed pixel used nowhere else on a border; spacing.r1 is the token every other border in the library uses, including the field's own, so the list and the field now hold the same hairline at any root size. --- .../components/searchinput/SearchInput.component.tsx | 1 - src/lib/organisms/attachments/AttachmentTable.tsx | 11 +++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/lib/components/searchinput/SearchInput.component.tsx b/src/lib/components/searchinput/SearchInput.component.tsx index 0c1b0c22f6..573072e09e 100644 --- a/src/lib/components/searchinput/SearchInput.component.tsx +++ b/src/lib/components/searchinput/SearchInput.component.tsx @@ -19,7 +19,6 @@ export type Props = { searchIconColor?: keyof CoreUITheme; className?: string; }; -/** Exported so a dependent layout can match the field's floor directly, instead of duplicating the value. */ export const SEARCH_INPUT_MIN_WIDTH = convertSizeToRem('1/2'); const SearchInputContainer = styled.div.withConfig({ diff --git a/src/lib/organisms/attachments/AttachmentTable.tsx b/src/lib/organisms/attachments/AttachmentTable.tsx index 8e6d51c739..2cd6531474 100644 --- a/src/lib/organisms/attachments/AttachmentTable.tsx +++ b/src/lib/organisms/attachments/AttachmentTable.tsx @@ -88,20 +88,19 @@ const MenuContainer = styled.ul<{ 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: ${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: ${spacing.f1} solid ${(props) => props.theme.backgroundLevel2}; + border-top: ${spacing.r1} solid ${(props) => props.theme.backgroundLevel2}; &[aria-selected='true'] { background: ${(props) => props.theme.highlight}; } @@ -112,7 +111,7 @@ const SearchBoxContainer = styled.div` padding: ${spacing.r16}; `; -/** Matches SearchInput's min-width: the list is sized from this anchor, so a narrower anchor draws the list narrower than the field. */ +/** 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; From 9a1ce024fe62a817119af90573bdf12429ee2da6 Mon Sep 17 00:00:00 2001 From: Jean-Marc Millet Date: Mon, 19 Oct 2026 15:21:23 +0200 Subject: [PATCH 7/7] bugfix(attachments): keep a suggestion's name inside the search list A name the browser cannot break -- an email, an id -- left the list entirely, by 33px once the field sits on its floor, and past the panel's own border with it. The typography guideline allows wrapping at a delimiter or an ellipsis carrying the full value in a tooltip; the list did neither, while the table of attached entities in this same component already draws the same names through ConstrainedText. The tooltip goes to the right rather than the default top, which over the first suggestion would cover the search field and the query just typed -- the placement Selectv2 already uses for an option's tooltip. --- .../attachments/AttachmentTable.test.tsx | 22 +++++++++++++++++++ .../organisms/attachments/AttachmentTable.tsx | 5 ++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/lib/organisms/attachments/AttachmentTable.test.tsx b/src/lib/organisms/attachments/AttachmentTable.test.tsx index 267fb09949..47892785fb 100644 --- a/src/lib/organisms/attachments/AttachmentTable.test.tsx +++ b/src/lib/organisms/attachments/AttachmentTable.test.tsx @@ -12,6 +12,19 @@ jest.mock('@uiw/react-codemirror', () => ({ })); 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 = [ @@ -60,6 +73,15 @@ describe('AttachmentTable', () => { 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(); diff --git a/src/lib/organisms/attachments/AttachmentTable.tsx b/src/lib/organisms/attachments/AttachmentTable.tsx index 2cd6531474..19c58873d9 100644 --- a/src/lib/organisms/attachments/AttachmentTable.tsx +++ b/src/lib/organisms/attachments/AttachmentTable.tsx @@ -664,7 +664,10 @@ export const AttachmentTable = < key={`${item.id}${index}`} {...getItemProps({ item, index })} > - {item.name} + ))} {isOpen && filteredEntities.status === 'loading' && (