UNPKG

sanity

Version:

Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches

202 lines (172 loc) 5.05 kB
import {useCallback, useEffect, useState} from 'react' import {type RovingFocusProps} from './types' const MUTATION_ATTRIBUTE_FILTER = ['aria-hidden', 'disabled', 'href'] const FOCUSABLE = 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])' function getFocusableElements(element: HTMLElement) { return [...(element.querySelectorAll(FOCUSABLE) as any)].filter( (el) => !el.hasAttribute('disabled') && el.getAttribute('aria-hidden') !== 'true', ) as HTMLElement[] } /** * This hook handles focus with the keyboard arrows. * * @see {@link https://a11y-solutions.stevenwoodson.com/solutions/focus/roving-focus/ | Roving focus definition} * * @example * ```tsx * function MyComponent() { * const [rootElement, setRootElement] = setRootElement(null) * * useRovingFocus({ * rootElement: rootElement, * }) * * return ( * <div ref={setRootElement}> * <button>Button</button> * <button>Button</button> * <button>Button</button> * </div> * ) * } * ``` * * * @hidden * @beta */ export function useRovingFocus(props: RovingFocusProps): undefined { const { direction = 'horizontal', initialFocus, loop = true, navigation = ['arrows'], pause = false, rootElement, } = props const [focusedIndex, setFocusedIndex] = useState<number>(-1) const [focusableElements, setFocusableElements] = useState<HTMLElement[]>([]) const focusableLen = focusableElements.length const lastFocusableIndex = focusableLen - 1 /** * Determine what keys to listen to depending on direction */ const nextKey = direction === 'horizontal' ? 'ArrowRight' : 'ArrowDown' const prevKey = direction === 'horizontal' ? 'ArrowLeft' : 'ArrowUp' /** * Set focusable elements in state */ const handleSetElements = useCallback(() => { if (rootElement) { const els = getFocusableElements(rootElement) setFocusableElements(els) } }, [rootElement]) /** * Set focused index */ const handleFocus = useCallback((index: number) => { setFocusedIndex(index) }, []) /** * Handle increment/decrement of focusedIndex */ const handleKeyDown = useCallback( (event: any) => { if (pause) { return } const focusPrev = () => { event.preventDefault() setFocusedIndex((prevIndex) => { const next = (prevIndex + lastFocusableIndex) % focusableLen if (!loop && next === lastFocusableIndex) { return prevIndex } return next }) } const focusNext = () => { event.preventDefault() setFocusedIndex((prevIndex) => { const next = (prevIndex + 1) % focusableLen if (!loop && next === 0) { return prevIndex } return next }) } if (event.key === 'Tab' && navigation.includes('tab')) { if (event.shiftKey) { focusPrev() } else { focusNext() } } if (navigation.includes('arrows')) { if (event.key === prevKey) { focusPrev() } if (event.key === nextKey) { focusNext() } } }, [pause, prevKey, navigation, nextKey, lastFocusableIndex, focusableLen, loop], ) /** * Set focusable elements on mount */ useEffect(() => { handleSetElements() }, [handleSetElements, initialFocus, direction]) /** * Listen to DOM mutations to update focusableElements with latest state */ useEffect(() => { const mo = new MutationObserver(handleSetElements) if (rootElement) { mo.observe(rootElement, { childList: true, subtree: true, attributeFilter: MUTATION_ATTRIBUTE_FILTER, }) } return () => { mo.disconnect() } }, [focusableElements, handleSetElements, rootElement]) /** * Set focus on elements in focusableElements depending on focusedIndex */ useEffect(() => { focusableElements.forEach((el, index) => { if (index === focusedIndex) { el.setAttribute('tabIndex', '0') el.setAttribute('aria-selected', 'true') el.focus() el.onfocus = () => handleFocus(index) el.onblur = () => handleFocus(-1) } else { el.setAttribute('tabIndex', '-1') el.setAttribute('aria-selected', 'false') el.onfocus = () => handleFocus(index) } }) if (focusedIndex === -1 && focusableElements) { const initialIndex = initialFocus === 'last' ? lastFocusableIndex : 0 focusableElements[initialIndex]?.setAttribute('tabIndex', '0') } }, [focusableElements, focusedIndex, handleFocus, initialFocus, lastFocusableIndex]) /** * Listen to key down events on rootElement */ useEffect(() => { rootElement?.addEventListener('keydown', handleKeyDown) return () => { rootElement?.removeEventListener('keydown', handleKeyDown) } }, [handleKeyDown, rootElement]) return undefined }