diff --git a/src/components/MultiSelect/MultiSelect.test.tsx b/src/components/MultiSelect/MultiSelect.test.tsx index e3d4202..665b693 100644 --- a/src/components/MultiSelect/MultiSelect.test.tsx +++ b/src/components/MultiSelect/MultiSelect.test.tsx @@ -513,6 +513,55 @@ describe('MultiSelect', () => { expect(onChange).toHaveBeenCalledWith(['status']); }); + it('hides sections and their dividers when the query filters out every option in them', async () => { + const user = userEvent.setup(); + + render( + {}} + options={[ + { + title: 'Visible', + type: 'section', + options: [ + {label: 'Name', value: 'name'}, + {label: 'Email', value: 'email'}, + ], + }, + {type: 'divider'}, + { + title: 'Metadata', + type: 'section', + options: [{label: 'Status', value: 'status'}], + }, + ]} + value={[]} + />, + ); + + await user.click(screen.getByRole('combobox', {name: 'Columns'})); + const search = screen.getByLabelText('Search Columns'); + + // Only the Metadata group matches, so its header stays and the Visible + // header -- along with the divider that used to separate them -- goes away. + await user.type(search, 'status'); + expect(screen.queryByText('Visible')).not.toBeInTheDocument(); + expect(screen.getByText('Metadata')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect( + screen.queryByRole('separator', {hidden: true}), + ).not.toBeInTheDocument(); + + // Nothing matches, so no orphaned headers are left behind. + await user.clear(search); + await user.type(search, 'zzz'); + expect(screen.queryByText('Visible')).not.toBeInTheDocument(); + expect(screen.queryByText('Metadata')).not.toBeInTheDocument(); + expect(screen.queryAllByRole('group', {hidden: true})).toHaveLength(0); + }); + it('renders multiple dividers without duplicate key warnings', async () => { const user = userEvent.setup(); const consoleError = vi diff --git a/src/components/Select/Select.test.tsx b/src/components/Select/Select.test.tsx index 0ac4eba..436bce4 100644 --- a/src/components/Select/Select.test.tsx +++ b/src/components/Select/Select.test.tsx @@ -451,6 +451,99 @@ describe('Select', () => { expect(onChange).toHaveBeenCalledWith('katherine'); }); + it('hides sections and their dividers when the query filters out every option in them', async () => { + const user = userEvent.setup(); + + render( + {}} + options={[ + 'Apple', + {type: 'divider'}, + 'Banana', + {type: 'divider'}, + 'Cherry', + ]} + value={null} + />, + ); + + await user.click(screen.getByRole('combobox', {name: 'Fruit'})); + const search = screen.getByRole('searchbox', { + hidden: true, + name: 'Search Fruit', + }); + + // Apple and Cherry survive, so the two dividers that surrounded Banana + // collapse into the single one that still separates them. + await user.type(search, 'e'); + expect(screen.getAllByRole('option', {hidden: true})).toHaveLength(2); + expect(screen.getAllByRole('separator', {hidden: true})).toHaveLength(1); + + // A single surviving option needs no divider on either side of it. + await user.clear(search); + await user.type(search, 'banana'); + expect(screen.getAllByRole('option', {hidden: true})).toHaveLength(1); + expect( + screen.queryByRole('separator', {hidden: true}), + ).not.toBeInTheDocument(); + }); + it('renders multiple dividers without duplicate key warnings', async () => { const user = userEvent.setup(); const consoleError = vi diff --git a/src/internal/useSelectListbox.tsx b/src/internal/useSelectListbox.tsx index bb63fe3..6297bb8 100644 --- a/src/internal/useSelectListbox.tsx +++ b/src/internal/useSelectListbox.tsx @@ -310,44 +310,61 @@ export function renderSelectListboxOptions< renderOption, sectionHeadingClassName, }: RenderSelectListboxOptionsConfig): ReactNode[] { - const optionNodes: ReactNode[] = []; + // `renderOption` returns null for options filtered out by the current query, + // so entries are collected with their visibility first and the dividers are + // resolved afterwards -- a divider only earns its place between two entries + // that survived the filter. + const entries: {isDivider: boolean; node: ReactNode}[] = []; let dividerCount = 0; let sectionCount = 0; + const pushOption = (option: string | TOption): void => { + const node = renderOption(normalizeSelectListboxOption(option)); + if (isNonEmptyReactNode(node)) { + entries.push({isDivider: false, node}); + } + }; + for (const option of options) { - if (typeof option === 'string') { - optionNodes.push( - renderOption(normalizeSelectListboxOption(option)), - ); - } else if ('type' in option) { - if (option.type === 'divider') { - dividerCount += 1; - optionNodes.push( + if (typeof option === 'string' || !('type' in option)) { + pushOption(option); + } else if (option.type === 'divider') { + dividerCount += 1; + entries.push({ + isDivider: true, + node: (
, - ); - } else { - const sectionKey = - option.title ?? - option.options.map(sectionOption => sectionOption.value).join('|'); - sectionCount += 1; - const sectionHeadingId = - option.title == null - ? undefined - : `${inputId}-section-${sectionKey.replace( - /[^a-zA-Z0-9_-]/g, - '-', - )}-${sectionCount}`; - const sectionOptionNodes: ReactNode[] = []; - for (const sectionOption of option.options) { - sectionOptionNodes.push( - renderOption(normalizeSelectListboxOption(sectionOption)), - ); - } - optionNodes.push( + /> + ), + }); + } else { + const sectionKey = + option.title ?? + option.options.map(sectionOption => sectionOption.value).join('|'); + // Counted even when the section is dropped so that keys stay stable as + // the query changes. + sectionCount += 1; + const sectionHeadingId = + option.title == null + ? undefined + : `${inputId}-section-${sectionKey.replace( + /[^a-zA-Z0-9_-]/g, + '-', + )}-${sectionCount}`; + const sectionOptionNodes = option.options + .map((sectionOption): ReactNode => + renderOption(normalizeSelectListboxOption(sectionOption)), + ) + .filter(isNonEmptyReactNode); + if (sectionOptionNodes.length === 0) { + continue; + } + entries.push({ + isDivider: false, + node: (
) : null} {sectionOptionNodes} -
, - ); +
+ ), + }); + } + } + + const optionNodes: ReactNode[] = []; + let pendingDivider: ReactNode = null; + for (const entry of entries) { + if (entry.isDivider) { + // Nothing rendered yet means a leading divider; otherwise hold it until + // something follows. Overwriting collapses runs of adjacent dividers. + if (optionNodes.length > 0) { + pendingDivider = entry.node; } - } else { - optionNodes.push( - renderOption(normalizeSelectListboxOption(option)), - ); + continue; + } + if (isNonEmptyReactNode(pendingDivider)) { + optionNodes.push(pendingDivider); + pendingDivider = null; } + optionNodes.push(entry.node); } return optionNodes;