Skip to content

Commit 79a6df1

Browse files
authored
FilteredActionList: Add opt-in props to give more flexibility on focus management with aria-activedescendant (#7240)
1 parent 4c53379 commit 79a6df1

File tree

7 files changed

+206
-5
lines changed

7 files changed

+206
-5
lines changed

.changeset/chubby-wombats-start.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
FilteredActionList: Adds new prop `setInitialFocus` which will prevent `aria-activedescendant` from being set until user action

.changeset/many-planets-take.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
FilteredActionList: Add prop `disableSelectOnHover` which will disable the ability where hovering over an item sets it as the `aria-activedescendant` value

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
"@github/tab-container-element": "^4.8.2",
8080
"@lit-labs/react": "1.2.1",
8181
"@oddbird/popover-polyfill": "^0.5.2",
82-
"@primer/behaviors": "^1.8.2",
82+
"@primer/behaviors": "^1.9.0",
8383
"@primer/live-region-element": "^0.7.1",
8484
"@primer/octicons-react": "^19.13.0",
8585
"@primer/primitives": "10.x || 11.x",

packages/react/src/FilteredActionList/FilteredActionList.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ export interface FilteredActionListProps extends Partial<Omit<GroupedListProps,
8080
* @default 'active-descendant'
8181
*/
8282
_PrivateFocusManagement?: 'roving-tabindex' | 'active-descendant'
83+
/**
84+
* If true, disables selecting items when hovering over them with the mouse.
85+
*/
86+
disableSelectOnHover?: boolean
87+
/**
88+
* If true, focus remains where it was and the user must interact to move focus.
89+
* If false, sets initial focus to the first item in the list when rendered, enabling keyboard navigation immediately.
90+
*/
91+
setInitialFocus?: boolean
8392
}
8493

8594
export function FilteredActionList({
@@ -106,6 +115,8 @@ export function FilteredActionList({
106115
actionListProps,
107116
focusOutBehavior = 'wrap',
108117
_PrivateFocusManagement = 'active-descendant',
118+
disableSelectOnHover = false,
119+
setInitialFocus = false,
109120
...listProps
110121
}: FilteredActionListProps): JSX.Element {
111122
const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '')
@@ -233,6 +244,8 @@ export function FilteredActionList({
233244
scrollIntoView(current, scrollContainerRef.current, menuScrollMargins)
234245
}
235246
},
247+
focusInStrategy: setInitialFocus ? 'initial' : 'previous',
248+
ignoreHoverEvents: disableSelectOnHover,
236249
}
237250
: undefined,
238251
[listContainerElement, usingRovingTabindex],

packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,3 +381,65 @@ export const LotsOfItems = () => {
381381
</>
382382
)
383383
}
384+
385+
export const WithDisableOnHover = ({onCancel, secondaryAction}: ParamProps) => {
386+
const [selected, setSelected] = useState<ItemInput[]>(simpleItems.slice(1, 3))
387+
const [filter, setFilter] = useState('')
388+
const filteredItems = simpleItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))
389+
const [open, setOpen] = useState(false)
390+
391+
return (
392+
<SelectPanel
393+
title="Select labels"
394+
placeholder="Select labels"
395+
subtitle="Use labels to organize issues and pull requests"
396+
renderAnchor={({children, ...anchorProps}) => (
397+
<Button trailingAction={TriangleDownIcon} {...anchorProps} aria-haspopup="dialog">
398+
{children}
399+
</Button>
400+
)}
401+
open={open}
402+
onOpenChange={setOpen}
403+
items={filteredItems}
404+
selected={selected}
405+
onSelectedChange={setSelected}
406+
onFilterChange={setFilter}
407+
width="medium"
408+
message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined}
409+
onCancel={onCancel}
410+
secondaryAction={secondaryAction}
411+
disableSelectOnHover
412+
/>
413+
)
414+
}
415+
416+
export const WithInitialFocusEnabled = ({onCancel, secondaryAction}: ParamProps) => {
417+
const [selected, setSelected] = useState<ItemInput[]>(simpleItems.slice(1, 3))
418+
const [filter, setFilter] = useState('')
419+
const filteredItems = simpleItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))
420+
const [open, setOpen] = useState(false)
421+
422+
return (
423+
<SelectPanel
424+
title="Select labels"
425+
placeholder="Select labels"
426+
subtitle="Use labels to organize issues and pull requests"
427+
renderAnchor={({children, ...anchorProps}) => (
428+
<Button trailingAction={TriangleDownIcon} {...anchorProps} aria-haspopup="dialog">
429+
{children}
430+
</Button>
431+
)}
432+
open={open}
433+
onOpenChange={setOpen}
434+
items={filteredItems}
435+
selected={selected}
436+
onSelectedChange={setSelected}
437+
onFilterChange={setFilter}
438+
width="medium"
439+
message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined}
440+
onCancel={onCancel}
441+
secondaryAction={secondaryAction}
442+
setInitialFocus={true}
443+
/>
444+
)
445+
}

packages/react/src/SelectPanel/SelectPanel.test.tsx

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,6 +1519,122 @@ for (const usingRemoveActiveDescendant of [false, true]) {
15191519
expect(selectAllCheckbox).toHaveProperty('indeterminate', true)
15201520
})
15211521
})
1522+
1523+
describe('disableSelectOnHover', () => {
1524+
it('should not update aria-activedescendant when hovering over items when disableSelectOnHover is true', async () => {
1525+
const user = userEvent.setup()
1526+
1527+
render(<BasicSelectPanel disableSelectOnHover={true} />)
1528+
1529+
await user.click(screen.getByText('Select items'))
1530+
1531+
const input = screen.getByPlaceholderText('Filter items')
1532+
const options = screen.getAllByRole('option')
1533+
1534+
// Initially, aria-activedescendant should not be set if setInitialFocus is false (default)
1535+
const initialActiveDescendant = input.getAttribute('aria-activedescendant')
1536+
1537+
// Hover over the first item
1538+
await user.hover(options[0])
1539+
1540+
// aria-activedescendant should not change when disableSelectOnHover is true
1541+
expect(input.getAttribute('aria-activedescendant')).toBe(initialActiveDescendant)
1542+
1543+
// Hover over the second item
1544+
await user.hover(options[1])
1545+
1546+
// aria-activedescendant should still not change
1547+
expect(input.getAttribute('aria-activedescendant')).toBe(initialActiveDescendant)
1548+
})
1549+
1550+
it('should update aria-activedescendant when hovering over items when disableSelectOnHover is false (default)', async () => {
1551+
const user = userEvent.setup()
1552+
1553+
render(<BasicSelectPanel />)
1554+
1555+
await user.click(screen.getByText('Select items'))
1556+
1557+
const input = screen.getByPlaceholderText('Filter items')
1558+
const options = screen.getAllByRole('option')
1559+
1560+
// Hover over the first item
1561+
await user.hover(options[0])
1562+
1563+
// aria-activedescendant should be set to the first item
1564+
expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id)
1565+
1566+
// Hover over the second item
1567+
await user.hover(options[1])
1568+
1569+
// aria-activedescendant should be updated to the second item
1570+
expect(input.getAttribute('aria-activedescendant')).toBe(options[1].id)
1571+
})
1572+
})
1573+
1574+
describe('setInitialFocus', () => {
1575+
it('should not set aria-activedescendant until user interaction when setInitialFocus is true', async () => {
1576+
const user = userEvent.setup()
1577+
1578+
render(<BasicSelectPanel setInitialFocus={true} />)
1579+
1580+
await user.click(screen.getByText('Select items'))
1581+
1582+
const input = screen.getByPlaceholderText('Filter items')
1583+
const options = screen.getAllByRole('option')
1584+
1585+
// Initially, aria-activedescendant should not be set
1586+
expect(input.getAttribute('aria-activedescendant')).toBeFalsy()
1587+
1588+
// User interacts with keyboard
1589+
await user.keyboard('{ArrowDown}')
1590+
1591+
// Now aria-activedescendant should be set to the first item
1592+
expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id)
1593+
})
1594+
1595+
it('should set aria-activedescendant to the first item on mount when setInitialFocus is false (default)', async () => {
1596+
const user = userEvent.setup()
1597+
1598+
render(<BasicSelectPanel />)
1599+
1600+
await user.click(screen.getByText('Select items'))
1601+
1602+
const input = screen.getByPlaceholderText('Filter items')
1603+
const options = screen.getAllByRole('option')
1604+
1605+
// Wait a tick for the effect to run
1606+
await new Promise(resolve => setTimeout(resolve, 0))
1607+
1608+
// aria-activedescendant should be set to the first item
1609+
expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id)
1610+
})
1611+
1612+
it('should not set aria-activedescendant on mouse hover until after first interaction when setInitialFocus is true', async () => {
1613+
const user = userEvent.setup()
1614+
1615+
render(<BasicSelectPanel setInitialFocus={true} />)
1616+
1617+
await user.click(screen.getByText('Select items'))
1618+
1619+
const input = screen.getByPlaceholderText('Filter items')
1620+
const options = screen.getAllByRole('option')
1621+
1622+
// Initially, aria-activedescendant should not be set
1623+
expect(input.getAttribute('aria-activedescendant')).toBeFalsy()
1624+
1625+
// Hover over the first item (this is the first interaction)
1626+
await user.hover(options[0])
1627+
1628+
// Now aria-activedescendant should be set to the first item
1629+
expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id)
1630+
1631+
// Hover over the second item
1632+
await user.hover(options[1])
1633+
1634+
// aria-activedescendant should update to the second item
1635+
expect(input.getAttribute('aria-activedescendant')).toBe(options[1].id)
1636+
})
1637+
})
15221638
})
15231639

15241640
describe('Event propagation', () => {

0 commit comments

Comments
 (0)