Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/components/MultiSelect/MultiSelect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MultiSelect
hasSearch
label="Columns"
onChange={() => {}}
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
Expand Down
93 changes: 93 additions & 0 deletions src/components/Select/Select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Select
hasSearch
label="Person"
onChange={() => {}}
options={[
{
title: 'Engineering',
type: 'section',
options: [
{label: 'Ada Lovelace', value: 'ada'},
{label: 'Grace Hopper', value: 'grace'},
],
},
{type: 'divider'},
{
title: 'Science',
type: 'section',
options: [{label: 'Katherine Johnson', value: 'katherine'}],
},
]}
value={null}
/>,
);

await user.click(screen.getByRole('combobox', {name: 'Person'}));
const search = screen.getByRole('searchbox', {
hidden: true,
name: 'Search Person',
});

// Only the Science group matches, so its header stays and the Engineering
// header -- along with the divider that used to separate them -- goes away.
await user.type(search, 'katherine');
expect(screen.queryByText('Engineering')).not.toBeInTheDocument();
expect(screen.getByText('Science')).toBeInTheDocument();
expect(screen.getByText('Katherine Johnson')).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('Engineering')).not.toBeInTheDocument();
expect(screen.queryByText('Science')).not.toBeInTheDocument();
expect(screen.queryAllByRole('group', {hidden: true})).toHaveLength(0);
expect(screen.queryAllByRole('option', {hidden: true})).toHaveLength(0);
});

it('drops dividers left dangling by the query', async () => {
const user = userEvent.setup();

render(
<Select
hasSearch
label="Fruit"
onChange={() => {}}
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
Expand Down
103 changes: 67 additions & 36 deletions src/internal/useSelectListbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,44 +310,61 @@ export function renderSelectListboxOptions<
renderOption,
sectionHeadingClassName,
}: RenderSelectListboxOptionsConfig<TOption>): 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<TOption>(option));
if (isNonEmptyReactNode(node)) {
entries.push({isDivider: false, node});
}
};

for (const option of options) {
if (typeof option === 'string') {
optionNodes.push(
renderOption(normalizeSelectListboxOption<TOption>(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: (
<div
className={dividerClassName}
key={`divider-${dividerCount}`}
role="separator"
/>,
);
} 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<TOption>(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<TOption>(sectionOption)),
)
.filter(isNonEmptyReactNode);
if (sectionOptionNodes.length === 0) {
continue;
}
entries.push({
isDivider: false,
node: (
<div
aria-labelledby={sectionHeadingId}
key={`section-${sectionKey}-${sectionCount}`}
Expand All @@ -358,14 +375,28 @@ export function renderSelectListboxOptions<
</div>
) : null}
{sectionOptionNodes}
</div>,
);
</div>
),
});
}
}

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<TOption>(option)),
);
continue;
}
if (isNonEmptyReactNode(pendingDivider)) {
optionNodes.push(pendingDivider);
pendingDivider = null;
}
optionNodes.push(entry.node);
}

return optionNodes;
Expand Down