import { unwrapElement, useMutationObserver } from '@basmilius/common'; import { type ComponentPublicInstance, type Ref, watch } from 'vue'; import { getBidirectionalFocusElement, getFocusableElement, getFocusableElements, isActiveElement } from '../util'; type EligibleElement = ComponentPublicInstance | HTMLElement; export default function (containerRef: Ref, {cycle = true, direction = 'bidirectional', ignore}: UseFocusZoneOptions = {}): void { useMutationObserver(containerRef, () => updateFocus(findInitialIndex(), false), {childList: true, subtree: true}); function findInitialIndex(): number { const container = unwrapElement(containerRef); const elements = getFocusableElements(container, ignore); const isActiveIndex = elements.findIndex(elm => isActiveElement(elm) && !elm.hasAttribute('aria-disabled')); const notDisabledIndex = elements.findIndex(elm => !elm.hasAttribute('aria-disabled')); if (isActiveIndex > -1) { return isActiveIndex; } if (notDisabledIndex > -1) { return notDisabledIndex; } return 0; } function updateFocus(elementIndex: number, doFocus: boolean = true): void { const container = unwrapElement(containerRef)!; const elements = getFocusableElements(container, ignore); elements.forEach((elm, index) => elm.tabIndex = index === elementIndex ? 0 : -1); doFocus && elements[elementIndex]?.focus(); } function onKeyDown(evt: KeyboardEvent): void { const container = unwrapElement(containerRef)!; const elements = getFocusableElements(container, ignore); if (['Enter', ' '].includes(evt.key)) { return; } switch (direction) { case 'bidirectional': handleBidirectionalFocus(evt, container, elements, updateFocus, ignore); break; case 'horizontal': case 'vertical': handleDirectionalFocus(evt, container, cycle, direction, elements, updateFocus, ignore); break; } } watch(containerRef, (_, __, onCleanup) => { const container = unwrapElement(containerRef); if (!container) { return; } container.addEventListener('keydown', onKeyDown); updateFocus(findInitialIndex(), false); onCleanup(() => container.removeEventListener('keydown', onKeyDown)); }, {immediate: true}); } function handleBidirectionalFocus(evt: KeyboardEvent, container: HTMLElement, elements: HTMLElement[], updateFocus: (index: number) => void, ignore?: string): void { let dir: 'up' | 'down' | 'left' | 'right'; switch (evt.key) { case 'ArrowUp': dir = 'up'; break; case 'ArrowDown': dir = 'down'; break; case 'ArrowLeft': dir = 'left'; break; case 'ArrowRight': dir = 'right'; break; default: return; } const element = getBidirectionalFocusElement(container, document.activeElement as HTMLElement, dir, ignore); if (element) { updateFocus(elements.indexOf(element)); } evt.preventDefault(); } function handleDirectionalFocus(evt: KeyboardEvent, container: HTMLElement, cycle: boolean, direction: 'horizontal' | 'vertical', elements: HTMLElement[], updateFocus: (index: number) => void, ignore?: string): void { let dir: number; if (evt.key === (direction === 'horizontal' ? 'ArrowLeft' : 'ArrowUp')) { dir = -1; } else if (evt.key === (direction === 'horizontal' ? 'ArrowRight' : 'ArrowDown')) { dir = 1; } else { return; } const element = getFocusableElement(container, dir, undefined, ignore); if (element) { updateFocus(elements.indexOf(element)); } else if (cycle) { updateFocus(dir === 1 ? 0 : elements.length - 1); } evt.preventDefault(); } type UseFocusZoneOptions = { readonly cycle?: boolean; readonly direction?: 'bidirectional' | 'horizontal' | 'vertical'; readonly ignore?: string; };