From 54b690cd046dde3b53c142e4132be86a0a3f49d7 Mon Sep 17 00:00:00 2001 From: Cole Bemis Date: Tue, 20 Sep 2022 14:34:44 -0700 Subject: [PATCH 1/7] Move getNextFocusableElement into its own file --- src/TreeView/TreeView.tsx | 137 +----------------------- src/TreeView/getNextFocusableElement.ts | 135 +++++++++++++++++++++++ 2 files changed, 136 insertions(+), 136 deletions(-) create mode 100644 src/TreeView/getNextFocusableElement.ts diff --git a/src/TreeView/TreeView.tsx b/src/TreeView/TreeView.tsx index 4c1c7c97839..a17ceafe76e 100644 --- a/src/TreeView/TreeView.tsx +++ b/src/TreeView/TreeView.tsx @@ -5,6 +5,7 @@ import styled from 'styled-components' import Box from '../Box' import sx, {SxProp} from '../sx' import {Theme} from '../ThemeProvider' +import {getNextFocusableElement} from './getNextFocusableElement' // ---------------------------------------------------------------------------- // Context @@ -95,142 +96,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/getNextFocusableElement.ts b/src/TreeView/getNextFocusableElement.ts new file mode 100644 index 00000000000..14d23465513 --- /dev/null +++ b/src/TreeView/getNextFocusableElement.ts @@ -0,0 +1,135 @@ +// DOM utilities used for focus management + +export 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 +} From 9c3807736af690881280c368a03cb90eff91f786 Mon Sep 17 00:00:00 2001 From: Cole Bemis Date: Tue, 20 Sep 2022 16:00:09 -0700 Subject: [PATCH 2/7] Create useTypeahead hook --- src/TreeView/TreeView.tsx | 19 ++++--- src/TreeView/useTypeahead.ts | 97 ++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 src/TreeView/useTypeahead.ts diff --git a/src/TreeView/TreeView.tsx b/src/TreeView/TreeView.tsx index a17ceafe76e..fd5a6245a82 100644 --- a/src/TreeView/TreeView.tsx +++ b/src/TreeView/TreeView.tsx @@ -6,6 +6,7 @@ import Box from '../Box' import sx, {SxProp} from '../sx' import {Theme} from '../ThemeProvider' import {getNextFocusableElement} from './getNextFocusableElement' +import {useTypeahead} from './useTypeahead' // ---------------------------------------------------------------------------- // Context @@ -37,13 +38,18 @@ 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 containerRef = React.useRef(null) const [activeDescendant, setActiveDescendant] = React.useState('') + useTypeahead({ + containerRef, + onFocusChange: element => setActiveDescendant(element.id) + }) + React.useEffect(() => { - if (rootRef.current && !activeDescendant) { - const currentItem = rootRef.current.querySelector('[role="treeitem"][aria-current="true"]') - const firstItem = rootRef.current.querySelector('[role="treeitem"]') + 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) { @@ -54,12 +60,12 @@ const Root: React.FC = ({'aria-label': ariaLabel, 'aria-labelledb setActiveDescendant(firstItem.id) } } - }, [rootRef, activeDescendant]) + }, [containerRef, activeDescendant]) return ( = ({'aria-label': ariaLabel, 'aria-labelledb if (!activeElement) return const nextElement = getNextFocusableElement(activeElement, event) + if (nextElement) { // Move active descendant if necessary setActiveDescendant(nextElement.id) diff --git a/src/TreeView/useTypeahead.ts b/src/TreeView/useTypeahead.ts new file mode 100644 index 00000000000..527c777a904 --- /dev/null +++ b/src/TreeView/useTypeahead.ts @@ -0,0 +1,97 @@ +import React from 'react' + +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 handleKeyDown = React.useCallback( + (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 + + // Combine the existing search value with the new key press + const newSearchValue = searchValue + event.key + + // Get focusable elements + const elements = Array.from(containerRef.current?.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 (newSearchValue.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(newSearchValue.toLowerCase()) + }) + + // If a match is found, focus it + if (nextElement) { + onFocusChange(nextElement) + } + + // Update the search value + setSearchValue(newSearchValue) + + // Reset timer + window.clearTimeout(timeoutRef.current) + timeoutRef.current = window.setTimeout(() => setSearchValue(''), 300) + + // Prevent default behavior + event.preventDefault() + event.stopPropagation() + }, + [containerRef, searchValue, onFocusChange] + ) + + React.useEffect(() => { + const container = containerRef.current + + if (!container) return + + container.addEventListener('keydown', handleKeyDown) + return () => container.removeEventListener('keydown', handleKeyDown) + }, [containerRef, handleKeyDown]) +} + +/** + * 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]) +} From f7b8a81f8cb130a30cd5db17e5ee190c11f81ebe Mon Sep 17 00:00:00 2001 From: Cole Bemis Date: Tue, 20 Sep 2022 16:09:59 -0700 Subject: [PATCH 3/7] Create useActiveDescendant hook --- src/TreeView/TreeView.tsx | 38 +---------- ...sableElement.ts => useActiveDescendant.ts} | 66 +++++++++++++++++-- 2 files changed, 65 insertions(+), 39 deletions(-) rename src/TreeView/{getNextFocusableElement.ts => useActiveDescendant.ts} (65%) diff --git a/src/TreeView/TreeView.tsx b/src/TreeView/TreeView.tsx index fd5a6245a82..b3d58da2e17 100644 --- a/src/TreeView/TreeView.tsx +++ b/src/TreeView/TreeView.tsx @@ -5,7 +5,7 @@ import styled from 'styled-components' import Box from '../Box' import sx, {SxProp} from '../sx' import {Theme} from '../ThemeProvider' -import {getNextFocusableElement} from './getNextFocusableElement' +import {useActiveDescendant} from './useActiveDescendant' import {useTypeahead} from './useTypeahead' // ---------------------------------------------------------------------------- @@ -39,29 +39,14 @@ const UlBox = styled.ul(sx) const Root: React.FC = ({'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, children}) => { const containerRef = React.useRef(null) - const [activeDescendant, setActiveDescendant] = React.useState('') + + const [activeDescendant, setActiveDescendant] = useActiveDescendant({containerRef}) useTypeahead({ containerRef, onFocusChange: element => setActiveDescendant(element.id) }) - 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]) - 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} diff --git a/src/TreeView/getNextFocusableElement.ts b/src/TreeView/useActiveDescendant.ts similarity index 65% rename from src/TreeView/getNextFocusableElement.ts rename to src/TreeView/useActiveDescendant.ts index 14d23465513..146d7087788 100644 --- a/src/TreeView/getNextFocusableElement.ts +++ b/src/TreeView/useActiveDescendant.ts @@ -1,9 +1,67 @@ +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: React.KeyboardEvent -): HTMLElement | undefined { +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 From 030243993e76ed7f269d20c12e3303bd7c039cfe Mon Sep 17 00:00:00 2001 From: Cole Bemis Date: Tue, 20 Sep 2022 16:40:15 -0700 Subject: [PATCH 4/7] Add typeahead tests --- src/TreeView/TreeView.test.tsx | 146 +++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) 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) + }) + }) }) From 667f9ac913422a22542e3e376ddc170dd4452ab9 Mon Sep 17 00:00:00 2001 From: Cole Bemis Date: Tue, 20 Sep 2022 17:01:38 -0700 Subject: [PATCH 5/7] Create loud-toys-explode.md --- .changeset/loud-toys-explode.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/loud-toys-explode.md 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 From 6118df53dfb3cfc928bbddadecd7fdb261e33b2f Mon Sep 17 00:00:00 2001 From: Cole Bemis Date: Wed, 21 Sep 2022 13:57:02 -0700 Subject: [PATCH 6/7] Refactor useTypeahead to work better with useEffect hook Co-authored-by: Josh Black --- src/TreeView/useTypeahead.ts | 106 ++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 46 deletions(-) diff --git a/src/TreeView/useTypeahead.ts b/src/TreeView/useTypeahead.ts index 527c777a904..8e76b38b06b 100644 --- a/src/TreeView/useTypeahead.ts +++ b/src/TreeView/useTypeahead.ts @@ -8,70 +8,84 @@ type TypeaheadOptions = { export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) { const [searchValue, setSearchValue] = React.useState('') const timeoutRef = React.useRef(0) + const onFocusChangeRef = React.useRef(onFocusChange) - const handleKeyDown = React.useCallback( - (event: KeyboardEvent) => { + // 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 - // Combine the existing search value with the new key press - const newSearchValue = searchValue + event.key - - // Get focusable elements - const elements = Array.from(containerRef.current?.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) + // Update the existing search value with the new key press + setSearchValue(value => value + event.key) - // Remove the active descendant from the beginning of the array - // when the user initiates a new search - if (newSearchValue.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(newSearchValue.toLowerCase()) - }) - - // If a match is found, focus it - if (nextElement) { - onFocusChange(nextElement) - } - - // Update the search value - setSearchValue(newSearchValue) - - // Reset timer + // Reset the timeout window.clearTimeout(timeoutRef.current) timeoutRef.current = window.setTimeout(() => setSearchValue(''), 300) // Prevent default behavior event.preventDefault() event.stopPropagation() - }, - [containerRef, searchValue, onFocusChange] - ) + } + container.addEventListener('keydown', onKeyDown) + return () => container.removeEventListener('keydown', onKeyDown) + }, [containerRef]) + + // Update focus when the search value changes React.useEffect(() => { - const container = containerRef.current + // Don't change focus if the search value is empty + if (!searchValue) return - if (!container) return + if (!containerRef.current) return + const container = containerRef.current - container.addEventListener('keydown', handleKeyDown) - return () => container.removeEventListener('keydown', handleKeyDown) - }, [containerRef, handleKeyDown]) + // 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]) + + // Clear timeout on unmount + React.useEffect(() => { + return () => window.clearTimeout(timeoutRef.current) + }, []) } /** From c2f0c6c174493ee2d9be4e79aa5f00390f9df142 Mon Sep 17 00:00:00 2001 From: Cole Bemis Date: Thu, 22 Sep 2022 16:09:54 -0700 Subject: [PATCH 7/7] Use useSafeTimeout hook --- src/TreeView/useTypeahead.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/TreeView/useTypeahead.ts b/src/TreeView/useTypeahead.ts index 8e76b38b06b..3966e616b21 100644 --- a/src/TreeView/useTypeahead.ts +++ b/src/TreeView/useTypeahead.ts @@ -1,4 +1,5 @@ import React from 'react' +import useSafeTimeout from '../hooks/useSafeTimeout' type TypeaheadOptions = { containerRef: React.RefObject @@ -9,6 +10,7 @@ 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(() => { @@ -31,8 +33,8 @@ export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) { setSearchValue(value => value + event.key) // Reset the timeout - window.clearTimeout(timeoutRef.current) - timeoutRef.current = window.setTimeout(() => setSearchValue(''), 300) + safeClearTimeout(timeoutRef.current) + timeoutRef.current = safeSetTimeout(() => setSearchValue(''), 300) // Prevent default behavior event.preventDefault() @@ -41,7 +43,7 @@ export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) { container.addEventListener('keydown', onKeyDown) return () => container.removeEventListener('keydown', onKeyDown) - }, [containerRef]) + }, [containerRef, safeClearTimeout, safeSetTimeout]) // Update focus when the search value changes React.useEffect(() => { @@ -81,11 +83,6 @@ export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) { onFocusChangeRef.current(nextElement) } }, [searchValue, containerRef]) - - // Clear timeout on unmount - React.useEffect(() => { - return () => window.clearTimeout(timeoutRef.current) - }, []) } /**