Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
046e7d4
chore: expand selectableRoles and listRoleTypes arrays to include 'tr…
francinelucca May 7, 2026
5f38b59
chore: add ActionListContainerContext to ActionList export
francinelucca May 7, 2026
90c5e10
add config items to useRovingTabIndex and export through public API
francinelucca May 7, 2026
f52488c
use preventScroll config
francinelucca May 7, 2026
f35949d
Merge branch 'main' into chore/expand-actionlist-roles
francinelucca May 7, 2026
6cb12d0
add changeset
francinelucca May 8, 2026
ef4d678
Merge branch 'chore/expand-actionlist-roles' of github.com:primer/rea…
francinelucca May 8, 2026
17fa9db
fix changeset
francinelucca May 8, 2026
3a7bd97
Merge branch 'main' of github.com:primer/react into chore/expand-acti…
francinelucca May 8, 2026
4faf9c9
Merge branch 'main' into chore/expand-actionlist-roles
francinelucca May 8, 2026
c32696a
add tests
francinelucca May 8, 2026
b7fd9f8
Merge branch 'chore/expand-actionlist-roles' of github.com:primer/rea…
francinelucca May 8, 2026
5ba0289
Make useRovingTabIndex reactive to option changes
Copilot May 8, 2026
498980b
ignore new test file for implementsClassName
francinelucca May 8, 2026
de4b18a
Merge branch 'chore/expand-actionlist-roles' of github.com:primer/rea…
francinelucca May 8, 2026
cc0a786
refactor useRovingTabIndex changes
francinelucca May 11, 2026
17a1b2d
Add new ActionList, useRovingTabIndex stories
francinelucca May 11, 2026
ecdb08a
Update expand-actionlist-roles.md
francinelucca May 11, 2026
d146c05
test(vrt): update snapshots
francinelucca May 11, 2026
e16795b
add hooksDocs for useRovingTabIndex
francinelucca May 11, 2026
bccf7d0
Revert "test(vrt): update snapshots"
francinelucca May 11, 2026
3efc334
test(vrt): update snapshots
francinelucca May 11, 2026
d644687
revert snapshot update
francinelucca May 11, 2026
cc74cde
Merge branch 'main' into chore/expand-actionlist-roles
francinelucca May 11, 2026
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
7 changes: 7 additions & 0 deletions .changeset/expand-actionlist-roles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@primer/react': minor
---

- ActionList: Expand `selectableRoles` and `listRoleTypes` to include `treeitem` and `tree`.
- Export `ActionListContainerContext` as `ActionList.ContainerContext`.
- Export `useRovingTabIndex` from the public API with additional configuration options (`preventScroll`, `focusOutBehavior`, `wrapAround`, and `dependencies`).
4 changes: 2 additions & 2 deletions packages/react/src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ const baseSlots = {
const slotsConfig = {...baseSlots, description: Description}

// Pre-allocated array for selectableRoles check, avoids per-render allocation
const selectableRoles = ['menuitemradio', 'menuitemcheckbox', 'option']
const listRoleTypes = ['listbox', 'menu', 'list']
const selectableRoles = ['menuitemradio', 'menuitemcheckbox', 'option', 'treeitem']
const listRoleTypes = ['listbox', 'menu', 'list', 'tree']

Comment thread
francinelucca marked this conversation as resolved.
const UnwrappedItem = <As extends React.ElementType = 'li'>(
{
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/ActionList/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {Description} from './Description'
import {TrailingAction} from './TrailingAction'
import {LeadingVisual, TrailingVisual} from './Visuals'
import {Heading} from './Heading'
import {ActionListContainerContext} from './ActionListContainerContext'

export type {ActionListProps} from './shared'
export type {ActionListGroupProps, ActionListGroupHeadingProps} from './Group'
Expand All @@ -22,6 +23,8 @@ export type {ActionListTrailingActionProps} from './TrailingAction'
* Collection of list-related components.
*/
export const ActionList = Object.assign(List, {
/** Context for the `ActionList` container. */
ContainerContext: ActionListContainerContext,
/** Collects related `Items` in an `ActionList`. */
Group,

Expand Down
175 changes: 104 additions & 71 deletions packages/react/src/TreeView/useRovingTabIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,80 +2,104 @@ import type React from 'react'
import {FocusKeys, useFocusZone} from '../hooks/useFocusZone'
import {getScrollContainer} from '../utils/scroll'

export function useRovingTabIndex({
containerRef,
mouseDownRef,
}: {
containerRef: React.RefObject<HTMLElement>
mouseDownRef: React.RefObject<boolean>
}) {
// TODO: Initialize focus to the aria-current item if it exists
useFocusZone({
export function useRovingTabIndex(
{
containerRef,
bindKeys:
FocusKeys.ArrowVertical |
FocusKeys.ArrowHorizontal |
FocusKeys.HomeAndEnd |
FocusKeys.Backspace |
FocusKeys.PageUpDown,
preventScroll: true,
getNextFocusable: (direction, from, event) => {
if (!(from instanceof HTMLElement)) return

// Skip elements within a modal dialog
// This need to be in a try/catch to avoid errors in
// non-supported browsers
try {
if (from.closest('dialog:modal')) {
return
mouseDownRef,
focusOutBehavior,
preventScroll = true,
wrapAround = false,
}: {
containerRef: React.RefObject<HTMLElement | null>
mouseDownRef?: React.RefObject<boolean>
preventScroll?: boolean
focusOutBehavior?: 'stop' | 'wrap'
wrapAround?: boolean
},
dependencies: React.DependencyList = [],
) {
Comment thread
francinelucca marked this conversation as resolved.
// TODO: Initialize focus to the aria-current item if it exists
useFocusZone(
{
containerRef,
bindKeys:
FocusKeys.ArrowVertical |
FocusKeys.ArrowHorizontal |
FocusKeys.HomeAndEnd |
FocusKeys.Backspace |
FocusKeys.PageUpDown,
preventScroll,
getNextFocusable: (direction, from, event) => {
if (!(from instanceof HTMLElement)) return

// Skip elements within a modal dialog
// This need to be in a try/catch to avoid errors in
// non-supported browsers
try {
if (from.closest('dialog:modal')) {
return
}
} catch {
// Don't return
}
} catch {
// Don't return
}

return getNextFocusableElement(from, event) ?? from
},
focusInStrategy: () => {
// Don't try to execute the focusInStrategy if focus is coming from a click.
// The clicked row will receive focus correctly by default.
// If a chevron is clicked, setting the focus through the focuszone will prevent its toggle.
if (mouseDownRef.current) {
return undefined
}

const currentItem = containerRef.current?.querySelector('[aria-current]')
const firstItem = containerRef.current?.querySelector('[role="treeitem"]')

// Focus the aria-current item if it exists
if (currentItem instanceof HTMLElement) {
return currentItem
}

// Otherwise, focus the activeElement if it's a treeitem
if (
document.activeElement instanceof HTMLElement &&
containerRef.current?.contains(document.activeElement) &&
document.activeElement.getAttribute('role') === 'treeitem'
) {
return document.activeElement
}

// Otherwise, focus the first treeitem
return firstItem instanceof HTMLElement ? firstItem : undefined
return getNextFocusableElement(from, event, wrapAround) ?? from
},
...(focusOutBehavior ? {focusOutBehavior} : {}),
focusInStrategy: () => {
// Don't try to execute the focusInStrategy if focus is coming from a click.
// The clicked row will receive focus correctly by default.
// If a chevron is clicked, setting the focus through the focuszone will prevent its toggle.
if (mouseDownRef?.current) {
return undefined
}
Comment thread
francinelucca marked this conversation as resolved.
Outdated

const currentItem = containerRef.current?.querySelector('[aria-current]')
const firstItem = containerRef.current?.querySelector('[role="treeitem"]')

// Focus the aria-current item if it exists
if (currentItem instanceof HTMLElement) {
return currentItem
}

// Otherwise, focus the activeElement if it's a treeitem
if (
document.activeElement instanceof HTMLElement &&
containerRef.current?.contains(document.activeElement) &&
document.activeElement.getAttribute('role') === 'treeitem'
) {
return document.activeElement
}

// Otherwise, focus the first treeitem
return firstItem instanceof HTMLElement ? firstItem : undefined
},
},
})
dependencies,
)
}

// DOM utilities used for focus management

export function getNextFocusableElement(activeElement: HTMLElement, event: KeyboardEvent): HTMLElement | undefined {
const elementState = getElementState(activeElement)
export function getNextFocusableElement(
activeElement: HTMLElement,
event: KeyboardEvent,
wrapAround: boolean = false,
): HTMLElement | undefined {
// If focus is on a non-treeitem child (e.g. GroupHeadingWrap), resolve to the closest treeitem ancestor.
const treeitem =
activeElement.getAttribute('role') === 'treeitem'
? activeElement
: activeElement.closest<HTMLElement>('[role="treeitem"]')
if (!treeitem) return

const elementState = getElementState(treeitem)

// Reference: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/#keyboard-interaction-24
switch (`${elementState} ${event.key}`) {
case 'open ArrowRight':
// Focus first child node
return getFirstChildElement(activeElement)
return getFirstChildElement(treeitem)

case 'open ArrowLeft':
// Close node; don't change focus
Expand All @@ -87,43 +111,43 @@ export function getNextFocusableElement(activeElement: HTMLElement, event: Keybo

case 'closed ArrowLeft':
// Focus parent element
return getParentElement(activeElement)
return getParentElement(treeitem)

case 'end ArrowRight':
// Do nothing
return

case 'end ArrowLeft':
// Focus parent element
return getParentElement(activeElement)
return getParentElement(treeitem)
}

// ArrowUp, ArrowDown, Home, and End behavior are the same regardless of element state
switch (event.key) {
case 'ArrowUp':
// Focus previous visible element
return getVisibleElement(activeElement, 'previous')
return getVisibleElement(treeitem, 'previous', wrapAround)

case 'ArrowDown':
// Focus next visible element
return getVisibleElement(activeElement, 'next')
return getVisibleElement(treeitem, 'next', wrapAround)

case 'Backspace':
return getParentElement(activeElement)
return getParentElement(treeitem)

case 'Home':
// Focus first visible element
return getFirstElement(activeElement)
return getFirstElement(treeitem)

case 'End':
// Focus last visible element
return getLastElement(activeElement)
return getLastElement(treeitem)

case 'PageUp':
return getPreviousPageElement(activeElement)
return getPreviousPageElement(treeitem)

case 'PageDown':
return getNextPageElement(activeElement)
return getNextPageElement(treeitem)
}
}

Expand All @@ -142,7 +166,11 @@ export function getElementState(element: HTMLElement): 'open' | 'closed' | 'end'
}
}

export function getVisibleElement(element: HTMLElement, direction: 'next' | 'previous'): HTMLElement | undefined {
export function getVisibleElement(
element: HTMLElement,
direction: 'next' | 'previous',
wrapAround = false,
): HTMLElement | undefined {
const root = element.closest('[role=tree]')

if (!root) return
Expand All @@ -165,6 +193,11 @@ export function getVisibleElement(element: HTMLElement, direction: 'next' | 'pre
next = direction === 'next' ? walker.nextNode() : walker.previousNode()
}

// Wrap around if we reached the end/beginning of the tree
if (!next && wrapAround) {
return direction === 'next' ? getFirstElement(element) : getLastElement(element)
}

return next instanceof HTMLElement ? next : undefined
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] =
"useRefObjectAsForwardedRef",
"useResizeObserver",
"useResponsiveValue",
"useRovingTabIndex",
"useSafeTimeout",
"useSyncedState",
"useTheme",
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export {default as Textarea} from './Textarea'
export type {TextareaProps} from './Textarea'

export {TreeView} from './TreeView'
export {useRovingTabIndex} from './TreeView/useRovingTabIndex'
export type {
TreeViewProps,
TreeViewItemProps,
Expand Down
Loading