Skip to content
77 changes: 70 additions & 7 deletions src/OptionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,29 @@ const OptionList: React.ForwardRefRenderFunction<RefOptionListProps, {}> = (_, r
},
}));

// `optionPositions`: skip group headers to match native <select> announcements with <optgroup>
// `itemGroups`: owning group header of each option, so lookups stay O(1) while rendering
const [optionPositions, itemGroups] = React.useMemo(() => {
let count = 0;
let group: FlattenOptionData<BaseOptionType> | null = null;

const positions: number[] = [];
const groups: (FlattenOptionData<BaseOptionType> | null)[] = [];

memoFlattenOptions.forEach((item) => {
if (item.group) {
group = item;
positions.push(count);
groups.push(null);
} else {
positions.push((count += 1));
groups.push(item.groupOption ? group : null);
}
});

return [positions, groups] as const;
}, [memoFlattenOptions]);

// ========================== Render ==========================
if (memoFlattenOptions.length === 0) {
return (
Expand Down Expand Up @@ -291,12 +314,13 @@ const OptionList: React.ForwardRefRenderFunction<RefOptionListProps, {}> = (_, r
}
const itemData = item.data || {};
const { value, disabled } = itemData;
const { group } = item;
const attrs = pickAttrs(itemData, true);
const mergedLabel = getLabel(item);
return item ? (
return (
<div
aria-label={typeof mergedLabel === 'string' && !group ? mergedLabel : null}
aria-label={isTitleType(mergedLabel) ? String(mergedLabel) : null}
aria-setsize={optionPositions[optionPositions.length - 1] ?? 0}
aria-posinset={optionPositions[index]}
{...attrs}
key={index}
{...getItemAriaProps(item, index)}
Expand All @@ -305,7 +329,48 @@ const OptionList: React.ForwardRefRenderFunction<RefOptionListProps, {}> = (_, r
>
{value}
</div>
) : null;
);
};

// Nest options inside `role="group"` wrappers
const renderHiddenItems = () => {
const segments: {
group: FlattenOptionData<BaseOptionType> | null;
indexes: number[];
}[] = [];

[activeIndex - 1, activeIndex, activeIndex + 1].forEach((index) => {
const item = memoFlattenOptions[index];
if (!item || item.group) {
return;
}

const groupItem = itemGroups[index];
const lastSegment = segments[segments.length - 1];

if (lastSegment && lastSegment.group === groupItem) {
lastSegment.indexes.push(index);
} else {
segments.push({ group: groupItem, indexes: [index] });
}
});

return segments.map(({ group, indexes }) => {
if (!group) {
return indexes.map(renderItem);
}

const groupLabel = getLabel(group);
return (
<div
key={group.key}
role="group"
aria-label={group.data.title ?? (isTitleType(groupLabel) ? String(groupLabel) : null)}
>
{indexes.map(renderItem)}
</div>
);
});
};

const a11yProps = {
Expand All @@ -317,9 +382,7 @@ const OptionList: React.ForwardRefRenderFunction<RefOptionListProps, {}> = (_, r
<>
{virtual && (
<div {...a11yProps} style={{ height: 0, width: 0, overflow: 'hidden' }}>
{renderItem(activeIndex - 1)}
{renderItem(activeIndex)}
{renderItem(activeIndex + 1)}
{renderHiddenItems()}
</div>
)}
<List<FlattenOptionData<BaseOptionType>>
Expand Down
160 changes: 160 additions & 0 deletions tests/Accessibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,166 @@ describe('Select.Accessibility', () => {
});
});

it('should have correct aria-posinset and aria-setsize in virtual mode', () => {
const { container } = render(
<Select
id="virtual-select"
open
options={[{ value: '1' }, { value: '2' }, { value: '3' }, { value: '4' }, { value: '5' }]}
/>,
);

// The hidden accessibility container is the listbox itself in virtual mode
const getHiddenOptions = () =>
Array.from(document.querySelectorAll('#virtual-select_list div[role="option"]'));

// Active index is 0, so the hidden container renders options 0 and 1
let hiddenOptions = getHiddenOptions();
expect(hiddenOptions.map((option) => option.getAttribute('aria-posinset'))).toEqual([
'1',
'2',
]);
hiddenOptions.forEach((option) => {
expect(option).toHaveAttribute('aria-setsize', '5');
});

// Move active option to the middle of the list
keyDown(container.querySelector('input')!, KeyCode.DOWN);
keyDown(container.querySelector('input')!, KeyCode.DOWN);

// Active index is 2, so the hidden container renders options 1, 2 and 3
hiddenOptions = getHiddenOptions();
expect(hiddenOptions.map((option) => option.getAttribute('aria-posinset'))).toEqual([
'2',
'3',
'4',
]);
hiddenOptions.forEach((option) => {
expect(option).toHaveAttribute('aria-setsize', '5');
});
});

it('aria-posinset and aria-setsize should skip group headers like native optgroup', () => {
const { container } = render(
<Select
id="virtual-select"
open
options={[
{
label: 'First group',
options: [{ value: '1' }, { value: '2' }, { value: '3' }],
},
{
label: 'Second group',
options: [{ value: '4' }, { value: '5' }, { value: '6' }],
},
]}
/>,
);

const hiddenContainer = document.querySelector('#virtual-select_list');
const getHiddenOptions = () =>
Array.from(hiddenContainer.querySelectorAll('div[role="option"]'));
const getGroupWrappers = () =>
Array.from(hiddenContainer.querySelectorAll('div[role="group"]'));

// Active option is the first real option (flatten index 1), so the
// hidden container renders the first two options inside their group
let groupWrappers = getGroupWrappers();
expect(groupWrappers).toHaveLength(1);
expect(groupWrappers[0]).toHaveAttribute('aria-label', 'First group');

let hiddenOptions = getHiddenOptions();
expect(hiddenOptions.map((option) => option.getAttribute('aria-posinset'))).toEqual([
'1',
'2',
]);
hiddenOptions.forEach((option) => {
expect(option).toHaveAttribute('aria-setsize', '6');
expect(option.parentElement).toBe(groupWrappers[0]);
});

// Move into the second group: positions keep counting across groups
keyDown(container.querySelector('input')!, KeyCode.DOWN);
keyDown(container.querySelector('input')!, KeyCode.DOWN);
keyDown(container.querySelector('input')!, KeyCode.DOWN);

groupWrappers = getGroupWrappers();
expect(groupWrappers).toHaveLength(1);
expect(groupWrappers[0]).toHaveAttribute('aria-label', 'Second group');

hiddenOptions = getHiddenOptions();
expect(hiddenOptions.map((option) => option.getAttribute('aria-posinset'))).toEqual([
'4',
'5',
]);
hiddenOptions.forEach((option) => {
expect(option).toHaveAttribute('aria-setsize', '6');
expect(option.parentElement).toBe(groupWrappers[0]);
});
});

it('should split grouped and top-level options into separate segments', () => {
const { container } = render(
<Select
id="virtual-select"
open
options={[
{
label: 'Group',
options: [{ value: '1' }, { value: '2' }, { value: '3' }],
},
// Top-level option directly after a group, with no header between
{ value: '4' },
]}
/>,
);

const hiddenContainer = document.querySelector('#virtual-select_list');

// Activate the last grouped option so the window spans the group
// boundary: options '2', '3' (grouped) and '4' (top-level)
keyDown(container.querySelector('input')!, KeyCode.DOWN);
keyDown(container.querySelector('input')!, KeyCode.DOWN);

// Grouped options stay wrapped; the top-level option renders bare
const groupWrappers = Array.from(hiddenContainer.querySelectorAll('div[role="group"]'));
expect(groupWrappers).toHaveLength(1);
expect(groupWrappers[0]).toHaveAttribute('aria-label', 'Group');
expect(
Array.from(groupWrappers[0].querySelectorAll('div[role="option"]')).map((option) =>
option.getAttribute('aria-posinset'),
),
).toEqual(['2', '3']);

const topLevelOption = Array.from(
hiddenContainer.querySelectorAll('div[role="option"]'),
).find((option) => option.getAttribute('aria-posinset') === '4');
expect(topLevelOption).toBeTruthy();
expect(topLevelOption.parentElement).toBe(hiddenContainer);
expect(topLevelOption).toHaveAttribute('aria-setsize', '4');
});

it('should use group title in aria-label', () => {
render(
<Select
id="virtual-select"
open
options={[
{
label: 'Group',
title: 'Group title',
options: [{ value: '1' }, { value: '2' }],
},
]}
/>,
);

const hiddenContainer = document.querySelector('#virtual-select_list');
const groupWrapper = hiddenContainer.querySelector('div[role="group"]');
expect(groupWrapper).toHaveAttribute('aria-label', 'Group title');
});

it('should have correct aria and role attributes in virtual false', () => {
render(
<Select
Expand Down
26 changes: 11 additions & 15 deletions tests/__snapshots__/OptionList.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,19 @@ exports[`OptionList renders correctly virtual 1`] = `
style="height: 0px; width: 0px; overflow: hidden;"
>
<div
aria-selected="false"
id="undefined_list_0"
role="presentation"
/>
<div
aria-label="value-1"
aria-selected="true"
id="undefined_list_1"
role="option"
role="group"
>
1
<div
aria-label="value-1"
aria-posinset="1"
aria-selected="true"
aria-setsize="2"
id="undefined_list_1"
role="option"
>
1
</div>
</div>
<div
aria-selected="false"
id="undefined_list_2"
role="presentation"
/>
</div>
<div
class="rc-select-dropdown-list"
Expand Down
12 changes: 12 additions & 0 deletions tests/__snapshots__/Select.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,19 @@ exports[`Select.Basic does not filter when filterOption value is false 1`] = `
>
<div
aria-label="1"
aria-posinset="1"
aria-selected="false"
aria-setsize="2"
id="test-id_list_0"
role="option"
>
1
</div>
<div
aria-label="2"
aria-posinset="2"
aria-selected="false"
aria-setsize="2"
id="test-id_list_1"
role="option"
>
Expand Down Expand Up @@ -417,15 +421,19 @@ exports[`Select.Basic should contain falsy children 1`] = `
>
<div
aria-label="1"
aria-posinset="1"
aria-selected="true"
aria-setsize="2"
id="test-id_list_0"
role="option"
>
1
</div>
<div
aria-label="2"
aria-posinset="2"
aria-selected="false"
aria-setsize="2"
id="test-id_list_1"
role="option"
>
Expand Down Expand Up @@ -516,15 +524,19 @@ exports[`Select.Basic should render custom dropdown correctly 1`] = `
>
<div
aria-label="1"
aria-posinset="1"
aria-selected="false"
aria-setsize="2"
id="test-id_list_0"
role="option"
>
1
</div>
<div
aria-label="2"
aria-posinset="2"
aria-selected="false"
aria-setsize="2"
id="test-id_list_1"
role="option"
>
Expand Down
Loading
Loading