diff --git a/.changeset/expand-actionlist-roles.md b/.changeset/expand-actionlist-roles.md new file mode 100644 index 00000000000..5fef6536ded --- /dev/null +++ b/.changeset/expand-actionlist-roles.md @@ -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`). diff --git a/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-dark-colorblind-linux.png b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-dark-colorblind-linux.png new file mode 100644 index 00000000000..eedd9935bbd Binary files /dev/null and b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-dark-dimmed-linux.png b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-dark-dimmed-linux.png new file mode 100644 index 00000000000..412dc8b43d0 Binary files /dev/null and b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-dark-high-contrast-linux.png b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-dark-high-contrast-linux.png new file mode 100644 index 00000000000..de8e8bae624 Binary files /dev/null and b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-dark-linux.png b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-dark-linux.png new file mode 100644 index 00000000000..eedd9935bbd Binary files /dev/null and b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-dark-linux.png differ diff --git a/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-dark-tritanopia-linux.png b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-dark-tritanopia-linux.png new file mode 100644 index 00000000000..eedd9935bbd Binary files /dev/null and b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-light-colorblind-linux.png b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-light-colorblind-linux.png new file mode 100644 index 00000000000..7b737295444 Binary files /dev/null and b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-light-high-contrast-linux.png b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-light-high-contrast-linux.png new file mode 100644 index 00000000000..685df50c416 Binary files /dev/null and b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-light-linux.png b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-light-linux.png new file mode 100644 index 00000000000..7b737295444 Binary files /dev/null and b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-light-linux.png differ diff --git a/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-light-tritanopia-linux.png b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-light-tritanopia-linux.png new file mode 100644 index 00000000000..7b737295444 Binary files /dev/null and b/.playwright/snapshots/components/ActionList.test.ts-snapshots/ActionList-Custom-Tree-Role-light-tritanopia-linux.png differ diff --git a/e2e/components/ActionList.test.ts b/e2e/components/ActionList.test.ts index a7562b65d40..3c0706bbade 100644 --- a/e2e/components/ActionList.test.ts +++ b/e2e/components/ActionList.test.ts @@ -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', () => { diff --git a/packages/react/src/ActionList/ActionList.examples.stories.tsx b/packages/react/src/ActionList/ActionList.examples.stories.tsx index ef116545cc4..f5f669bb447 100644 --- a/packages/react/src/ActionList/ActionList.examples.stories.tsx +++ b/packages/react/src/ActionList/ActionList.examples.stories.tsx @@ -12,6 +12,8 @@ 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' @@ -19,6 +21,7 @@ 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 = { @@ -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([0]) + const containerRef = React.useRef(null) + + useRovingTabIndex({containerRef}) + + const handleSelect = (index: number) => { + if (selectedIndices.includes(index)) { + setSelectedIndices(selectedIndices.filter(i => i !== index)) + } else { + setSelectedIndices([...selectedIndices, index]) + } + } + + return ( + <> + + + + {projects.map((project, index) => ( + handleSelect(index)} + > + + + + {project.name} + {project.scope} + + ))} + + + + ) +} diff --git a/packages/react/src/ActionList/ActionList.test.tsx b/packages/react/src/ActionList/ActionList.test.tsx index c46ce4a11df..dc5f1f2e575 100644 --- a/packages/react/src/ActionList/ActionList.test.tsx +++ b/packages/react/src/ActionList/ActionList.test.tsx @@ -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' @@ -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( + + Item 1 + Item 2 + , + ) + + const tree = container.querySelector('[role="tree"]') + expect(tree).toBeInTheDocument() + expect(tree).toHaveAccessibleName('File tree') + }) + + it('applies role="treeitem" to items', () => { + const {container} = HTMLRender( + + Item 1 + Item 2 + , + ) + + const treeitems = container.querySelectorAll('[role="treeitem"]') + expect(treeitems).toHaveLength(2) + }) + + it('renders items with list semantics (div container, not button)', () => { + const {container} = HTMLRender( + + Item 1 + , + ) + + 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( + + + + Selected Item + + + Unselected Item + + + , + ) + + 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( + + + + Selected 1 + + + Selected 2 + + + Unselected + + + , + ) + + 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( + + + Selected Item + + Unselected Item + , + ) + + 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( + + + Item 1 + + , + ) + + 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( + + + Item 1 + + , + ) + + 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( + + + Disabled Item + + , + ) + + 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( + + + Icon + Item 1Badge + + , + ) + + expect(container.querySelector('[data-component="ActionList.LeadingVisual"]')).toBeInTheDocument() + expect(container.querySelector('[data-component="ActionList.TrailingVisual"]')).toBeInTheDocument() + }) +}) diff --git a/packages/react/src/ActionList/Item.tsx b/packages/react/src/ActionList/Item.tsx index bd4c2f541b0..28321404fc3 100644 --- a/packages/react/src/ActionList/Item.tsx +++ b/packages/react/src/ActionList/Item.tsx @@ -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'] const UnwrappedItem = ( { diff --git a/packages/react/src/ActionList/index.ts b/packages/react/src/ActionList/index.ts index 1380abfd90c..c819d6017f3 100644 --- a/packages/react/src/ActionList/index.ts +++ b/packages/react/src/ActionList/index.ts @@ -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' @@ -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, diff --git a/packages/react/src/TreeView/useRovingTabIndex.hookDocs.json b/packages/react/src/TreeView/useRovingTabIndex.hookDocs.json new file mode 100644 index 00000000000..a150ddc729f --- /dev/null +++ b/packages/react/src/TreeView/useRovingTabIndex.hookDocs.json @@ -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", + "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", + "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." + } +} diff --git a/packages/react/src/TreeView/useRovingTabIndex.test.tsx b/packages/react/src/TreeView/useRovingTabIndex.test.tsx new file mode 100644 index 00000000000..d1a9419d2e2 --- /dev/null +++ b/packages/react/src/TreeView/useRovingTabIndex.test.tsx @@ -0,0 +1,640 @@ +import {fireEvent, render, act, cleanup as cleanupRTL} from '@testing-library/react' +import {describe, it, expect, vi, afterEach} from 'vitest' +import React from 'react' +import { + useRovingTabIndex, + getElementState, + getFirstChildElement, + getParentElement, + getFirstElement, + getLastElement, + getVisibleElement, + getNextFocusableElement, +} from './useRovingTabIndex' + +// Mock scrollIntoView since it's not implemented in JSDOM +Element.prototype.scrollIntoView = vi.fn() + +function createTree(html: string): HTMLElement { + const container = document.createElement('div') + container.innerHTML = html.trim() + document.body.appendChild(container) + return container +} + +function cleanup(container: HTMLElement) { + document.body.removeChild(container) +} + +describe('getElementState', () => { + it('returns "open" for expanded treeitems', () => { + const el = document.createElement('li') + el.setAttribute('role', 'treeitem') + el.setAttribute('aria-expanded', 'true') + expect(getElementState(el)).toBe('open') + }) + + it('returns "closed" for collapsed treeitems', () => { + const el = document.createElement('li') + el.setAttribute('role', 'treeitem') + el.setAttribute('aria-expanded', 'false') + expect(getElementState(el)).toBe('closed') + }) + + it('returns "end" for treeitems without aria-expanded', () => { + const el = document.createElement('li') + el.setAttribute('role', 'treeitem') + expect(getElementState(el)).toBe('end') + }) + + it('throws if element is not a treeitem', () => { + const el = document.createElement('li') + expect(() => getElementState(el)).toThrow('Element is not a treeitem') + }) +}) + +describe('getFirstChildElement', () => { + it('returns the first child treeitem', () => { + const container = createTree(` +
    +
  • + Parent +
      +
    • Child 1
    • +
    • Child 2
    • +
    +
  • +
+ `) + + const parent = container.querySelector('#parent') as HTMLElement + const firstChild = getFirstChildElement(parent) + expect(firstChild?.id).toBe('child-1') + cleanup(container) + }) + + it('returns undefined when there are no children', () => { + const container = createTree(` +
    +
  • Leaf
  • +
+ `) + + const leaf = container.querySelector('#leaf') as HTMLElement + expect(getFirstChildElement(leaf)).toBeUndefined() + cleanup(container) + }) +}) + +describe('getParentElement', () => { + it('returns the parent treeitem', () => { + const container = createTree(` +
    +
  • + Parent +
      +
    • Child
    • +
    +
  • +
+ `) + + const child = container.querySelector('#child') as HTMLElement + const parent = getParentElement(child) + expect(parent?.id).toBe('parent') + cleanup(container) + }) + + it('returns undefined for top-level treeitems', () => { + const container = createTree(` +
    +
  • Item
  • +
+ `) + + const item = container.querySelector('#item') as HTMLElement + expect(getParentElement(item)).toBeUndefined() + cleanup(container) + }) +}) + +describe('getFirstElement', () => { + it('returns the first treeitem in the tree', () => { + const container = createTree(` +
    +
  • First
  • +
  • Second
  • +
  • Third
  • +
+ `) + + const second = container.querySelector('#second') as HTMLElement + const first = getFirstElement(second) + expect(first?.id).toBe('first') + cleanup(container) + }) +}) + +describe('getLastElement', () => { + it('returns the last visible treeitem in the tree', () => { + const container = createTree(` +
    +
  • First
  • +
  • Second
  • +
  • Third
  • +
+ `) + + const first = container.querySelector('#first') as HTMLElement + const last = getLastElement(first) + expect(last?.id).toBe('third') + cleanup(container) + }) + + it('skips treeitems inside collapsed subtrees', () => { + const container = createTree(` +
    +
  • First
  • + +
  • Visible Last
  • +
+ `) + + const first = container.querySelector('#first') as HTMLElement + const last = getLastElement(first) + expect(last?.id).toBe('visible-last') + cleanup(container) + }) +}) + +describe('getVisibleElement', () => { + it('returns the next visible treeitem', () => { + const container = createTree(` +
    +
  • Item 1
  • +
  • Item 2
  • +
  • Item 3
  • +
+ `) + + const item1 = container.querySelector('#item-1') as HTMLElement + const next = getVisibleElement(item1, 'next') + expect(next?.id).toBe('item-2') + cleanup(container) + }) + + it('returns the previous visible treeitem', () => { + const container = createTree(` +
    +
  • Item 1
  • +
  • Item 2
  • +
  • Item 3
  • +
+ `) + + const item3 = container.querySelector('#item-3') as HTMLElement + const prev = getVisibleElement(item3, 'previous') + expect(prev?.id).toBe('item-2') + cleanup(container) + }) + + it('returns undefined at the end of the tree', () => { + const container = createTree(` +
    +
  • Item 1
  • +
  • Item 2
  • +
+ `) + + const item2 = container.querySelector('#item-2') as HTMLElement + expect(getVisibleElement(item2, 'next')).toBeUndefined() + cleanup(container) + }) + + it('returns undefined at the start of the tree', () => { + const container = createTree(` +
    +
  • Item 1
  • +
  • Item 2
  • +
+ `) + + const item1 = container.querySelector('#item-1') as HTMLElement + expect(getVisibleElement(item1, 'previous')).toBeUndefined() + cleanup(container) + }) + + it('skips treeitems inside collapsed subtrees', () => { + const container = createTree(` +
    +
  • Item 1
  • + +
  • Item 3
  • +
+ `) + + const item1 = container.querySelector('#item-1') as HTMLElement + const next = getVisibleElement(item1, 'next') + expect(next?.id).toBe('collapsed') + + const collapsed = container.querySelector('#collapsed') as HTMLElement + const nextAfterCollapsed = getVisibleElement(collapsed, 'next') + expect(nextAfterCollapsed?.id).toBe('item-3') + cleanup(container) + }) + + it('returns undefined when element is not in a tree', () => { + const el = document.createElement('li') + el.setAttribute('role', 'treeitem') + document.body.appendChild(el) + expect(getVisibleElement(el, 'next')).toBeUndefined() + document.body.removeChild(el) + }) +}) + +describe('getNextFocusableElement', () => { + it('focuses first child on ArrowRight from open node', () => { + const container = createTree(` +
    +
  • + Parent +
      +
    • Child 1
    • +
    • Child 2
    • +
    +
  • +
+ `) + + const parent = container.querySelector('#parent') as HTMLElement + const result = getNextFocusableElement(parent, new KeyboardEvent('keydown', {key: 'ArrowRight'})) + expect(result?.id).toBe('child-1') + cleanup(container) + }) + + it('returns undefined on ArrowRight from closed node (node should open)', () => { + const container = createTree(` +
    + +
+ `) + + const closed = container.querySelector('#closed') as HTMLElement + const result = getNextFocusableElement(closed, new KeyboardEvent('keydown', {key: 'ArrowRight'})) + expect(result).toBeUndefined() + cleanup(container) + }) + + it('returns undefined on ArrowLeft from open node (node should close)', () => { + const container = createTree(` +
    +
  • + Open +
      +
    • Child
    • +
    +
  • +
+ `) + + const open = container.querySelector('#open') as HTMLElement + const result = getNextFocusableElement(open, new KeyboardEvent('keydown', {key: 'ArrowLeft'})) + expect(result).toBeUndefined() + cleanup(container) + }) + + it('focuses parent on ArrowLeft from closed node', () => { + const container = createTree(` +
    +
  • + Parent +
      + +
    +
  • +
+ `) + + const child = container.querySelector('#child') as HTMLElement + const result = getNextFocusableElement(child, new KeyboardEvent('keydown', {key: 'ArrowLeft'})) + expect(result?.id).toBe('parent') + cleanup(container) + }) + + it('focuses parent on ArrowLeft from end node', () => { + const container = createTree(` +
    +
  • + Parent +
      +
    • Child
    • +
    +
  • +
+ `) + + const child = container.querySelector('#child') as HTMLElement + const result = getNextFocusableElement(child, new KeyboardEvent('keydown', {key: 'ArrowLeft'})) + expect(result?.id).toBe('parent') + cleanup(container) + }) + + it('focuses next visible element on ArrowDown', () => { + const container = createTree(` +
    +
  • Item 1
  • +
  • Item 2
  • +
+ `) + + const item1 = container.querySelector('#item-1') as HTMLElement + const result = getNextFocusableElement(item1, new KeyboardEvent('keydown', {key: 'ArrowDown'})) + expect(result?.id).toBe('item-2') + cleanup(container) + }) + + it('focuses previous visible element on ArrowUp', () => { + const container = createTree(` +
    +
  • Item 1
  • +
  • Item 2
  • +
+ `) + + const item2 = container.querySelector('#item-2') as HTMLElement + const result = getNextFocusableElement(item2, new KeyboardEvent('keydown', {key: 'ArrowUp'})) + expect(result?.id).toBe('item-1') + cleanup(container) + }) + + it('focuses first element on Home', () => { + const container = createTree(` +
    +
  • Item 1
  • +
  • Item 2
  • +
  • Item 3
  • +
+ `) + + const item3 = container.querySelector('#item-3') as HTMLElement + const result = getNextFocusableElement(item3, new KeyboardEvent('keydown', {key: 'Home'})) + expect(result?.id).toBe('item-1') + cleanup(container) + }) + + it('focuses last element on End', () => { + const container = createTree(` +
    +
  • Item 1
  • +
  • Item 2
  • +
  • Item 3
  • +
+ `) + + const item1 = container.querySelector('#item-1') as HTMLElement + const result = getNextFocusableElement(item1, new KeyboardEvent('keydown', {key: 'End'})) + expect(result?.id).toBe('item-3') + cleanup(container) + }) + + it('focuses parent on Backspace', () => { + const container = createTree(` +
    +
  • + Parent +
      +
    • Child
    • +
    +
  • +
+ `) + + const child = container.querySelector('#child') as HTMLElement + const result = getNextFocusableElement(child, new KeyboardEvent('keydown', {key: 'Backspace'})) + expect(result?.id).toBe('parent') + cleanup(container) + }) + + it('does nothing on ArrowRight from end node', () => { + const container = createTree(` +
    +
  • End node
  • +
+ `) + + const endNode = container.querySelector('#end-node') as HTMLElement + const result = getNextFocusableElement(endNode, new KeyboardEvent('keydown', {key: 'ArrowRight'})) + expect(result).toBeUndefined() + cleanup(container) + }) + + it('returns undefined on ArrowDown at the end of the tree', () => { + const container = createTree(` +
    +
  • Item 1
  • +
  • Item 2
  • +
+ `) + + const item2 = container.querySelector('#item-2') as HTMLElement + const result = getNextFocusableElement(item2, new KeyboardEvent('keydown', {key: 'ArrowDown'})) + expect(result).toBeUndefined() + cleanup(container) + }) + + it('navigates into expanded children on ArrowDown', () => { + const container = createTree(` +
    +
  • + Parent +
      +
    • Child 1
    • +
    • Child 2
    • +
    +
  • +
  • Sibling
  • +
+ `) + + const parent = container.querySelector('#parent') as HTMLElement + const result = getNextFocusableElement(parent, new KeyboardEvent('keydown', {key: 'ArrowDown'})) + expect(result?.id).toBe('child-1') + cleanup(container) + }) +}) + +// Test component that uses the useRovingTabIndex hook directly +function TreeWithRovingTabIndex({ + preventScroll = true, + children, +}: { + preventScroll?: boolean + children: React.ReactNode +}) { + const containerRef = React.useRef(null) + const mouseDownRef = React.useRef(false) + + useRovingTabIndex({ + containerRef, + mouseDownRef, + preventScroll, + }) + + return ( +
    { + mouseDownRef.current = true + }} + onMouseUp={() => { + mouseDownRef.current = false + }} + > + {children} +
+ ) +} + +describe('useRovingTabIndex hook', () => { + afterEach(cleanupRTL) + + it('moves focus with keyboard navigation', () => { + const {getByRole} = render( + +
  • + Item 1 +
  • +
  • + Item 2 +
  • +
  • + Item 3 +
  • +
    , + ) + + const tree = getByRole('tree') + const item1 = tree.querySelector('#item-1') as HTMLElement + const item2 = tree.querySelector('#item-2') as HTMLElement + + act(() => item1.focus()) + expect(item1).toHaveFocus() + + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + expect(item2).toHaveFocus() + + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowUp'}) + expect(item1).toHaveFocus() + }) + + describe('preventScroll', () => { + it('defaults to preventScroll=true', () => { + const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus') + + const {getByRole} = render( + +
  • + Item 1 +
  • +
  • + Item 2 +
  • +
    , + ) + + const tree = getByRole('tree') + const item1 = tree.querySelector('#item-1') as HTMLElement + + act(() => item1.focus()) + focusSpy.mockClear() + + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + + // When preventScroll is true, the focus zone should call focus with preventScroll: true + const focusCalls = focusSpy.mock.calls + const hasPreventScrollCall = focusCalls.some(call => { + const options = call[0] as FocusOptions | undefined + return options?.preventScroll === true + }) + expect(hasPreventScrollCall).toBe(true) + + focusSpy.mockRestore() + }) + + it('allows scrolling when preventScroll is false', () => { + const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus') + + const {getByRole} = render( + +
  • + Item 1 +
  • +
  • + Item 2 +
  • +
    , + ) + + const tree = getByRole('tree') + const item1 = tree.querySelector('#item-1') as HTMLElement + + act(() => item1.focus()) + focusSpy.mockClear() + + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + + // When preventScroll is false, focus calls should not include preventScroll: true + const focusCalls = focusSpy.mock.calls + const hasPreventScrollCall = focusCalls.some(call => { + const options = call[0] as FocusOptions | undefined + return options?.preventScroll === true + }) + expect(hasPreventScrollCall).toBe(false) + + focusSpy.mockRestore() + }) + }) + + describe('mouse click bypass', () => { + it('does not redirect focus when mouseDownRef is true (click scenario)', () => { + const {getByRole} = render( + +
  • + Item 1 +
  • +
  • + Item 2 +
  • +
    , + ) + + const tree = getByRole('tree') + const item1 = tree.querySelector('#item-1') as HTMLElement + + // Simulate a mouse click: mouseDown sets the ref, then focus happens + fireEvent.mouseDown(tree) + act(() => item1.focus()) + + // Focus should stay on item-1 (not redirected to aria-current item) + // because mouseDownRef is true during click + expect(item1).toHaveFocus() + }) + }) +}) diff --git a/packages/react/src/TreeView/useRovingTabIndex.ts b/packages/react/src/TreeView/useRovingTabIndex.ts index 98a7dbbfa6b..c877f9141bb 100644 --- a/packages/react/src/TreeView/useRovingTabIndex.ts +++ b/packages/react/src/TreeView/useRovingTabIndex.ts @@ -2,68 +2,76 @@ import type React from 'react' import {FocusKeys, useFocusZone} from '../hooks/useFocusZone' import {getScrollContainer} from '../utils/scroll' -export function useRovingTabIndex({ - containerRef, - mouseDownRef, -}: { - containerRef: React.RefObject - mouseDownRef: React.RefObject -}) { - // 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, + preventScroll = true, + }: { + containerRef: React.RefObject + mouseDownRef?: React.RefObject + preventScroll?: boolean + }, + dependencies: React.DependencyList = [], +) { + // 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) ?? 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 + }, }, - }) + [preventScroll, ...dependencies], + ) } // DOM utilities used for focus management diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index 9619cb0c3d5..d42fa9832cb 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -230,6 +230,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "useRefObjectAsForwardedRef", "useResizeObserver", "useResponsiveValue", + "useRovingTabIndex", "useSafeTimeout", "useSyncedState", "useTheme", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index db2c6531296..033b16136da 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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, diff --git a/packages/react/src/stories/useRovingTabIndex.stories.module.css b/packages/react/src/stories/useRovingTabIndex.stories.module.css new file mode 100644 index 00000000000..6ab1db46d10 --- /dev/null +++ b/packages/react/src/stories/useRovingTabIndex.stories.module.css @@ -0,0 +1,43 @@ +.Tree { + list-style: none; + padding: 0; + margin: 0; +} + +.TreeItem { + padding: var(--base-size-8) var(--base-size-12); + cursor: pointer; + border-radius: var(--borderRadius-medium); + outline: none; +} + +.TreeItem:focus-visible { + @mixin focusOutline 2px; +} + +.TreeItem[aria-selected='true'] { + background-color: var(--bgColor-accent-muted); +} + +.SubTree { + list-style: none; + padding-inline-start: var(--base-size-20); + margin: 0; +} + +.LastKeyDisplay { + position: absolute; + right: var(--base-size-20); + top: var(--base-size-8); +} + +.Container { + display: flex; + flex-direction: column; + gap: var(--base-size-8); +} + +.Label { + font-weight: var(--base-text-weight-semibold); + font-size: var(--text-body-size-medium); +} diff --git a/packages/react/src/stories/useRovingTabIndex.stories.tsx b/packages/react/src/stories/useRovingTabIndex.stories.tsx new file mode 100644 index 00000000000..8b2958b4037 --- /dev/null +++ b/packages/react/src/stories/useRovingTabIndex.stories.tsx @@ -0,0 +1,107 @@ +import type React from 'react' +import {useRef, useState} from 'react' +import type {Meta} from '@storybook/react-vite' +import {ChevronDownIcon, ChevronRightIcon} from '@primer/octicons-react' +import {useRovingTabIndex} from '../TreeView/useRovingTabIndex' +import classes from './useRovingTabIndex.stories.module.css' + +export default { + title: 'Hooks/useRovingTabIndex', +} as Meta + +function TreeItem({ + label, + children, + selected, + onSelect, + defaultExpanded = false, +}: { + label: string + children?: React.ReactNode + selected?: boolean + onSelect?: () => void + defaultExpanded?: boolean +}) { + const [expanded, setExpanded] = useState(defaultExpanded) + const hasChildren = !!children + + return ( +
  • { + e.stopPropagation() + if (hasChildren) { + setExpanded(!expanded) + } + onSelect?.() + }} + onKeyDown={e => { + if (e.currentTarget !== e.target) return + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + e.stopPropagation() + onSelect?.() + } + if (hasChildren && e.key === 'ArrowRight' && !expanded) { + setExpanded(true) + } + if (hasChildren && e.key === 'ArrowLeft' && expanded) { + setExpanded(false) + } + }} + > + {hasChildren ? expanded ? : : null} + {label} + {hasChildren && expanded ? ( +
      + {children} +
    + ) : null} +
  • + ) +} + +export const BasicRovingTabIndex = () => { + const [lastKey, setLastKey] = useState('none') + const containerRef = useRef(null) + const mouseDownRef = useRef(false) + + useRovingTabIndex({containerRef, mouseDownRef}) + + return ( +
    +
    Last key pressed: {lastKey}
    +

    Use Arrow keys, Home, and End to navigate the tree.

    +
      setLastKey(e.key)} + > + + + + + + + + + + + + + + + + + +
    +
    + ) +} diff --git a/script/check-classname-tests.mjs b/script/check-classname-tests.mjs index 2b934a53fd3..dd756028ec7 100755 --- a/script/check-classname-tests.mjs +++ b/script/check-classname-tests.mjs @@ -23,6 +23,7 @@ const IGNORED_FILES = [ 'packages/react/src/__tests__/ThemeProvider.test.tsx', 'packages/react/src/__tests__/deprecated/ActionMenu.test.tsx', 'packages/react/src/__tests__/Caret.test.tsx', + 'packages/react/src/TreeView/useRovingTabIndex.test.tsx', ] function getAllTestFiles(dir, files = []) {