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])
+}