diff --git a/.changeset/chubby-wombats-start.md b/.changeset/chubby-wombats-start.md new file mode 100644 index 00000000000..d6ab8790547 --- /dev/null +++ b/.changeset/chubby-wombats-start.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +FilteredActionList: Adds new prop `setInitialFocus` which will prevent `aria-activedescendant` from being set until user action diff --git a/.changeset/many-planets-take.md b/.changeset/many-planets-take.md new file mode 100644 index 00000000000..034ee5b2755 --- /dev/null +++ b/.changeset/many-planets-take.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +FilteredActionList: Add prop `disableSelectOnHover` which will disable the ability where hovering over an item sets it as the `aria-activedescendant` value diff --git a/package-lock.json b/package-lock.json index 57ec80f12e4..e74f2438e9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6480,9 +6480,9 @@ } }, "node_modules/@primer/behaviors": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.8.2.tgz", - "integrity": "sha512-qIiMXxJQImuV4CFHhzDvnjBThtZue7LsJ03fLDSG7FuTcm1CSYkNnCLfJqnyDqY9LmVz7WJ6rvCGhOVEW8fLcA==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.9.0.tgz", + "integrity": "sha512-MIVSZlQGJzas44DZDwo4Dqiev289K918oDl/AcA+zTpQAjp+PrJ0LUxEnrSOapyivLYpRpmSCeF0XPh7NgXpCA==", "license": "MIT" }, "node_modules/@primer/css": { @@ -27224,7 +27224,7 @@ "@github/tab-container-element": "^4.8.2", "@lit-labs/react": "1.2.1", "@oddbird/popover-polyfill": "^0.5.2", - "@primer/behaviors": "^1.8.2", + "@primer/behaviors": "1.9.0", "@primer/live-region-element": "^0.7.1", "@primer/octicons-react": "^19.13.0", "@primer/primitives": "10.x || 11.x", diff --git a/packages/react/package.json b/packages/react/package.json index 2e8cf2c50ca..f27f072d510 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -79,7 +79,7 @@ "@github/tab-container-element": "^4.8.2", "@lit-labs/react": "1.2.1", "@oddbird/popover-polyfill": "^0.5.2", - "@primer/behaviors": "^1.8.2", + "@primer/behaviors": "^1.9.0", "@primer/live-region-element": "^0.7.1", "@primer/octicons-react": "^19.13.0", "@primer/primitives": "10.x || 11.x", diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index f5f1275bd7c..6589e9eb983 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -80,6 +80,15 @@ export interface FilteredActionListProps extends Partial { ) } + +export const WithDisableOnHover = ({onCancel, secondaryAction}: ParamProps) => { + const [selected, setSelected] = useState(simpleItems.slice(1, 3)) + const [filter, setFilter] = useState('') + const filteredItems = simpleItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) + const [open, setOpen] = useState(false) + + return ( + ( + + )} + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={setFilter} + width="medium" + message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined} + onCancel={onCancel} + secondaryAction={secondaryAction} + disableSelectOnHover + /> + ) +} + +export const WithInitialFocusEnabled = ({onCancel, secondaryAction}: ParamProps) => { + const [selected, setSelected] = useState(simpleItems.slice(1, 3)) + const [filter, setFilter] = useState('') + const filteredItems = simpleItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) + const [open, setOpen] = useState(false) + + return ( + ( + + )} + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={setFilter} + width="medium" + message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined} + onCancel={onCancel} + secondaryAction={secondaryAction} + setInitialFocus={true} + /> + ) +} diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index 522dae058a7..47ee19a457d 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -1519,6 +1519,122 @@ for (const usingRemoveActiveDescendant of [false, true]) { expect(selectAllCheckbox).toHaveProperty('indeterminate', true) }) }) + + describe('disableSelectOnHover', () => { + it('should not update aria-activedescendant when hovering over items when disableSelectOnHover is true', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText('Select items')) + + const input = screen.getByPlaceholderText('Filter items') + const options = screen.getAllByRole('option') + + // Initially, aria-activedescendant should not be set if setInitialFocus is false (default) + const initialActiveDescendant = input.getAttribute('aria-activedescendant') + + // Hover over the first item + await user.hover(options[0]) + + // aria-activedescendant should not change when disableSelectOnHover is true + expect(input.getAttribute('aria-activedescendant')).toBe(initialActiveDescendant) + + // Hover over the second item + await user.hover(options[1]) + + // aria-activedescendant should still not change + expect(input.getAttribute('aria-activedescendant')).toBe(initialActiveDescendant) + }) + + it('should update aria-activedescendant when hovering over items when disableSelectOnHover is false (default)', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText('Select items')) + + const input = screen.getByPlaceholderText('Filter items') + const options = screen.getAllByRole('option') + + // Hover over the first item + await user.hover(options[0]) + + // aria-activedescendant should be set to the first item + expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id) + + // Hover over the second item + await user.hover(options[1]) + + // aria-activedescendant should be updated to the second item + expect(input.getAttribute('aria-activedescendant')).toBe(options[1].id) + }) + }) + + describe('setInitialFocus', () => { + it('should not set aria-activedescendant until user interaction when setInitialFocus is true', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText('Select items')) + + const input = screen.getByPlaceholderText('Filter items') + const options = screen.getAllByRole('option') + + // Initially, aria-activedescendant should not be set + expect(input.getAttribute('aria-activedescendant')).toBeFalsy() + + // User interacts with keyboard + await user.keyboard('{ArrowDown}') + + // Now aria-activedescendant should be set to the first item + expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id) + }) + + it('should set aria-activedescendant to the first item on mount when setInitialFocus is false (default)', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText('Select items')) + + const input = screen.getByPlaceholderText('Filter items') + const options = screen.getAllByRole('option') + + // Wait a tick for the effect to run + await new Promise(resolve => setTimeout(resolve, 0)) + + // aria-activedescendant should be set to the first item + expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id) + }) + + it('should not set aria-activedescendant on mouse hover until after first interaction when setInitialFocus is true', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText('Select items')) + + const input = screen.getByPlaceholderText('Filter items') + const options = screen.getAllByRole('option') + + // Initially, aria-activedescendant should not be set + expect(input.getAttribute('aria-activedescendant')).toBeFalsy() + + // Hover over the first item (this is the first interaction) + await user.hover(options[0]) + + // Now aria-activedescendant should be set to the first item + expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id) + + // Hover over the second item + await user.hover(options[1]) + + // aria-activedescendant should update to the second item + expect(input.getAttribute('aria-activedescendant')).toBe(options[1].id) + }) + }) }) describe('Event propagation', () => {