Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/loud-toys-explode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

Add support for typeahead search of items in a TreeView
146 changes: 146 additions & 0 deletions src/TreeView/TreeView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TreeView aria-label="Test tree">
<TreeView.Item>
Apple
<TreeView.SubTree>
<TreeView.Item>Cantalope</TreeView.Item>
</TreeView.SubTree>
</TreeView.Item>
<TreeView.Item>Banana</TreeView.Item>
<TreeView.Item>Cherry</TreeView.Item>
<TreeView.Item>Cucumber</TreeView.Item>
</TreeView>
)

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(
<TreeView aria-label="Test tree">
<TreeView.Item>Apple</TreeView.Item>
<TreeView.Item>Banana</TreeView.Item>
<TreeView.Item>Cherry</TreeView.Item>
<TreeView.Item>Durian</TreeView.Item>
</TreeView>
)

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(
<TreeView aria-label="Test tree">
<TreeView.Item>Apple</TreeView.Item>
<TreeView.Item>Banana</TreeView.Item>
<TreeView.Item>Cherry</TreeView.Item>
<TreeView.Item>Cantalope 1</TreeView.Item>
<TreeView.Item>Cantalope 2</TreeView.Item>
</TreeView>
)

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(
<TreeView aria-label="Test tree">
<TreeView.Item>Cucumber</TreeView.Item>
<TreeView.Item current>Cherry</TreeView.Item>
<TreeView.Item>Cantalope</TreeView.Item>
</TreeView>
)

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(
<TreeView aria-label="Test tree">
<TreeView.Item>Cucumber</TreeView.Item>
<TreeView.Item>Cherry</TreeView.Item>
<TreeView.Item current>Cantalope</TreeView.Item>
<TreeView.Item>Apple</TreeView.Item>
</TreeView>
)

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)
})
})
})
178 changes: 9 additions & 169 deletions src/TreeView/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -36,29 +38,19 @@ export type TreeViewProps = {
const UlBox = styled.ul<SxProp>(sx)

const Root: React.FC<TreeViewProps> = ({'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, children}) => {
const rootRef = React.useRef<HTMLUListElement>(null)
const [activeDescendant, setActiveDescendant] = React.useState('')
const containerRef = React.useRef<HTMLUListElement>(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 (
<RootContext.Provider value={{activeDescendant, setActiveDescendant}}>
<UlBox
ref={rootRef}
ref={containerRef}
tabIndex={0}
role="tree"
aria-label={ariaLabel}
Expand All @@ -72,165 +64,13 @@ const Root: React.FC<TreeViewProps> = ({'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}
</UlBox>
</RootContext.Provider>
)
}

// DOM utilities used for focus management

function getNextFocusableElement(
activeElement: HTMLElement,
event: React.KeyboardEvent<HTMLElement>
): 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

Expand Down
Loading