diff --git a/packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyled.test.tsx b/packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyled.test.tsx index 0efd2271ef89dd..1a00afc9697870 100644 --- a/packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyled.test.tsx +++ b/packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyled.test.tsx @@ -12,6 +12,7 @@ import { userEvent, act, fireEvent, + screen, } from 'test/utils'; describe('MultiSelectUnstyled', () => { @@ -53,13 +54,13 @@ describe('MultiSelectUnstyled', () => { it(`opens the dropdown when the "${key}" key is down on the button`, () => { // can't use the default native `button` as it doesn't treat enter or space press as a click const { getByRole } = render(); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.focus(); + select.focus(); }); - fireEvent.keyDown(button, { key }); + fireEvent.keyDown(select, { key }); - expect(button).to.have.attribute('aria-expanded', 'true'); + expect(select).to.have.attribute('aria-expanded', 'true'); expect(getByRole('listbox')).not.to.equal(null); expect(document.activeElement).to.equal(getByRole('listbox')); }); @@ -68,13 +69,13 @@ describe('MultiSelectUnstyled', () => { it(`opens the dropdown when the " " key is let go on the button`, () => { // can't use the default native `button` as it doesn't treat enter or space press as a click const { getByRole } = render(); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.focus(); + select.focus(); }); - fireEvent.keyUp(button, { key: ' ' }); + fireEvent.keyUp(select, { key: ' ' }); - expect(button).to.have.attribute('aria-expanded', 'true'); + expect(select).to.have.attribute('aria-expanded', 'true'); expect(getByRole('listbox')).not.to.equal(null); expect(document.activeElement).to.equal(getByRole('listbox')); }); @@ -89,9 +90,9 @@ describe('MultiSelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const listbox = getByRole('listbox'); @@ -100,7 +101,7 @@ describe('MultiSelectUnstyled', () => { userEvent.keyPress(listbox, { key: 'ArrowDown' }); // highlights '2' userEvent.keyPress(listbox, { key }); - expect(button).to.have.text('2'); + expect(select).to.have.text('2'); }), ); }); @@ -113,18 +114,18 @@ describe('MultiSelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const listbox = getByRole('listbox'); userEvent.keyPress(listbox, { key: 'ArrowDown' }); // highlights '2' userEvent.keyPress(listbox, { key: 'Escape' }); - expect(button).to.have.attribute('aria-expanded', 'false'); - expect(button).to.have.text('1'); + expect(select).to.have.attribute('aria-expanded', 'false'); + expect(select).to.have.text('1'); expect(queryByRole('listbox')).to.equal(null); }); }); @@ -243,9 +244,9 @@ describe('MultiSelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const optionTwo = getByText('Two'); @@ -299,7 +300,7 @@ describe('MultiSelectUnstyled', () => { , ); - expect(getByRole('button')).to.have.text('One (1), Two (2)'); + expect(getByRole('combobox')).to.have.text('One (1), Two (2)'); }); it('renders the selected values as comma-separated list of labels if renderValue is not provided', () => { @@ -310,7 +311,94 @@ describe('MultiSelectUnstyled', () => { , ); - expect(getByRole('button')).to.have.text('One, Two'); + expect(getByRole('combobox')).to.have.text('One, Two'); + }); + }); + + // according to WAI-ARIA 1.2 (https://www.w3.org/TR/wai-aria-1.2/#combobox) + describe('a11y attributes', () => { + it('should have the `combobox` role', () => { + render( + + One + , + ); + + expect(screen.queryByRole('combobox')).not.to.equal(null); + }); + + it('should have the aria-haspopup listbox', () => { + render( + + One + , + ); + + expect(screen.getByRole('combobox')).to.have.attribute('aria-haspopup', 'listbox'); + }); + + it('should have the aria-expanded attribute', () => { + render( + + One + , + ); + + expect(screen.getByRole('combobox')).to.have.attribute('aria-expanded', 'false'); + }); + + it('should have the aria-expanded attribute set to true when the listbox is open', () => { + render( + + One + , + ); + + const select = screen.getByRole('combobox'); + act(() => { + select.click(); + }); + + expect(select).to.have.attribute('aria-expanded', 'true'); + }); + + it('should have the aria-controls attribute', () => { + render( + + One + , + ); + + const select = screen.getByRole('combobox'); + + act(() => { + select.click(); + }); + + const listbox = screen.getByRole('listbox'); + const listboxId = listbox.getAttribute('id'); + expect(listboxId).not.to.equal(null); + + expect(select).to.have.attribute('aria-controls', listboxId!); + }); + + it('should have the aria-activedescendant attribute', () => { + render( + + One + , + ); + + const select = screen.getByRole('combobox'); + act(() => { + select.click(); + }); + + const listbox = screen.getByRole('listbox'); + fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + + const options = screen.getAllByRole('option'); + expect(listbox).to.have.attribute('aria-activedescendant', options[0].getAttribute('id')!); }); }); @@ -365,10 +453,10 @@ describe('MultiSelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const listbox = getByRole('listbox'); @@ -379,8 +467,8 @@ describe('MultiSelectUnstyled', () => { focusTarget.focus(); }); - expect(button).to.have.attribute('aria-expanded', 'false'); - expect(button).to.have.text('1'); + expect(select).to.have.attribute('aria-expanded', 'false'); + expect(select).to.have.text('1'); }); it('focuses the listbox after it is opened', () => { @@ -390,9 +478,9 @@ describe('MultiSelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); expect(document.activeElement).to.equal(getByRole('listbox')); diff --git a/packages/mui-base/src/SelectUnstyled/SelectUnstyled.test.tsx b/packages/mui-base/src/SelectUnstyled/SelectUnstyled.test.tsx index c8377d2e2c102c..2e6b513871a31a 100644 --- a/packages/mui-base/src/SelectUnstyled/SelectUnstyled.test.tsx +++ b/packages/mui-base/src/SelectUnstyled/SelectUnstyled.test.tsx @@ -14,6 +14,7 @@ import { fireEvent, userEvent, act, + screen, } from 'test/utils'; describe('SelectUnstyled', () => { @@ -55,13 +56,13 @@ describe('SelectUnstyled', () => { it(`opens the dropdown when the "${key}" key is down on the button`, () => { // can't use the default native `button` as it doesn't treat enter or space press as a click const { getByRole } = render(); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.focus(); + select.focus(); }); - fireEvent.keyDown(button, { key }); + fireEvent.keyDown(select, { key }); - expect(button).to.have.attribute('aria-expanded', 'true'); + expect(select).to.have.attribute('aria-expanded', 'true'); expect(getByRole('listbox')).not.to.equal(null); expect(document.activeElement).to.equal(getByRole('listbox')); }); @@ -70,13 +71,13 @@ describe('SelectUnstyled', () => { it(`opens the dropdown when the " " key is let go on the button`, () => { // can't use the default native `button` as it doesn't treat enter or space press as a click const { getByRole } = render(); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.focus(); + select.focus(); }); - fireEvent.keyUp(button, { key: ' ' }); + fireEvent.keyUp(select, { key: ' ' }); - expect(button).to.have.attribute('aria-expanded', 'true'); + expect(select).to.have.attribute('aria-expanded', 'true'); expect(getByRole('listbox')).not.to.equal(null); expect(document.activeElement).to.equal(getByRole('listbox')); }); @@ -88,15 +89,15 @@ describe('SelectUnstyled', () => { 1 , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const listbox = getByRole('listbox'); userEvent.keyPress(listbox, { key }); - expect(button).to.have.attribute('aria-expanded', 'false'); + expect(select).to.have.attribute('aria-expanded', 'false'); expect(queryByRole('listbox')).to.equal(null); }); }); @@ -111,9 +112,9 @@ describe('SelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const listbox = getByRole('listbox'); @@ -122,7 +123,7 @@ describe('SelectUnstyled', () => { userEvent.keyPress(listbox, { key: 'ArrowDown' }); // highlights '2' userEvent.keyPress(listbox, { key }); - expect(button).to.have.text('2'); + expect(select).to.have.text('2'); }), ); }); @@ -140,9 +141,9 @@ describe('SelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const listbox = getByRole('listbox'); @@ -167,9 +168,9 @@ describe('SelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const listbox = getByRole('listbox'); @@ -208,9 +209,9 @@ describe('SelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const listbox = getByRole('listbox'); @@ -238,9 +239,9 @@ describe('SelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const listbox = getByRole('listbox'); @@ -264,9 +265,9 @@ describe('SelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const listbox = getByRole('listbox'); @@ -290,9 +291,9 @@ describe('SelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const listbox = getByRole('listbox'); @@ -322,18 +323,18 @@ describe('SelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const listbox = getByRole('listbox'); userEvent.keyPress(listbox, { key: 'ArrowDown' }); // highlights '2' userEvent.keyPress(listbox, { key: 'Escape' }); - expect(button).to.have.attribute('aria-expanded', 'false'); - expect(button).to.have.text('1'); + expect(select).to.have.attribute('aria-expanded', 'false'); + expect(select).to.have.text('1'); }); }); @@ -464,9 +465,9 @@ describe('SelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const optionTwo = getByText('Two'); @@ -493,7 +494,7 @@ describe('SelectUnstyled', () => { , ); - expect(getByRole('button')).to.have.text('One (1)'); + expect(getByRole('combobox')).to.have.text('One (1)'); }); it('renders the selected values as a label if renderValue is not provided', () => { @@ -504,7 +505,94 @@ describe('SelectUnstyled', () => { , ); - expect(getByRole('button')).to.have.text('One'); + expect(getByRole('combobox')).to.have.text('One'); + }); + }); + + // according to WAI-ARIA 1.2 (https://www.w3.org/TR/wai-aria-1.2/#combobox) + describe('a11y attributes', () => { + it('should have the `combobox` role', () => { + render( + + One + , + ); + + expect(screen.queryByRole('combobox')).not.to.equal(null); + }); + + it('should have the aria-haspopup listbox', () => { + render( + + One + , + ); + + expect(screen.getByRole('combobox')).to.have.attribute('aria-haspopup', 'listbox'); + }); + + it('should have the aria-expanded attribute', () => { + render( + + One + , + ); + + expect(screen.getByRole('combobox')).to.have.attribute('aria-expanded', 'false'); + }); + + it('should have the aria-expanded attribute set to true when the listbox is open', () => { + render( + + One + , + ); + + const select = screen.getByRole('combobox'); + act(() => { + select.click(); + }); + + expect(select).to.have.attribute('aria-expanded', 'true'); + }); + + it('should have the aria-controls attribute', () => { + render( + + One + , + ); + + const select = screen.getByRole('combobox'); + + act(() => { + select.click(); + }); + + const listbox = screen.getByRole('listbox'); + const listboxId = listbox.getAttribute('id'); + expect(listboxId).not.to.equal(null); + + expect(select).to.have.attribute('aria-controls', listboxId!); + }); + + it('should have the aria-activedescendant attribute', () => { + render( + + One + , + ); + + const select = screen.getByRole('combobox'); + act(() => { + select.click(); + }); + + const listbox = screen.getByRole('listbox'); + fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + + const options = screen.getAllByRole('option'); + expect(listbox).to.have.attribute('aria-activedescendant', options[0].getAttribute('id')!); }); }); @@ -521,10 +609,10 @@ describe('SelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const listbox = getByRole('listbox'); @@ -535,8 +623,8 @@ describe('SelectUnstyled', () => { focusTarget.focus(); }); - expect(button).to.have.attribute('aria-expanded', 'false'); - expect(button).to.have.text('1'); + expect(select).to.have.attribute('aria-expanded', 'false'); + expect(select).to.have.text('1'); }); it('closes the listbox when already selected option is selected again with a click', () => { @@ -549,17 +637,17 @@ describe('SelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); const selectedOption = getByTestId('selected-option'); fireEvent.click(selectedOption); - expect(button).to.have.attribute('aria-expanded', 'false'); - expect(button).to.have.text('1'); + expect(select).to.have.attribute('aria-expanded', 'false'); + expect(select).to.have.text('1'); }); it('focuses the listbox after it is opened', () => { @@ -569,9 +657,9 @@ describe('SelectUnstyled', () => { , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.click(); + select.click(); }); expect(document.activeElement).to.equal(getByRole('listbox')); diff --git a/packages/mui-base/src/SelectUnstyled/useSelect.ts b/packages/mui-base/src/SelectUnstyled/useSelect.ts index 03790c4af235df..34606663cdb63a 100644 --- a/packages/mui-base/src/SelectUnstyled/useSelect.ts +++ b/packages/mui-base/src/SelectUnstyled/useSelect.ts @@ -2,6 +2,7 @@ import * as React from 'react'; import { unstable_useControlled as useControlled, unstable_useForkRef as useForkRef, + unstable_useId as useId, } from '@mui/utils'; import { useButton } from '../ButtonUnstyled'; import { @@ -32,7 +33,7 @@ function useSelect(props: UseSelectParameters) { buttonRef: buttonRefProp, defaultValue, disabled = false, - listboxId, + listboxId: listboxIdProp, listboxRef: listboxRefProp, multiple = false, onChange, @@ -47,6 +48,7 @@ function useSelect(props: UseSelectParameters) { const handleButtonRef = useForkRef(buttonRefProp, buttonRef); const listboxRef = React.useRef(null); + const listboxId = useId(listboxIdProp); const [value, setValue] = useControlled({ controlled: valueProp, @@ -262,8 +264,10 @@ function useSelect(props: UseSelectParameters) { onMouseDown: createHandleMouseDown(otherHandlers), onKeyDown: createHandleButtonKeyDown(otherHandlers), }), + role: 'combobox' as const, 'aria-expanded': open, 'aria-haspopup': 'listbox' as const, + 'aria-controls': listboxId, }; }; diff --git a/packages/mui-base/test/integration/SelectUnstyled.test.tsx b/packages/mui-base/test/integration/SelectUnstyled.test.tsx index 3480e04462c295..f8e177b2fd2ffa 100644 --- a/packages/mui-base/test/integration/SelectUnstyled.test.tsx +++ b/packages/mui-base/test/integration/SelectUnstyled.test.tsx @@ -40,7 +40,7 @@ describe(' integration', () => { , ); - const select = getByRole('button'); + const select = getByRole('combobox'); act(() => { select.focus(); diff --git a/packages/mui-joy/src/FormControl/FormControl.test.tsx b/packages/mui-joy/src/FormControl/FormControl.test.tsx index f89bdfe390ac62..d75a0b75e41332 100644 --- a/packages/mui-joy/src/FormControl/FormControl.test.tsx +++ b/packages/mui-joy/src/FormControl/FormControl.test.tsx @@ -162,7 +162,7 @@ describe('', () => { ); const label = container.querySelector('label'); - expect(getByRole('button')).to.have.attribute('aria-labelledby', label?.id); + expect(getByRole('combobox')).to.have.attribute('aria-labelledby', label?.id); }); it('should inherit color prop from FormControl', () => { diff --git a/packages/mui-joy/src/Select/Select.test.tsx b/packages/mui-joy/src/Select/Select.test.tsx index d8628b0016acf1..58c61a1e5e0510 100644 --- a/packages/mui-joy/src/Select/Select.test.tsx +++ b/packages/mui-joy/src/Select/Select.test.tsx @@ -35,7 +35,7 @@ describe('Joy , ); - expect(screen.getByRole('button')).to.have.text('Ten'); + expect(screen.getByRole('combobox')).to.have.text('Ten'); }); specify('the trigger is in tab order', () => { @@ -45,7 +45,7 @@ describe('Joy , ); - expect(getByRole('button')).to.have.property('tabIndex', 0); + expect(getByRole('combobox')).to.have.property('tabIndex', 0); }); it('should accept null child', () => { @@ -72,13 +72,13 @@ describe('Joy , ); - const button = getByRole('button'); + const select = getByRole('combobox'); act(() => { - button.focus(); + select.focus(); }); act(() => { - button.blur(); + select.blur(); }); expect(handleBlur.callCount).to.equal(1); @@ -106,7 +106,7 @@ describe('Joy ); - fireEvent.keyDown(getByRole('button'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByRole('combobox'), { key: 'ArrowDown' }); expect(getByRole('listbox')).toHaveFocus(); }); @@ -121,7 +121,7 @@ describe('Joy , ); - fireEvent.click(getByRole('button')); + fireEvent.click(getByRole('combobox')); act(() => { getAllByRole('option')[1].click(); }); @@ -139,7 +139,7 @@ describe('Joy , ); - fireEvent.click(getByRole('button')); + fireEvent.click(getByRole('combobox')); act(() => { getAllByRole('option')[1].click(); }); @@ -151,7 +151,7 @@ describe('Joy ); - expect(getByRole('button', { hidden: true })).to.have.attribute('aria-expanded', 'true'); + expect(getByRole('combobox', { hidden: true })).to.have.attribute('aria-expanded', 'true'); }); }); @@ -214,7 +214,7 @@ describe('Joy , ); - expect(getByRole('button')).to.have.text('Twenty'); + expect(getByRole('combobox')).to.have.text('Twenty'); }); }); @@ -270,32 +270,32 @@ describe('Joy ); - expect(getByRole('button', { hidden: true })).to.have.attribute('aria-expanded', 'true'); + expect(getByRole('combobox', { hidden: true })).to.have.attribute('aria-expanded', 'true'); }); specify('ARIA 1.2: aria-expanded="false" if the listbox isnt displayed', () => { const { getByRole } = render(); - // expect(getByRole('button')).to.have.attribute('aria-disabled', 'true'); + // expect(getByRole('combobox')).to.have.attribute('aria-disabled', 'true'); // }); specify('aria-disabled is not present if component is not disabled', () => { const { getByRole } = render(); - expect(getByRole('button')).to.have.attribute('aria-haspopup', 'listbox'); + expect(getByRole('combobox')).to.have.attribute('aria-haspopup', 'listbox'); }); it('renders an element with listbox behavior', () => { @@ -369,7 +369,7 @@ describe('Joy ); - expect(getByRole('button')).not.to.have.attribute('aria-labelledby'); + expect(getByRole('combobox')).not.to.have.attribute('aria-labelledby'); }); specify('the list of options is not labelled by default', () => { @@ -386,7 +386,7 @@ describe('Joy ', () => { , ); - expect(getByRole('button')).to.have.text('0b100'); + expect(getByRole('combobox')).to.have.text('0b100'); }); }); @@ -410,7 +410,7 @@ describe('Joy ); - expect(getByRole('button')).not.to.have.attribute('id'); + expect(getByRole('combobox')).not.to.have.attribute('id'); }); }); @@ -479,7 +479,7 @@ describe('Joy ', () => { getByTestId('test-element').click(); }); - expect(getByRole('button', { hidden: true })).to.have.attribute('aria-expanded', 'true'); + expect(getByRole('combobox', { hidden: true })).to.have.attribute('aria-expanded', 'true'); }); it('should not show dropdown if stop propagation is handled', () => { @@ -632,7 +632,7 @@ describe('Joy