Skip to content

Commit 05a835f

Browse files
committed
Reacreare PR-5801 to remove aria activedescendant and add a roving tab index
1 parent a5a0afe commit 05a835f

File tree

9 files changed

+197
-178
lines changed

9 files changed

+197
-178
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
SelectPanel: Remove `aria-activedescendant` from SelectPanel
6+
7+
- Remove aria-activedescendant pattern from modern ActionList implementation (when `primer_react_select_panel_with_modern_action_list` feature flag is enabled)
8+
- Replace with roving tabindex behavior for better accessibility
9+
- Simplify announcement logic since roving tabindex provides focus announcements automatically
10+
- This change only affects SelectPanel when using the modern ActionList feature flag

packages/react/src/ActionList/ActionListContainerContext.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {AriaRole} from '../utils/types'
66
type ContextProps = {
77
container?: string
88
listRole?: AriaRole
9-
selectionVariant?: 'single' | 'multiple'
9+
selectionVariant?: 'single' | 'multiple' | 'radio'
1010
selectionAttribute?: 'aria-selected' | 'aria-checked'
1111
listLabelledBy?: string
1212
// This can be any function, we don't know anything about the arguments

packages/react/src/ActionList/Item.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
9191
const inactive = Boolean(inactiveText)
9292
// TODO change `menuContext` check to ```listRole !== undefined && ['menu', 'listbox'].includes(listRole)```
9393
// once we have a better way to handle existing usage in dotcom that incorrectly use ActionList.TrailingAction
94-
const menuContext = container === 'ActionMenu' || container === 'SelectPanel'
94+
const menuContext = container === 'ActionMenu' || container === 'SelectPanel' || container === 'FilteredActionList'
9595
// TODO: when we change `menuContext` to check `listRole` instead of `container`
9696
const showInactiveIndicator = inactive && !(listRole !== undefined && ['menu', 'listbox'].includes(listRole))
9797

@@ -118,7 +118,7 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
118118
if (selectionVariant === 'single') inferredItemRole = 'menuitemradio'
119119
else if (selectionVariant === 'multiple') inferredItemRole = 'menuitemcheckbox'
120120
else inferredItemRole = 'menuitem'
121-
} else if (listRole === 'listbox') {
121+
} else if (container && ['SelectPanel', 'FilteredActionList'].includes(container) && listRole === 'listbox') {
122122
if (selectionVariant !== undefined && !role) inferredItemRole = 'option'
123123
}
124124

packages/react/src/ActionList/List.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const List = React.forwardRef<HTMLUListElement, ActionListProps>(
2828
listLabelledBy,
2929
selectionVariant: containerSelectionVariant, // TODO: Remove after DropdownMenu2 deprecation
3030
enableFocusZone: enableFocusZoneFromContainer,
31+
container,
3132
} = React.useContext(ActionListContainerContext)
3233

3334
const ariaLabelledBy = slots.heading ? (slots.heading.props.id ?? headingId) : listLabelledBy
@@ -42,7 +43,7 @@ export const List = React.forwardRef<HTMLUListElement, ActionListProps>(
4243
disabled: !enableFocusZone,
4344
containerRef: listRef,
4445
bindKeys: FocusKeys.ArrowVertical | FocusKeys.HomeAndEnd | FocusKeys.PageUpDown,
45-
focusOutBehavior: listRole === 'menu' ? 'wrap' : undefined,
46+
focusOutBehavior: listRole === 'menu' || container === 'SelectPanel' || container === 'FilteredActionList' ? 'wrap' : undefined,
4647
})
4748

4849
return (

packages/react/src/FilteredActionList/FilteredActionListEntry.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import {FilteredActionList as WithDeprecatedActionList} from './FilteredActionLi
33
import {FilteredActionList as WithStableActionList} from './FilteredActionListWithModernActionList'
44
import {useFeatureFlag} from '../FeatureFlags'
55

6-
export function FilteredActionList(props: FilteredActionListProps): JSX.Element {
6+
export function FilteredActionList({onListContainerRefChanged, ...props}: FilteredActionListProps) {
77
const enabled = useFeatureFlag('primer_react_select_panel_with_modern_action_list')
88

99
if (enabled) return <WithStableActionList {...props} />
10-
else return <WithDeprecatedActionList {...props} />
10+
else return <WithDeprecatedActionList onListContainerRefChanged={onListContainerRefChanged} {...props} />
1111
}
1212

1313
FilteredActionList.displayName = 'FilteredActionList'

packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx

Lines changed: 78 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import type {ScrollIntoViewOptions} from '@primer/behaviors'
2-
import {scrollIntoView, FocusKeys} from '@primer/behaviors'
1+
import {FocusKeys} from '@primer/behaviors'
32
import type {KeyboardEventHandler} from 'react'
43
import type React from 'react'
54
import {useCallback, useEffect, useRef, useState} from 'react'
@@ -10,7 +9,6 @@ import TextInput from '../TextInput'
109
import {get} from '../constants'
1110
import {ActionList} from '../ActionList'
1211
import type {GroupedListProps, ListPropsBase, ItemInput} from '../SelectPanel/types'
13-
import {useFocusZone} from '../hooks/useFocusZone'
1412
import {useId} from '../hooks/useId'
1513
import {useProvidedRefOrCreate} from '../hooks/useProvidedRefOrCreate'
1614
import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate'
@@ -20,14 +18,12 @@ import type {SxProp} from '../sx'
2018
import type {FilteredActionListLoadingType} from './FilteredActionListLoaders'
2119
import {FilteredActionListLoadingTypes, FilteredActionListBodyLoader} from './FilteredActionListLoaders'
2220
import classes from './FilteredActionList.module.css'
23-
21+
import {ActionListContainerContext} from '../ActionList/ActionListContainerContext'
2422
import {isValidElementType} from 'react-is'
2523
import type {RenderItemFn} from '../deprecated/ActionList/List'
2624
import {useAnnouncements} from './useAnnouncements'
2725
import {clsx} from 'clsx'
2826

29-
const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8}
30-
3127
export interface FilteredActionListProps
3228
extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>,
3329
ListPropsBase,
@@ -37,7 +33,6 @@ export interface FilteredActionListProps
3733
placeholderText?: string
3834
filterValue?: string
3935
onFilterChange: (value: string, e: React.ChangeEvent<HTMLInputElement>) => void
40-
onListContainerRefChanged?: (ref: HTMLElement | null) => void
4136
onInputRefChanged?: (ref: React.RefObject<HTMLInputElement>) => void
4237
textInputProps?: Partial<Omit<TextInputProps, 'onChange'>>
4338
inputRef?: React.RefObject<HTMLInputElement>
@@ -58,7 +53,6 @@ export function FilteredActionList({
5853
filterValue: externalFilterValue,
5954
loadingType = FilteredActionListLoadingTypes.bodySpinner,
6055
onFilterChange,
61-
onListContainerRefChanged,
6256
onInputRefChanged,
6357
items,
6458
textInputProps,
@@ -68,6 +62,7 @@ export function FilteredActionList({
6862
showItemDividers,
6963
message,
7064
className,
65+
selectionVariant,
7166
announcementsEnabled = true,
7267
fullScreenOnNarrow,
7368
...listProps
@@ -82,72 +77,66 @@ export function FilteredActionList({
8277
[onFilterChange, setInternalFilterValue],
8378
)
8479

80+
const [enableAnnouncements, setEnableAnnouncements] = useState(false)
81+
const [selectedItems, setSelectedItems] = useState<(string | number | undefined)[]>([])
82+
8583
const scrollContainerRef = useRef<HTMLDivElement>(null)
8684
const inputRef = useProvidedRefOrCreate<HTMLInputElement>(providedInputRef)
87-
const [listContainerElement, setListContainerElement] = useState<HTMLUListElement | null>(null)
88-
const activeDescendantRef = useRef<HTMLElement>()
85+
const listRef = useRef<HTMLUListElement>(null)
8986
const listId = useId()
9087
const inputDescriptionTextId = useId()
91-
const onInputKeyPress: KeyboardEventHandler = useCallback(
92-
event => {
93-
if (event.key === 'Enter' && activeDescendantRef.current) {
94-
event.preventDefault()
95-
event.nativeEvent.stopImmediatePropagation()
88+
const keydownListener = useCallback(
89+
(event: React.KeyboardEvent<HTMLDivElement>) => {
90+
if (event.key === 'ArrowDown') {
91+
if (listRef.current) {
92+
const firstSelectedItem = listRef.current.querySelector('[role="option"]') as HTMLElement | undefined
93+
firstSelectedItem?.focus()
9694

97-
// Forward Enter key press to active descendant so that item gets activated
98-
const activeDescendantEvent = new KeyboardEvent(event.type, event.nativeEvent)
99-
activeDescendantRef.current.dispatchEvent(activeDescendantEvent)
100-
}
101-
},
102-
[activeDescendantRef],
103-
)
95+
event.preventDefault()
96+
}
97+
} else if (event.key === 'Enter') {
98+
let firstItem
99+
// If there are groups, it's not guaranteed that the first item is the actual first item in the first -
100+
// as groups are rendered in the order of the groupId provided
101+
if (groupMetadata) {
102+
const firstGroup = groupMetadata[0].groupId
103+
firstItem = items.filter(item => item.groupId === firstGroup)[0]
104+
} else {
105+
firstItem = items[0]
106+
}
104107

105-
const listContainerRefCallback = useCallback(
106-
(node: HTMLUListElement | null) => {
107-
setListContainerElement(node)
108-
onListContainerRefChanged?.(node)
108+
if (firstItem.onAction) {
109+
firstItem.onAction(firstItem, event)
110+
event.preventDefault()
111+
}
112+
}
109113
},
110-
[onListContainerRefChanged],
114+
[items, groupMetadata],
111115
)
112116

113117
useEffect(() => {
114118
onInputRefChanged?.(inputRef)
115119
}, [inputRef, onInputRefChanged])
116120

117-
useFocusZone(
118-
{
119-
containerRef: {current: listContainerElement},
120-
bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown,
121-
focusOutBehavior: 'wrap',
122-
focusableElementFilter: element => {
123-
return !(element instanceof HTMLInputElement)
124-
},
125-
activeDescendantFocus: inputRef,
126-
onActiveDescendantChanged: (current, previous, directlyActivated) => {
127-
activeDescendantRef.current = current
128-
129-
if (current && scrollContainerRef.current && directlyActivated) {
130-
scrollIntoView(current, scrollContainerRef.current, menuScrollMargins)
131-
}
132-
},
133-
},
134-
[
135-
// List container isn't in the DOM while loading. Need to re-bind focus zone when it changes.
136-
listContainerElement,
137-
],
138-
)
139-
140121
useEffect(() => {
141-
// if items changed, we want to instantly move active descendant into view
142-
if (activeDescendantRef.current && scrollContainerRef.current) {
143-
scrollIntoView(activeDescendantRef.current, scrollContainerRef.current, {
144-
...menuScrollMargins,
145-
behavior: 'auto',
146-
})
122+
if (items.length === 0) {
123+
inputRef.current?.focus()
124+
} else {
125+
const itemIds = items.filter(item => item.selected).map(item => item.id)
126+
const removedItem = selectedItems.find(item => !itemIds.includes(item))
127+
if (removedItem && document.activeElement !== inputRef.current) {
128+
const list = listRef.current
129+
if (list) {
130+
const firstSelectedItem = list.querySelector('[role="option"]') as HTMLElement
131+
firstSelectedItem.focus()
132+
}
133+
}
147134
}
148-
}, [items])
135+
}, [items, inputRef, selectedItems])
149136

150-
useAnnouncements(items, {current: listContainerElement}, inputRef, announcementsEnabled, loading)
137+
useEffect(() => {
138+
setEnableAnnouncements(announcementsEnabled)
139+
}, [announcementsEnabled])
151140
useScrollFlash(scrollContainerRef)
152141

153142
function getItemListForEachGroup(groupId: string) {
@@ -170,36 +159,40 @@ export function FilteredActionList({
170159
}
171160

172161
return (
173-
<ActionList
174-
ref={listContainerRefCallback}
175-
showDividers={showItemDividers}
176-
{...listProps}
177-
role="listbox"
178-
id={listId}
179-
sx={{flexGrow: 1}}
162+
<ActionListContainerContext.Provider
163+
value={{
164+
container: 'FilteredActionList',
165+
listRole: 'listbox',
166+
selectionAttribute: 'aria-selected',
167+
selectionVariant,
168+
enableFocusZone: true,
169+
}}
180170
>
181-
{groupMetadata?.length
182-
? groupMetadata.map((group, index) => {
183-
return (
184-
<ActionList.Group key={index}>
185-
<ActionList.GroupHeading variant={group.header?.variant ? group.header.variant : undefined}>
186-
{group.header?.title ? group.header.title : `Group ${group.groupId}`}
187-
</ActionList.GroupHeading>
188-
{getItemListForEachGroup(group.groupId).map(({key: itemKey, ...item}, index) => {
189-
const key = itemKey ?? item.id?.toString() ?? index.toString()
190-
return <MappedActionListItem key={key} {...item} renderItem={listProps.renderItem} />
191-
})}
192-
</ActionList.Group>
193-
)
194-
})
195-
: items.map(({key: itemKey, ...item}, index) => {
196-
const key = itemKey ?? item.id?.toString() ?? index.toString()
197-
return <MappedActionListItem key={key} {...item} renderItem={listProps.renderItem} />
198-
})}
199-
</ActionList>
171+
<ActionList ref={listRef} showDividers={showItemDividers} {...listProps} id={listId} sx={{flexGrow: 1}}>
172+
{groupMetadata?.length
173+
? groupMetadata.map((group, index) => {
174+
return (
175+
<ActionList.Group key={index}>
176+
<ActionList.GroupHeading variant={group.header?.variant ? group.header.variant : undefined}>
177+
{group.header?.title ? group.header.title : `Group ${group.groupId}`}
178+
</ActionList.GroupHeading>
179+
{getItemListForEachGroup(group.groupId).map(({key: itemKey, ...item}, index) => {
180+
const key = itemKey ?? item.id?.toString() ?? index.toString()
181+
return <MappedActionListItem key={key} {...item} renderItem={listProps.renderItem} />
182+
})}
183+
</ActionList.Group>
184+
)
185+
})
186+
: items.map(({key: itemKey, ...item}, index) => {
187+
const key = itemKey ?? item.id?.toString() ?? index.toString()
188+
return <MappedActionListItem key={key} {...item} renderItem={listProps.renderItem} />
189+
})}
190+
</ActionList>
191+
</ActionListContainerContext.Provider>
200192
)
201193
}
202194

195+
useAnnouncements(items, listRef, inputRef, enableAnnouncements)
203196
return (
204197
<Box
205198
display="flex"
@@ -217,7 +210,7 @@ export function FilteredActionList({
217210
color="fg.default"
218211
value={filterValue}
219212
onChange={onInputChange}
220-
onKeyPress={onInputKeyPress}
213+
onKeyDown={keydownListener}
221214
placeholder={placeholderText}
222215
role="combobox"
223216
aria-expanded="true"

0 commit comments

Comments
 (0)