Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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` and `dependencies`).
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions e2e/components/ActionList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ const stories = [
title: 'Text Wrap And Truncation',
id: 'components-actionlist-features--text-wrap-and-truncation',
},
{
title: 'Custom Tree Role',
id: 'components-actionlist-examples--custom-tree-role',
},
] as const

test.describe('ActionList', () => {
Expand Down
60 changes: 60 additions & 0 deletions packages/react/src/ActionList/ActionList.examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ import {
XIcon,
} from '@primer/octicons-react'
import {ActionList} from '.'
import {ActionListContainerContext} from './ActionListContainerContext'
import {Banner} from '../Banner'
import TextInput from '../TextInput'
import Spinner from '../Spinner'
import Text from '../Text'
import FormControl from '../FormControl'
import {AriaStatus} from '../live-region'
import {VisuallyHidden} from '../VisuallyHidden'
import {ReactRouterLikeLink} from '../Pagination/mocks/ReactRouterLink'
import {useRovingTabIndex} from '../TreeView/useRovingTabIndex'
import classes from './ActionList.examples.stories.module.css'

const meta: Meta = {
Expand Down Expand Up @@ -440,3 +443,60 @@ export function AllCombinations(): JSX.Element {
</>
)
}

const projects = [
{name: 'Primer Backlog', scope: 'GitHub'},
{name: 'Accessibility', scope: 'GitHub'},
{name: 'Octicons', scope: 'github/primer'},
{name: 'Primer React', scope: 'github/primer'},
]

export const CustomTreeRole = () => {
const [selectedIndices, setSelectedIndices] = React.useState<number[]>([0])
const containerRef = React.useRef<HTMLUListElement>(null)

useRovingTabIndex({containerRef})

const handleSelect = (index: number) => {
if (selectedIndices.includes(index)) {
setSelectedIndices(selectedIndices.filter(i => i !== index))
} else {
setSelectedIndices([...selectedIndices, index])
}
}

return (
<>
<Banner
variant="warning"
title="Demonstration only"
description="This story is for demonstration purposes only. For most use cases, you probably want to use the default list
roles provided by ActionList. Overriding default roles may have accessibility implications, so be sure to consult with an accessibility expert if you need to use custom roles."
/>
<ActionListContainerContext.Provider value={{selectionAttribute: 'aria-selected'}}>
<ActionList
ref={containerRef}
selectionVariant="multiple"
role="tree"
aria-label="Projects"
aria-multiselectable="true"
>
{projects.map((project, index) => (
<ActionList.Item
key={index}
role="treeitem"
selected={selectedIndices.includes(index)}
onSelect={() => handleSelect(index)}
>
<ActionList.LeadingVisual>
<TableIcon />
</ActionList.LeadingVisual>
{project.name}
<ActionList.Description variant="block">{project.scope}</ActionList.Description>
</ActionList.Item>
))}
</ActionList>
</ActionListContainerContext.Provider>
</>
)
}
157 changes: 157 additions & 0 deletions packages/react/src/ActionList/ActionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {describe, it, expect, vi} from 'vitest'
import {render as HTMLRender} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {ActionList} from '.'
import {ActionListContainerContext} from './ActionListContainerContext'
import {implementsClassName} from '../utils/testing'
import classes from './ActionList.module.css'

Expand Down Expand Up @@ -424,3 +425,159 @@ describe('ActionList data-component attributes', () => {
expect(trailingAction).toBeInTheDocument()
})
})

describe('ActionList with role="tree"', () => {
it('applies role="tree" to the list', () => {
const {container} = HTMLRender(
<ActionList role="tree" aria-label="File tree">
<ActionList.Item role="treeitem">Item 1</ActionList.Item>
<ActionList.Item role="treeitem">Item 2</ActionList.Item>
</ActionList>,
)

const tree = container.querySelector('[role="tree"]')
expect(tree).toBeInTheDocument()
expect(tree).toHaveAccessibleName('File tree')
})

it('applies role="treeitem" to items', () => {
const {container} = HTMLRender(
<ActionList role="tree" aria-label="File tree">
<ActionList.Item role="treeitem">Item 1</ActionList.Item>
<ActionList.Item role="treeitem">Item 2</ActionList.Item>
</ActionList>,
)

const treeitems = container.querySelectorAll('[role="treeitem"]')
expect(treeitems).toHaveLength(2)
})

it('renders items with list semantics (div container, not button)', () => {
const {container} = HTMLRender(
<ActionList role="tree" aria-label="File tree">
<ActionList.Item role="treeitem">Item 1</ActionList.Item>
</ActionList>,
)

const treeitem = container.querySelector('[role="treeitem"]')
expect(treeitem).toBeInTheDocument()
// Items with tree role should not render as buttons
expect(treeitem?.tagName).not.toBe('BUTTON')
})

it('applies aria-selected for single selection with treeitem role', () => {
const {container} = HTMLRender(
<ActionListContainerContext.Provider value={{selectionAttribute: 'aria-selected'}}>
<ActionList role="tree" selectionVariant="single" aria-label="File tree">
<ActionList.Item role="treeitem" selected>
Selected Item
</ActionList.Item>
<ActionList.Item role="treeitem" selected={false}>
Unselected Item
</ActionList.Item>
</ActionList>
</ActionListContainerContext.Provider>,
)

const treeitems = container.querySelectorAll('[role="treeitem"]')
expect(treeitems[0]).toHaveAttribute('aria-selected', 'true')
expect(treeitems[1]).toHaveAttribute('aria-selected', 'false')
})

it('applies aria-selected for multiple selection with treeitem role', () => {
const {container} = HTMLRender(
<ActionListContainerContext.Provider value={{selectionAttribute: 'aria-selected'}}>
<ActionList role="tree" selectionVariant="multiple" aria-label="File tree">
<ActionList.Item role="treeitem" selected>
Selected 1
</ActionList.Item>
<ActionList.Item role="treeitem" selected>
Selected 2
</ActionList.Item>
<ActionList.Item role="treeitem" selected={false}>
Unselected
</ActionList.Item>
</ActionList>
</ActionListContainerContext.Provider>,
)

const treeitems = container.querySelectorAll('[role="treeitem"]')
expect(treeitems[0]).toHaveAttribute('aria-selected', 'true')
expect(treeitems[1]).toHaveAttribute('aria-selected', 'true')
expect(treeitems[2]).toHaveAttribute('aria-selected', 'false')
})

it('renders selection visual for selected treeitems', () => {
const {container} = HTMLRender(
<ActionList role="tree" selectionVariant="single" aria-label="File tree">
<ActionList.Item role="treeitem" selected>
Selected Item
</ActionList.Item>
<ActionList.Item role="treeitem">Unselected Item</ActionList.Item>
</ActionList>,
)

const selection = container.querySelector('[data-component="ActionList.Selection"]')
expect(selection).toBeInTheDocument()
})

it('calls onSelect when a treeitem is clicked', async () => {
const onSelect = vi.fn()
HTMLRender(
<ActionList role="tree" selectionVariant="single" aria-label="File tree">
<ActionList.Item role="treeitem" onSelect={onSelect}>
Item 1
</ActionList.Item>
</ActionList>,
)

const item = document.querySelector('[role="treeitem"]')!
await userEvent.click(item)
expect(onSelect).toHaveBeenCalledTimes(1)
})

it('calls onSelect when Enter is pressed on a treeitem', async () => {
const onSelect = vi.fn()
HTMLRender(
<ActionList role="tree" selectionVariant="single" aria-label="File tree">
<ActionList.Item role="treeitem" onSelect={onSelect}>
Item 1
</ActionList.Item>
</ActionList>,
)

const item = document.querySelector('[role="treeitem"]')!
;(item as HTMLElement).focus()
await userEvent.keyboard('{Enter}')
expect(onSelect).toHaveBeenCalledTimes(1)
})

it('does not call onSelect when item is disabled', async () => {
const onSelect = vi.fn()
HTMLRender(
<ActionList role="tree" selectionVariant="single" aria-label="File tree">
<ActionList.Item role="treeitem" disabled onSelect={onSelect}>
Disabled Item
</ActionList.Item>
</ActionList>,
)

const item = document.querySelector('[role="treeitem"]')!
await userEvent.click(item)
expect(onSelect).not.toHaveBeenCalled()
})

it('supports leading and trailing visuals on treeitems', () => {
const {container} = HTMLRender(
<ActionList role="tree" aria-label="File tree">
<ActionList.Item role="treeitem">
<ActionList.LeadingVisual>Icon</ActionList.LeadingVisual>
Item 1<ActionList.TrailingVisual>Badge</ActionList.TrailingVisual>
</ActionList.Item>
</ActionList>,
)

expect(container.querySelector('[data-component="ActionList.LeadingVisual"]')).toBeInTheDocument()
expect(container.querySelector('[data-component="ActionList.TrailingVisual"]')).toBeInTheDocument()
})
})
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
47 changes: 47 additions & 0 deletions packages/react/src/TreeView/useRovingTabIndex.hookDocs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "useRovingTabIndex",
"importPath": "@primer/react",
"stories": [{"id": "hooks-userovingtabindex--basic-roving-tab-index"}],
"parameters": [
{
"name": "options",
"type": "UseRovingTabIndexOptions",
"required": true,
"description": "Configuration options for the roving tabindex focus management."
},
{
"name": "dependencies",
"type": "React.DependencyList",
"defaultValue": "[]",
"description": "Dependency list that triggers re-initialization of the focus zone when values change."
}
],
"relatedTypes": [
{
"name": "UseRovingTabIndexOptions",
"properties": [
{
"name": "containerRef",
"type": "React.RefObject<HTMLElement>",
"required": true,
"description": "Ref to the container element (e.g. a tree) whose treeitem children will participate in roving tabindex focus management."
},
{
"name": "mouseDownRef",
"type": "React.RefObject<boolean>",
"description": "Optional ref that tracks whether the mouse is currently pressed. When true, the focus-in strategy is bypassed so clicked elements receive focus naturally."
},
{
"name": "preventScroll",
"type": "boolean",
"defaultValue": "true",
"description": "When true, prevents the browser from scrolling the focused element into view."
}
]
}
],
"returns": {
"type": "void",
"description": "This hook does not return a value. It sets up keyboard focus management on the container ref as a side effect."
}
}
Loading
Loading