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
text/typescript
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
}