diff --git a/.changeset/loud-toys-explode.md b/.changeset/loud-toys-explode.md new file mode 100644 index 00000000000..abac35ac7ce --- /dev/null +++ b/.changeset/loud-toys-explode.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +Add support for typeahead search of items in a TreeView diff --git a/src/TreeView/TreeView.test.tsx b/src/TreeView/TreeView.test.tsx index 877b2742ace..600201ec02b 100644 --- a/src/TreeView/TreeView.test.tsx +++ b/src/TreeView/TreeView.test.tsx @@ -794,4 +794,150 @@ describe('Keyboard interactions', () => { expect(onSelect).toHaveBeenCalledTimes(1) }) }) + + describe('Typeahead', () => { + it('moves aria-activedescendant to the next item that matches the typed character', () => { + const {getByRole} = renderWithTheme( + + + Apple + + Cantalope + + + Banana + Cherry + Cucumber + + ) + + const root = getByRole('tree') + const apple = getByRole('treeitem', {name: 'Apple'}) + const cherry = getByRole('treeitem', {name: 'Cherry'}) + + // Focus tree + root.focus() + + // aria-activedescendant should be set to apple + expect(root).toHaveAttribute('aria-activedescendant', apple.id) + + // Press C + fireEvent.keyDown(document.activeElement || document.body, {key: 'c'}) + + // aria-activedescendant should now be set to cherry + expect(root).toHaveAttribute('aria-activedescendant', cherry.id) + + // Notice that the aria-activedescendant is not set to cantalope because + // it is a child of apple and apple is collapsed. + }) + + it('does nothing if no items match the typed character', () => { + const {getByRole} = renderWithTheme( + + Apple + Banana + Cherry + Durian + + ) + + const root = getByRole('tree') + const apple = getByRole('treeitem', {name: 'Apple'}) + + // Focus tree + root.focus() + + // aria-activedescendant should be set to apple + expect(root).toHaveAttribute('aria-activedescendant', apple.id) + + // Press Z + fireEvent.keyDown(document.activeElement || document.body, {key: 'z'}) + + // aria-activedescendant should still be set to apple + expect(root).toHaveAttribute('aria-activedescendant', apple.id) + }) + + it('supports multiple typed characters', () => { + const {getByRole} = renderWithTheme( + + Apple + Banana + Cherry + Cantalope 1 + Cantalope 2 + + ) + + const root = getByRole('tree') + const apple = getByRole('treeitem', {name: 'Apple'}) + const cantalope = getByRole('treeitem', {name: 'Cantalope 1'}) + + // Focus tree + root.focus() + + // aria-activedescendant should be set to apple + expect(root).toHaveAttribute('aria-activedescendant', apple.id) + + // Press C + A + N + fireEvent.keyDown(document.activeElement || document.body, {key: 'c'}) + fireEvent.keyDown(document.activeElement || document.body, {key: 'a'}) + fireEvent.keyDown(document.activeElement || document.body, {key: 'n'}) + + // aria-activedescendant should now be set to cantalope + expect(root).toHaveAttribute('aria-activedescendant', cantalope.id) + }) + + it('prioritizes items following the current aria-activedescendant', () => { + const {getByRole} = renderWithTheme( + + Cucumber + Cherry + Cantalope + + ) + + const root = getByRole('tree') + const cherry = getByRole('treeitem', {name: 'Cherry'}) + const cantalope = getByRole('treeitem', {name: 'Cantalope'}) + + // Focus tree + root.focus() + + // aria-activedescendant should be set to cherry + expect(root).toHaveAttribute('aria-activedescendant', cherry.id) + + // Press C + fireEvent.keyDown(document.activeElement || document.body, {key: 'c'}) + + // aria-activedescendant should now be set to cantalope + expect(root).toHaveAttribute('aria-activedescendant', cantalope.id) + }) + + it('wraps around to the beginning if no items match after the current aria-activedescendant', () => { + const {getByRole} = renderWithTheme( + + Cucumber + Cherry + Cantalope + Apple + + ) + + const root = getByRole('tree') + const cantalope = getByRole('treeitem', {name: 'Cantalope'}) + const cucumber = getByRole('treeitem', {name: 'Cucumber'}) + + // Focus tree + root.focus() + + // aria-activedescendant should be set to cantalope + expect(root).toHaveAttribute('aria-activedescendant', cantalope.id) + + // Press C + fireEvent.keyDown(document.activeElement || document.body, {key: 'c'}) + + // aria-activedescendant should now be set to cucumber + expect(root).toHaveAttribute('aria-activedescendant', cucumber.id) + }) + }) }) diff --git a/src/TreeView/TreeView.tsx b/src/TreeView/TreeView.tsx index f0ff0c69bb1..5c752e62666 100644 --- a/src/TreeView/TreeView.tsx +++ b/src/TreeView/TreeView.tsx @@ -5,6 +5,8 @@ import styled from 'styled-components' import Box from '../Box' import sx, {SxProp} from '../sx' import {Theme} from '../ThemeProvider' +import {useActiveDescendant} from './useActiveDescendant' +import {useTypeahead} from './useTypeahead' // ---------------------------------------------------------------------------- // Context @@ -36,29 +38,19 @@ export type TreeViewProps = { const UlBox = styled.ul(sx) const Root: React.FC = ({'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, children}) => { - const rootRef = React.useRef(null) - const [activeDescendant, setActiveDescendant] = React.useState('') + const containerRef = React.useRef(null) - React.useEffect(() => { - if (rootRef.current && !activeDescendant) { - const currentItem = rootRef.current.querySelector('[role="treeitem"][aria-current="true"]') - const firstItem = rootRef.current.querySelector('[role="treeitem"]') + const [activeDescendant, setActiveDescendant] = useActiveDescendant({containerRef}) - // If current item exists, use it as the initial value for active descendant - if (currentItem) { - setActiveDescendant(currentItem.id) - } - // Otherwise, initialize the active descendant to the first item in the tree - else if (firstItem) { - setActiveDescendant(firstItem.id) - } - } - }, [rootRef, activeDescendant]) + useTypeahead({ + containerRef, + onFocusChange: element => setActiveDescendant(element.id) + }) return ( = ({'aria-label': ariaLabel, 'aria-labelledb // instead of the tree itself outline: 0 }} - onKeyDown={event => { - 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} @@ -95,142 +71,6 @@ const Root: React.FC = ({'aria-label': ariaLabel, 'aria-labelledb ) } -// 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 diff --git a/src/TreeView/useActiveDescendant.ts b/src/TreeView/useActiveDescendant.ts new file mode 100644 index 00000000000..146d7087788 --- /dev/null +++ b/src/TreeView/useActiveDescendant.ts @@ -0,0 +1,193 @@ +import React from 'react' + +type ActiveDescendantOptions = { + containerRef: React.RefObject +} + +export function useActiveDescendant({ + containerRef +}: ActiveDescendantOptions): [string, React.Dispatch>] { + const [activeDescendant, setActiveDescendant] = React.useState('') + + // Initialize value of active descendant + React.useEffect(() => { + if (containerRef.current && !activeDescendant) { + const currentItem = containerRef.current.querySelector('[role="treeitem"][aria-current="true"]') + const firstItem = containerRef.current.querySelector('[role="treeitem"]') + + // If current item exists, use it as the initial value for active descendant + if (currentItem) { + setActiveDescendant(currentItem.id) + } + // Otherwise, initialize the active descendant to the first item in the tree + else if (firstItem) { + setActiveDescendant(firstItem.id) + } + } + }, [containerRef, activeDescendant]) + + const handleKeyDown = React.useCallback( + (event: KeyboardEvent) => { + 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)) + } + }, + [activeDescendant] + ) + + React.useEffect(() => { + const container = containerRef.current + + if (!container) return + + container.addEventListener('keydown', handleKeyDown) + return () => container.removeEventListener('keydown', handleKeyDown) + }, [containerRef, handleKeyDown]) + + return [activeDescendant, setActiveDescendant] +} + +// DOM utilities used for focus management + +export function getNextFocusableElement(activeElement: HTMLElement, event: 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 +} diff --git a/src/TreeView/useTypeahead.ts b/src/TreeView/useTypeahead.ts new file mode 100644 index 00000000000..3966e616b21 --- /dev/null +++ b/src/TreeView/useTypeahead.ts @@ -0,0 +1,108 @@ +import React from 'react' +import useSafeTimeout from '../hooks/useSafeTimeout' + +type TypeaheadOptions = { + containerRef: React.RefObject + onFocusChange: (element: Element) => void +} + +export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) { + const [searchValue, setSearchValue] = React.useState('') + const timeoutRef = React.useRef(0) + const onFocusChangeRef = React.useRef(onFocusChange) + const {safeSetTimeout, safeClearTimeout} = useSafeTimeout() + + // Update the ref when the callback changes + React.useEffect(() => { + onFocusChangeRef.current = onFocusChange + }, [onFocusChange]) + + // Update the search value when the user types + React.useEffect(() => { + if (!containerRef.current) return + const container = containerRef.current + + function onKeyDown(event: KeyboardEvent) { + // Ignore key presses that don't produce a character value + if (!event.key || event.key.length > 1) return + + // Ignore key presses that occur with a modifier + if (event.ctrlKey || event.altKey || event.metaKey) return + + // Update the existing search value with the new key press + setSearchValue(value => value + event.key) + + // Reset the timeout + safeClearTimeout(timeoutRef.current) + timeoutRef.current = safeSetTimeout(() => setSearchValue(''), 300) + + // Prevent default behavior + event.preventDefault() + event.stopPropagation() + } + + container.addEventListener('keydown', onKeyDown) + return () => container.removeEventListener('keydown', onKeyDown) + }, [containerRef, safeClearTimeout, safeSetTimeout]) + + // Update focus when the search value changes + React.useEffect(() => { + // Don't change focus if the search value is empty + if (!searchValue) return + + if (!containerRef.current) return + const container = containerRef.current + + // Get focusable elements + const elements = Array.from(container.querySelectorAll('[role="treeitem"]')) + // Filter out collapsed items + .filter(element => !element.parentElement?.closest('[role=treeitem][aria-expanded=false]')) + + // Get the index of active descendant + const activeDescendantIndex = elements.findIndex( + element => element.id === containerRef.current?.getAttribute('aria-activedescendant') + ) + + // Wrap the array elements such that the active descendant is at the beginning + let sortedElements = wrapArray(elements, activeDescendantIndex) + + // Remove the active descendant from the beginning of the array + // when the user initiates a new search + if (searchValue.length === 1) { + sortedElements = sortedElements.slice(1) + } + + // Find the first element that matches the search value + const nextElement = sortedElements.find(element => { + const name = getAccessibleName(element).toLowerCase() + return name.startsWith(searchValue.toLowerCase()) + }) + + // If a match is found, focus it + if (nextElement) { + onFocusChangeRef.current(nextElement) + } + }, [searchValue, containerRef]) +} + +/** + * Returns the accessible name of an element + */ +function getAccessibleName(element: Element) { + const label = element.getAttribute('aria-label') + const labelledby = element.getAttribute('aria-labelledby') + + if (label) return label + if (labelledby) return document.getElementById(labelledby)?.textContent ?? '' + return element.textContent ?? '' +} + +/** + * Wraps an array around itself at a given start index + * + * @example + * wrapArray(['a', 'b', 'c', 'd'], 2) // ['c', 'd', 'a', 'b'] + */ +function wrapArray(array: T[], startIndex: number) { + return array.map((_, index) => array[(startIndex + index) % array.length]) +}