Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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`).
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
Loading
Loading