import type { ButtonHTMLAttributes, DetailedHTMLProps, Dispatch, KeyboardEvent, MouseEvent, RefObject, SetStateAction, } from 'react'; import { createRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; type TriggerProps = { ref: RefObject; } & Pick< DetailedHTMLProps, HTMLButtonElement>, 'onKeyDown' | 'onClick' | 'tabIndex' | 'role' | 'aria-haspopup' | 'aria-expanded' >; type Response = Readonly<{ /** * An object used as a property of an HTML element that controls * the activation and deactivation of ListBox. */ triggerProps: TriggerProps; /** * An array of objects used as properties of HTML elements * that function as menu items in ListBox. */ itemProps: { /** * This function controls the behavior of the list box menu * when a key is pressed while the menu item is focused. */ onKeyDown: (e: KeyboardEvent) => void; /** * Set to `-1` to disable the browser's native focus logic. */ tabIndex: -1; /** * Set `menuitem` to comply with WAI-ARIA guidelines. */ role: 'menuitem'; /** * RefObject to be applied to each menu item, used to control the focus handling. */ ref: RefObject; }[]; /** * A Boolean value indicating whether or not ListBox is active. * The application developer uses this value to set whether or not to display the menu. */ active: boolean; /** * It is used by application developers to control the activation * and deactivation of menus in their programs. */ setActive: Dispatch>; }>; export function useListBox(itemCount: number): Response { const [active, setActive] = useState(false); const [focusIndex, setFocusIndex] = useState(0); const triggerRef = useRef(null); const itemRefs = useMemo(() => [...Array(itemCount).keys()].map(() => createRef()), [itemCount]); /** Determine if it is a keyboard event. */ const isKeyboardEvent = (e: KeyboardEvent | MouseEvent): e is KeyboardEvent => !!(e as KeyboardEvent).key; /** Move the focus of a menu item. */ const moveFocus = useCallback( (itemIndex: number) => { setFocusIndex(itemIndex); itemRefs[itemIndex].current?.focus(); }, [itemRefs], ); const handleTrigger = useCallback( (e: KeyboardEvent | MouseEvent) => { if (isKeyboardEvent(e)) { if (![KeyMaps.ArrowDown, KeyMaps.Escape, KeyMaps.Enter, KeyMaps.Space, KeyMaps.Tab].includes(e.key)) return; if ([KeyMaps.ArrowDown, KeyMaps.Tab].includes(e.key) && active) { e.preventDefault(); moveFocus(0); } if ([KeyMaps.Enter, KeyMaps.Space].includes(e.key)) { e.preventDefault(); setActive(true); } if (e.key === KeyMaps.Escape) { e.preventDefault(); setActive(false); } return; } setActive(active => !active); }, [active, moveFocus], ); /** * Define the process to be executed in response to * the keyboard event fired by the menu item. */ const handleItemKeyDown = useCallback( (e: KeyboardEvent) => { if (Object.values(KeyMaps).includes(e.key)) { switch (e.key) { case KeyMaps.Escape: setActive(false); triggerRef.current?.focus(); break; case KeyMaps.Tab: setActive(false); break; case KeyMaps.Enter: if (!['BUTTON', 'INPUT', 'A'].includes(e.currentTarget.nodeName)) { e.currentTarget.click(); } setActive(false); break; case KeyMaps.Space: e.currentTarget.click(); setActive(false); break; } const newFocusIndex = (() => { switch (e.key) { case KeyMaps.ArrowUp: return focusIndex + (focusIndex > 0 ? -1 : itemRefs.length - 1); case KeyMaps.ArrowDown: return focusIndex + (focusIndex < itemRefs.length - 1 ? 1 : (itemRefs.length - 1) * -1); default: return focusIndex; } })(); moveFocus(newFocusIndex); return; } // Moves the focus to the menu item whose label starts // with the letter of the key you entered. if (/[a-zA-Z0-9./<>?;:"'`!@#$%^&*()\\[\]{}_+=|\\-~,]/.test(e.key)) { const index = itemRefs.findIndex(ref => { const key = e.key.toLowerCase(); return ( ref.current?.innerText.toLowerCase().startsWith(key) || ref.current?.textContent?.toLowerCase().startsWith(key) || ref.current?.getAttribute('aria-label')?.toLowerCase().startsWith(key) ); }); if (index > -1) { moveFocus(index); } } }, [focusIndex, itemRefs, moveFocus], ); // When the menu opens, focus in on the first item. useEffect(() => { if (active) { moveFocus(0); } }, [moveFocus, active]); // Listens for all click events and forces the click target // to close if it is outside the menu area. useEffect(() => { if (!active) return; const handleEveryClick = (e: globalThis.MouseEvent) => { if ( !(e.target instanceof Element) || e.target.closest('[role="menu"]') instanceof Element || e.target.closest('[aria-haspopup="true"][aria-expanded="true"]') === triggerRef.current ) return; setActive(active => !active); }; document.addEventListener('click', handleEveryClick); return () => { document.removeEventListener('click', handleEveryClick); }; }, [active]); // Suppresses page scrolling using the arrow keys while the menu is displayed. useEffect(() => { const handleDisableArrowScroll = (e: globalThis.KeyboardEvent) => { if (active && [KeyMaps.ArrowDown, KeyMaps.ArrowUp].includes(e.key)) { e.preventDefault(); } }; document.addEventListener('keydown', handleDisableArrowScroll); return () => { document.removeEventListener('keydown', handleDisableArrowScroll); }; }, [active]); return { active, setActive, triggerProps: { onClick: handleTrigger, onKeyDown: handleTrigger, tabIndex: 0, ref: triggerRef, role: 'button', 'aria-haspopup': true, 'aria-expanded': active, }, itemProps: [...Array(itemCount).keys()].map(index => ({ onKeyDown: handleItemKeyDown, tabIndex: -1, role: 'menuitem', ref: itemRefs[index], })), }; } const KeyMaps = { Tab: 'Tab', Shift: 'Shift', Enter: 'Enter', Escape: 'Escape', ArrowUp: 'ArrowUp', ArrowDown: 'ArrowDown', Space: ' ', };