diff --git a/.changeset/neat-squids-cheat.md b/.changeset/neat-squids-cheat.md new file mode 100644 index 00000000000..2f5aafe6d34 --- /dev/null +++ b/.changeset/neat-squids-cheat.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +Adds support for arrow key navigation of a TreeView using `aria-activedescendant` diff --git a/docs/content/TreeView.mdx b/docs/content/TreeView.mdx index 5d463a0a195..81b407839f2 100644 --- a/docs/content/TreeView.mdx +++ b/docs/content/TreeView.mdx @@ -80,6 +80,7 @@ description: A hierarchical list of items where nested items can be expanded and + ( @@ -52,12 +52,12 @@ export const FileTreeWithoutDirectoryLinks: Story = () => ( src - Avatar.tsx + Avatar.tsx Button - Button.tsx - Button.test.tsx + Button.tsx + Button.test.tsx @@ -68,11 +68,11 @@ export const FileTreeWithoutDirectoryLinks: Story = () => ( > public - index.html - favicon.ico + index.html + favicon.ico - package.json + package.json diff --git a/src/TreeView/TreeView.test.tsx b/src/TreeView/TreeView.test.tsx index 5fa55c0f674..336aa20b331 100644 --- a/src/TreeView/TreeView.test.tsx +++ b/src/TreeView/TreeView.test.tsx @@ -1,50 +1,678 @@ -import {render} from '@testing-library/react' +import {fireEvent, render} from '@testing-library/react' import React from 'react' +import {ThemeProvider} from '../ThemeProvider' import {TreeView} from './TreeView' -it('uses tree role', () => { - const {queryByRole} = render( - - Item 1 - Item 2 - Item 3 - - ) +// TODO: Move this function into a shared location +function renderWithTheme( + ui: Parameters[0], + options?: Parameters[1] +): ReturnType { + return render({ui}, options) +} - const root = queryByRole('tree') +describe('Markup', () => { + it('uses tree role', () => { + const {queryByRole} = renderWithTheme( + + Item 1 + Item 2 + Item 3 + + ) - expect(root).toHaveAccessibleName('Test tree') -}) + const root = queryByRole('tree') + + expect(root).toHaveAccessibleName('Test tree') + }) + + it('uses treeitem role', () => { + const {queryAllByRole} = renderWithTheme( + + Item 1 + Item 2 + Item 3 + + ) + + const items = queryAllByRole('treeitem') + + expect(items).toHaveLength(3) + }) -it('uses treeitem role', () => { - const {queryAllByRole} = render( - - Item 1 - Item 2 - Item 3 - - ) + it('hides subtrees by default', () => { + const {queryByRole} = renderWithTheme( + + + Parent + + Child + + + + ) - const items = queryAllByRole('treeitem') + const parentItem = queryByRole('treeitem', {name: 'Parent'}) + const subtree = queryByRole('group') - expect(items).toHaveLength(3) + expect(parentItem).toHaveAttribute('aria-expanded', 'false') + expect(subtree).toBeNull() + }) + + it('initializes aria-activedescendant to the first item by default', () => { + const {queryByRole} = renderWithTheme( + + Item 1 + Item 2 + Item 3 + + ) + + const root = queryByRole('tree') + const firstItem = queryByRole('treeitem', {name: 'Item 1'}) + + expect(root).toHaveAttribute('aria-activedescendant', firstItem?.id) + }) }) -it('hides subtrees by default', () => { - const {queryByRole} = render( - - - Parent - - Child - - - - ) - - const parentItem = queryByRole('treeitem', {name: 'Parent'}) - const subtree = queryByRole('group') - - expect(parentItem).toHaveAttribute('aria-expanded', 'false') - expect(subtree).toBeNull() +describe('Keyboard interactions', () => { + describe('ArrowDown', () => { + it('moves aria-activedescendant to the next visible treeitem', () => { + const {getByRole} = renderWithTheme( + + + Item 1 + + Item 1.1 + + + + Item 2 + + Item 2.1 + Item 2.2 + + + Item 3 + + ) + + const root = getByRole('tree') + const item1 = getByRole('treeitem', {name: 'Item 1'}) + const item11 = getByRole('treeitem', {name: 'Item 1.1'}) + const item2 = getByRole('treeitem', {name: 'Item 2'}) + const item3 = getByRole('treeitem', {name: 'Item 3'}) + + // aria-activedescendant should be set to the first visible treeitem by default + expect(root).toHaveAttribute('aria-activedescendant', item1.id) + + // Focus tree + root.focus() + + // Press ↓ + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + + // aria-activedescendant should now be set to item 1.1 + expect(root).toHaveAttribute('aria-activedescendant', item11.id) + + // Press ↓ + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + + // aria-activedescendant should now be set to item 2 + expect(root).toHaveAttribute('aria-activedescendant', item2.id) + + // Press ↓ + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + + // aria-activedescendant should now be set to item 3 (skips item 2.1 and item 2.2 because they are hidden) + expect(root).toHaveAttribute('aria-activedescendant', item3.id) + + // Press ↓ + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + + // aria-activedescendant should not change (item 3 is the last visible treeitem) + expect(root).toHaveAttribute('aria-activedescendant', item3.id) + }) + }) + + describe('ArrowUp', () => { + it('moves aria-activedescendant to the previous visible treeitem', () => { + const {getByRole} = renderWithTheme( + + + Item 1 + + Item 1.1 + + + + Item 2 + + Item 2.1 + Item 2.2 + + + Item 3 + + ) + + const root = getByRole('tree') + const item1 = getByRole('treeitem', {name: 'Item 1'}) + const item11 = getByRole('treeitem', {name: 'Item 1.1'}) + const item2 = getByRole('treeitem', {name: 'Item 2'}) + const item3 = getByRole('treeitem', {name: 'Item 3'}) + + // aria-activedescendant should be set to the first visible treeitem by default + expect(root).toHaveAttribute('aria-activedescendant', item1.id) + + // Focus tree + root.focus() + + // Press ↓ 4 times to move aria-activedescendant to item 3 + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + + // aria-activedescendant should now be set to item 3 + expect(root).toHaveAttribute('aria-activedescendant', item3.id) + + // Press ↑ + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowUp'}) + + // aria-activedescendant should now be set to item 2 (skips item 2.1 and item 2.2 because they are hidden) + expect(root).toHaveAttribute('aria-activedescendant', item2.id) + + // Press ↑ + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowUp'}) + + // aria-activedescendant should now be set to item 1.1 + expect(root).toHaveAttribute('aria-activedescendant', item11.id) + + // Press ↑ + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowUp'}) + + // aria-activedescendant should now be set to item 1 + expect(root).toHaveAttribute('aria-activedescendant', item1.id) + + // Press ↑ + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowUp'}) + + // aria-activedescendant should not change (item 1 is the first visible treeitem) + expect(root).toHaveAttribute('aria-activedescendant', item1.id) + }) + }) + + describe('ArrowLeft', () => { + it('collapses an expanded item', () => { + const {getByRole, queryByRole} = renderWithTheme( + + + Parent + + Child + + + + ) + + const root = getByRole('tree') + const parentItem = getByRole('treeitem', {name: 'Parent'}) + let subtree = queryByRole('group') + + // aria-activedescendant should be set to the first visible treeitem by default + expect(root).toHaveAttribute('aria-activedescendant', parentItem.id) + + // aria-expanded should be true + expect(parentItem).toHaveAttribute('aria-expanded', 'true') + + // Subtree should be visible + expect(subtree).toBeVisible() + + // Focus tree + root.focus() + + // Press ← + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowLeft'}) + + // aria-expanded should now be false + expect(parentItem).toHaveAttribute('aria-expanded', 'false') + + // aria-activedescendant should still be set to the parent treeitem + expect(root).toHaveAttribute('aria-activedescendant', parentItem.id) + + subtree = queryByRole('group') + + // Subtree should now be hidden + expect(subtree).toBeNull() + }) + + it('does nothing on a root-level collapsed item', () => { + const {getByRole} = renderWithTheme( + + + Parent + + Child + + + + ) + + const root = getByRole('tree') + const parentItem = getByRole('treeitem', {name: 'Parent'}) + + // aria-activedescendant should be set to the first visible treeitem by default + expect(root).toHaveAttribute('aria-activedescendant', parentItem.id) + + // aria-expanded should be false by default + expect(parentItem).toHaveAttribute('aria-expanded', 'false') + + // Focus tree + root.focus() + + // Press ← + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowLeft'}) + + // aria-expanded should still be false + expect(parentItem).toHaveAttribute('aria-expanded', 'false') + + // aria-activedescendant should still be set to the parent treeitem + expect(root).toHaveAttribute('aria-activedescendant', parentItem.id) + }) + + it('does nothing on a root-level end item', () => { + const {getByRole} = renderWithTheme( + + Item + + ) + + const root = getByRole('tree') + const item = getByRole('treeitem', {name: 'Item'}) + + // aria-activedescendant should be set to the first visible treeitem by default + expect(root).toHaveAttribute('aria-activedescendant', item.id) + + // Focus tree + root.focus() + + // Press ← + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowLeft'}) + + // aria-activedescendant should still be set to the item + expect(root).toHaveAttribute('aria-activedescendant', item.id) + }) + + it('moves aria-activedescendant to parent of end item', () => { + const {getByRole} = renderWithTheme( + + + Parent + + Child 1 + Child 2 + + + + ) + + const root = getByRole('tree') + const parentItem = getByRole('treeitem', {name: 'Parent'}) + const child2 = getByRole('treeitem', {name: 'Child 2'}) + + // Focus tree + root.focus() + + // Press ↓ 2 times to move aria-activedescendant to child 2 + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + + // aria-activedescendant should now be set to child 2 + expect(root).toHaveAttribute('aria-activedescendant', child2.id) + + // Press ← + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowLeft'}) + + // aria-activedescendant should now be set to parent + expect(root).toHaveAttribute('aria-activedescendant', parentItem.id) + }) + + it('moves aria-activedescendant to parent of collapsed item', () => { + const {getByRole} = renderWithTheme( + + + Parent + + Child + + Nested parent + + Nested child + + + + + + ) + + const root = getByRole('tree') + const parentItem = getByRole('treeitem', {name: 'Parent'}) + const nestedParentItem = getByRole('treeitem', {name: 'Nested parent'}) + + // Focus tree + root.focus() + + // Press ↓ 2 times to move aria-activedescendant to nested parent + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + + // aria-activedescendant should now be set to nested parent + expect(root).toHaveAttribute('aria-activedescendant', nestedParentItem.id) + + // Press ← + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowLeft'}) + + // aria-activedescendant should now be set to parent + expect(root).toHaveAttribute('aria-activedescendant', parentItem.id) + }) + }) + + describe('ArrowRight', () => { + it('expands a collapsed item', () => { + const {getByRole} = renderWithTheme( + + + Parent + + Child + + + + ) + + const root = getByRole('tree') + const parentItem = getByRole('treeitem', {name: 'Parent'}) + + // aria-activedescendant should be set to the first visible treeitem by default + expect(root).toHaveAttribute('aria-activedescendant', parentItem.id) + + // aria-expanded should be false by default + expect(parentItem).toHaveAttribute('aria-expanded', 'false') + + // Focus tree + root.focus() + + // Press → + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowRight'}) + + // aria-expanded should now be true + expect(parentItem).toHaveAttribute('aria-expanded', 'true') + + // aria-activedescendant should still be set to the parent treeitem + expect(root).toHaveAttribute('aria-activedescendant', parentItem.id) + + const subtree = getByRole('group') + + // Subtree should now be visible + expect(subtree).toBeVisible() + }) + + it('moves aria-activedescendant to first child of an expanded item', () => { + const {getByRole} = renderWithTheme( + + + Parent + + Child + + + + ) + + const root = getByRole('tree') + const parentItem = getByRole('treeitem', {name: 'Parent'}) + + // aria-activedescendant should be set to the first visible treeitem by default + expect(root).toHaveAttribute('aria-activedescendant', parentItem.id) + + // aria-expanded should be true + expect(parentItem).toHaveAttribute('aria-expanded', 'true') + + // Focus tree + root.focus() + + // Press → + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowRight'}) + + const childItem = getByRole('treeitem', {name: 'Child'}) + + // aria-activedescendant should now be set to the first child treeitem + expect(root).toHaveAttribute('aria-activedescendant', childItem.id) + + // aria-expanded should still be true + expect(parentItem).toHaveAttribute('aria-expanded', 'true') + }) + + it('does nothing on an end item', () => { + const {getByRole} = renderWithTheme( + + + Parent + + Child 1 + Child 2 + + + + ) + + const root = getByRole('tree') + const child1 = getByRole('treeitem', {name: 'Child 1'}) + + // Focus tree + root.focus() + + // Press ↓ to move aria-activedescendant to child 1 + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + + // aria-activedescendant should now be set to child 1 + expect(root).toHaveAttribute('aria-activedescendant', child1.id) + + // Press → + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowRight'}) + + // aria-activedescendant should still be set to child 1 + expect(root).toHaveAttribute('aria-activedescendant', child1.id) + }) + }) + + describe('Home', () => { + it('moves aria-activedescendant to first visible item', () => { + const {getByRole} = renderWithTheme( + + + Parent 1 + + Child 1 + + + + Parent 2 + + Child 2 + + + + Parent 3 + + Child 3 + + + + ) + + const root = getByRole('tree') + const parent1 = getByRole('treeitem', {name: 'Parent 1'}) + const parent3 = getByRole('treeitem', {name: 'Parent 2'}) + + // Focus tree + root.focus() + + // Press ↓ 2 times to move aria-activedescendant to parent 3 + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + + // aria-activedescendant should now be set to parent 3 + expect(root).toHaveAttribute('aria-activedescendant', parent3.id) + + // Press Home + fireEvent.keyDown(document.activeElement || document.body, {key: 'Home'}) + + // aria-activedescendant should now be set to parent 1 + expect(root).toHaveAttribute('aria-activedescendant', parent1.id) + }) + }) + + describe('End', () => { + it('moves aria-activedescendant to last visible item', () => { + const {getByRole} = renderWithTheme( + + + Parent 1 + + Child 1 + + + + Parent 2 + + Child 2 + + + + Parent 3 + + Child 3 + + + + ) + + const root = getByRole('tree') + const parent1 = getByRole('treeitem', {name: 'Parent 1'}) + const parent3 = getByRole('treeitem', {name: 'Parent 3'}) + + // Focus tree + root.focus() + + // aria-activedescendant should be set to parent 1 + expect(root).toHaveAttribute('aria-activedescendant', parent1.id) + + // Press End + fireEvent.keyDown(document.activeElement || document.body, {key: 'End'}) + + // aria-activedescendant should now be set to parent 3 + expect(root).toHaveAttribute('aria-activedescendant', parent3.id) + + // Press → to expand parent 3 + fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowRight'}) + + // Press End + fireEvent.keyDown(document.activeElement || document.body, {key: 'End'}) + + const child3 = getByRole('treeitem', {name: 'Child 3'}) + + // aria-activedescendant should now be set to child 3 + expect(root).toHaveAttribute('aria-activedescendant', child3.id) + }) + }) + + describe('Enter', () => { + it('calls onSelect function if provided', () => { + const onSelect = jest.fn() + const {getByRole} = renderWithTheme( + + Item + + ) + + const root = getByRole('tree') + + // Focus tree + root.focus() + + // Press Enter + fireEvent.keyDown(document.activeElement || document.body, {key: 'Enter'}) + + // onSelect should have been called + expect(onSelect).toHaveBeenCalledTimes(1) + }) + + it('toggles expanded state if no onSelect function is provided', () => { + const {getByRole, queryByRole} = renderWithTheme( + + + Parent + + Child 1 + Child 2 + + + + ) + + const root = getByRole('tree') + const parent = getByRole('treeitem', {name: 'Parent'}) + + // Focus tree + root.focus() + + // aria-expanded should be false + expect(parent).toHaveAttribute('aria-expanded', 'false') + + // Press Enter + fireEvent.keyDown(document.activeElement || document.body, {key: 'Enter'}) + + // aria-expanded should now be true + expect(parent).toHaveAttribute('aria-expanded', 'true') + + // Subtree should be visible + expect(queryByRole('group')).toBeVisible() + + // Press Enter + fireEvent.keyDown(document.activeElement || document.body, {key: 'Enter'}) + + // aria-expanded should now be false + expect(parent).toHaveAttribute('aria-expanded', 'false') + + // Subtree should no longer be visible + expect(queryByRole('group')).not.toBeInTheDocument() + }) + + it('navigates to href if provided', () => { + const windowSpy = jest.spyOn(window, 'open') + const onSelect = jest.fn() + const {getByRole} = renderWithTheme( + + + Item + + + ) + + const root = getByRole('tree') + + // Focus tree + root.focus() + + // Press Enter + fireEvent.keyDown(document.activeElement || document.body, {key: 'Enter'}) + + // window.open should have been called + expect(windowSpy).toHaveBeenCalledWith('#', '_self') + + // onSelect should have been called + expect(onSelect).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/src/TreeView/TreeView.tsx b/src/TreeView/TreeView.tsx index 28e90096b2d..c74e5e00234 100644 --- a/src/TreeView/TreeView.tsx +++ b/src/TreeView/TreeView.tsx @@ -1,10 +1,21 @@ import {ChevronDownIcon, ChevronRightIcon} from '@primer/octicons-react' +import {useSSRSafeId} from '@react-aria/ssr' import React from 'react' +import styled from 'styled-components' import Box from '../Box' +import sx, {SxProp} from '../sx' +import {Theme} from '../ThemeProvider' // ---------------------------------------------------------------------------- // Context +const RootContext = React.createContext<{ + activeDescendant: string + setActiveDescendant?: React.Dispatch> +}>({ + activeDescendant: '' +}) + const ItemContext = React.createContext<{ level: number isExpanded: boolean @@ -22,63 +33,274 @@ export type TreeViewProps = { children: React.ReactNode } +const UlBox = styled.ul(sx) + const Root: React.FC = ({'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, children}) => { + const [activeDescendant, setActiveDescendant] = React.useState('') + + React.useEffect(() => { + // Initialize the active descendant to the first item in the tree + if (!activeDescendant) { + const firstItem = document.querySelector('[role="treeitem"]') + if (firstItem) setActiveDescendant(firstItem.id) + } + }, [activeDescendant]) + return ( - - {children} - + + { + const activeElement = document.getElementById(activeDescendant) + + if (!activeElement) return + + const nextElement = getNextFocusableElement(activeElement, event) + if (nextElement) { + // Move active descendant if necessary + setActiveDescendant(nextElement.id) + event.preventDefault() + } else { + // If the active descendant didn't change, + // forward the event to the active descendant + activeElement.dispatchEvent(new KeyboardEvent(event.type, event)) + } + }} + > + {children} + + ) } +// DOM utilities used for focus management + +function getNextFocusableElement( + activeElement: HTMLElement, + event: React.KeyboardEvent +): HTMLElement | undefined { + const elementState = getElementState(activeElement) + + // 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) + + case 'open ArrowLeft': + // Close node; don't change focus + return + + case 'closed ArrowRight': + // Open node; don't change focus + return + + case 'closed ArrowLeft': + // Focus parent element + return getParentElement(activeElement) + + case 'end ArrowRight': + // Do nothing + return + + case 'end ArrowLeft': + // Focus parent element + return getParentElement(activeElement) + } + + // ArrowUp, ArrowDown, Home, and End behavior are the same regarless of element state + switch (event.key) { + case 'ArrowUp': + // Focus previous visible element + return getVisibleElement(activeElement, 'previous') + + case 'ArrowDown': + // Focus next visible element + return getVisibleElement(activeElement, 'next') + + case 'Home': + // Focus first visible element + return getFirstElement(activeElement) + + case 'End': + // Focus last visible element + return getLastElement(activeElement) + } +} + +function getElementState(element: HTMLElement): 'open' | 'closed' | 'end' { + if (element.getAttribute('role') !== 'treeitem') { + throw new Error('Element is not a treeitem') + } + + switch (element.getAttribute('aria-expanded')) { + case 'true': + return 'open' + case 'false': + return 'closed' + default: + return 'end' + } +} + +function getVisibleElement(element: HTMLElement, direction: 'next' | 'previous'): HTMLElement | undefined { + const root = element.closest('[role=tree]') + + if (!root) return + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, node => { + if (!(node instanceof HTMLElement)) return NodeFilter.FILTER_SKIP + return node.getAttribute('role') === 'treeitem' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP + }) + + let current = walker.firstChild() + + while (current !== element) { + current = walker.nextNode() + } + + let next = direction === 'next' ? walker.nextNode() : walker.previousNode() + + // If next element is nested inside a collapsed subtree, continue iterating + while (next instanceof HTMLElement && next.parentElement?.closest('[role=treeitem][aria-expanded=false]')) { + next = direction === 'next' ? walker.nextNode() : walker.previousNode() + } + + return next instanceof HTMLElement ? next : undefined +} + +function getFirstChildElement(element: HTMLElement): HTMLElement | undefined { + const firstChild = element.querySelector('[role=treeitem]') + return firstChild instanceof HTMLElement ? firstChild : undefined +} + +function getParentElement(element: HTMLElement): HTMLElement | undefined { + const group = element.closest('[role=group]') + const parent = group?.closest('[role=treeitem]') + return parent instanceof HTMLElement ? parent : undefined +} + +function getFirstElement(element: HTMLElement): HTMLElement | undefined { + const root = element.closest('[role=tree]') + const first = root?.querySelector('[role=treeitem]') + return first instanceof HTMLElement ? first : undefined +} + +function getLastElement(element: HTMLElement): HTMLElement | undefined { + const root = element.closest('[role=tree]') + const items = Array.from(root?.querySelectorAll('[role=treeitem]') || []) + + // If there are no items, return undefined + if (items.length === 0) return + + let index = items.length - 1 + let last = items[index] + + // If last element is nested inside a collapsed subtree, continue iterating + while ( + index > 0 && + last instanceof HTMLElement && + last.parentElement?.closest('[role=treeitem][aria-expanded=false]') + ) { + index -= 1 + last = items[index] + } + + return last instanceof HTMLElement ? last : undefined +} + // ---------------------------------------------------------------------------- // TreeView.Item export type TreeViewItemProps = { children: React.ReactNode - onSelect?: (event: React.MouseEvent | React.KeyboardEvent) => void + defaultExpanded?: boolean + onSelect?: (event: React.MouseEvent | KeyboardEvent) => void onToggle?: (isExpanded: boolean) => void } -const Item: React.FC = ({onSelect, onToggle, children}) => { +const Item: React.FC = ({defaultExpanded = false, onSelect, onToggle, children}) => { + const {setActiveDescendant} = React.useContext(RootContext) + const itemId = useSSRSafeId() + const labelId = useSSRSafeId() + const itemRef = React.useRef(null) const {level} = React.useContext(ItemContext) - const [isExpanded, setIsExpanded] = React.useState(false) + const [isExpanded, setIsExpanded] = React.useState(defaultExpanded) const {hasSubTree, subTree, childrenWithoutSubTree} = useSubTree(children) // Expand or collapse the subtree - function toggle() { - onToggle?.(!isExpanded) - setIsExpanded(!isExpanded) - } + const toggle = React.useCallback( + (event?: React.MouseEvent) => { + onToggle?.(!isExpanded) + setIsExpanded(!isExpanded) + event?.stopPropagation() + }, + [isExpanded, onToggle] + ) + + React.useEffect(() => { + const element = itemRef.current + + function handleKeyDown(event: KeyboardEvent) { + // WARNING: Removing this line will cause an infinite loop! + // The root element receives all keyboard events and forwards them + // to the active descendant. If we don't stop propagation here, + // the event will bubble back up to the root element and be forwarded + // back to the active descendant infinitely. + event.stopPropagation() + + switch (event.key) { + case 'Enter': + if (onSelect) { + onSelect(event) + } else { + toggle() + } + break + + case 'ArrowRight': + if (!isExpanded) setIsExpanded(true) + break + + case 'ArrowLeft': + if (isExpanded) setIsExpanded(false) + break + } + } + + element?.addEventListener('keydown', handleKeyDown) + return () => element?.removeEventListener('keydown', handleKeyDown) + }, [toggle, onSelect, isExpanded]) return (
  • { - if (event.key === ' ' || event.key === 'Enter') { - onSelect?.(event) - } - }} > { + setActiveDescendant?.(itemId) if (onSelect) { onSelect(event) } else { - toggle() + toggle(event) } }} sx={{ @@ -94,6 +316,9 @@ const Item: React.FC = ({onSelect, onToggle, children}) => { transition: 'background 33.333ms linear', '&:hover': { backgroundColor: 'actionListItem.default.hoverBg' + }, + [`[role=tree][aria-activedescendant="${itemId}"]:focus-visible &`]: { + boxShadow: (theme: Theme) => `0 0 0 2px ${theme.colors.accent.emphasis}` } }} > @@ -101,8 +326,8 @@ const Item: React.FC = ({onSelect, onToggle, children}) => { { if (onSelect) { - toggle() - event.stopPropagation() + setActiveDescendant?.(itemId) + toggle(event) } }} sx={{ @@ -123,6 +348,7 @@ const Item: React.FC = ({onSelect, onToggle, children}) => { ) : null} = ({href, onSelect, ...props}) = return ( { - // Navigate by clicking or pressing enter - if (event.type === 'click' || ('key' in event && event.key === 'Enter')) { - window.open(href) - } - + window.open(href, '_self') onSelect?.(event) }} {...props}