Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/selector-listbox-divider-role.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astryxdesign/core': patch
---

[fix] `Selector` and `MultiSelector` no longer place a `role="separator"` divider inside their `role="listbox"` popup. A `listbox` only permits `option`/`group` children, so a separator among them was a critical axe violation (`aria-required-children`, WCAG 1.3.1/4.1.2) that could drop or mis-report the listbox children in a screen reader. The visual dividers between options now render `role="presentation"`, keeping them out of the accessibility tree while preserving the visual rule. A standalone divider between the search row and the listbox keeps its `role="separator"`. `Divider` now also accepts an explicit `role` prop (default `separator`) so consumers can override it.
3 changes: 2 additions & 1 deletion packages/core/src/Divider/Divider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export function Divider({
ref,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
role = 'separator',
...props
}: DividerProps) {
const isHorizontal = orientation === 'horizontal';
Expand All @@ -166,7 +167,7 @@ export function Divider({
<div
ref={ref}
{...props}
role="separator"
role={role}
aria-orientation={orientation}
aria-label={ariaLabel}
aria-labelledby={resolvedLabelledBy}
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/MultiSelector/MultiSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1981,6 +1981,29 @@ describe('MultiSelector list structure', () => {
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});

it('renders a divider inside the listbox as presentation, not a separator', async () => {
// A role="separator" is not a permitted child of role="listbox" (axe
// aria-required-children, WCAG 1.3.1/4.1.2). A visual divider between
// options must stay out of the accessibility tree.
const user = userEvent.setup();
render(
<MultiSelector
label="Fruit"
options={['Apple', {type: 'divider'}, 'Banana']}
value={[]}
onChange={() => {}}
/>,
);
await user.click(screen.getByRole('combobox'));

const listbox = screen.getByRole('listbox', {hidden: true});
const dividerInListbox = [...listbox.children].find(el =>
el.className.includes('astryx-divider'),
);
expect(dividerInListbox).toBeTruthy();
expect(dividerInListbox).toHaveAttribute('role', 'presentation');
});
});

describe('MultiSelector search affordances', () => {
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/MultiSelector/MultiSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1420,8 +1420,16 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
// A standalone divider between groups would orphan itself once its
// neighbors are filtered out, so skip it while searching.
if (!isSearching) {
// A divider inside role="listbox" must not be a separator child:
// listbox only permits option/group children, so the visual divider
// is role="presentation" to stay out of the accessibility tree
// (axe aria-required-children, WCAG 1.3.1/4.1.2).
elements.push(
<Divider key={`divider-${i}`} xstyle={styles.divider} />,
<Divider
key={`divider-${i}`}
role="presentation"
xstyle={styles.divider}
/>,
);
}
} else if (isSection(option)) {
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/Selector/Selector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2484,6 +2484,29 @@ describe('Selector section headings', () => {
// visible heading must not announce it a second time.
expect(heading).toHaveAttribute('aria-hidden', 'true');
});

it('renders a divider inside the listbox as presentation, not a separator', async () => {
// A role="separator" is not a permitted child of role="listbox" (axe
// aria-required-children, WCAG 1.3.1/4.1.2). A visual divider between
// options must stay out of the accessibility tree.
const user = userEvent.setup();
render(
<Selector
label="Fruit"
options={['Almond', {type: 'divider'}, 'Banana']}
value={undefined}
onChange={() => {}}
/>,
);
await user.click(screen.getByRole('combobox'));

const listbox = screen.getByRole('listbox', {hidden: true});
const dividerInListbox = [...listbox.children].find(el =>
el.className.includes('astryx-divider'),
);
expect(dividerInListbox).toBeTruthy();
expect(dividerInListbox).toHaveAttribute('role', 'presentation');
});
});

describe('Selector search focus ring', () => {
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/Selector/Selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1344,7 +1344,17 @@ export function Selector<T extends SelectorOptionType>(
if (isSearching) {
continue;
}
elements.push(<Divider key={`divider-${i}`} xstyle={styles.divider} />);
// A divider inside role="listbox" must not be a separator child:
// listbox only permits option/group children, so the visual divider
// is role="presentation" to stay out of the accessibility tree
// (axe aria-required-children, WCAG 1.3.1/4.1.2).
elements.push(
<Divider
key={`divider-${i}`}
role="presentation"
xstyle={styles.divider}
/>,
);
} else if (isSection(option)) {
const sectionItems: ReactNode[] = [];
for (const opt of option.options) {
Expand Down