alouette
Version:
A modern, customizable design system built on top of NativeWind v5 with configurable defaults
91 lines (83 loc) • 3.14 kB
text/typescript
import { useEffect } from "react";
import type { UseMenuKeyboardParams } from "./useMenuKeyboard";
const enabledItemSelector = '[role="menuitem"]:not([aria-disabled="true"])';
/**
* Roving focus over the open menu's items: the first one takes focus when the
* menu opens, the arrow keys and Home/End move between them, the pointer moves
* it as it goes over a row, and the trigger takes focus back on close. Escape
* and outside presses are `Popover`'s.
*
* The listener goes on the menu element itself rather than through props: a
* react-native-web `View` forwards no `onKeyDown`, and the node is already there
* to anchor the roving focus.
*/
export function useMenuKeyboard({
menuNode,
triggerRef,
}: UseMenuKeyboardParams): void {
useEffect(() => {
const menu = menuNode as HTMLElement | null;
if (!menu) return undefined;
// The trigger outlives the panel, so the element to hand the focus back to
// is the one that was there when the menu opened.
const trigger = triggerRef.current as HTMLElement | null;
const readItems = (): HTMLElement[] => [
...menu.querySelectorAll<HTMLElement>(enabledItemSelector),
];
const focusItem = (index: number): void => {
const items = readItems();
if (items.length === 0) return;
items[(index + items.length) % items.length]?.focus();
};
focusItem(0);
const onKeyDown = (event: KeyboardEvent): void => {
const items = readItems();
const current = items.indexOf(document.activeElement as HTMLElement);
switch (event.key) {
case "ArrowDown":
event.preventDefault();
focusItem(current + 1);
break;
case "ArrowUp":
event.preventDefault();
focusItem(current - 1);
break;
case "Home":
event.preventDefault();
focusItem(0);
break;
case "End":
event.preventDefault();
focusItem(items.length - 1);
break;
default:
break;
}
};
// The pointer moves the roving focus too: the focused row is painted, so a
// hovered item would otherwise light up next to the one holding the focus.
const onMouseOver = (event: MouseEvent): void => {
const item = (event.target as HTMLElement | null)?.closest<HTMLElement>(
enabledItemSelector,
);
if (item && item !== document.activeElement) item.focus();
};
menu.addEventListener("keydown", onKeyDown);
menu.addEventListener("mouseover", onMouseOver);
return () => {
menu.removeEventListener("keydown", onKeyDown);
menu.removeEventListener("mouseover", onMouseOver);
// Only when the menu still holds the focus (or already lost it to the
// body as its panel went away): a press that lands on another control
// closes the menu too, and that control keeps what it was given.
const active = document.activeElement;
if (
active === null ||
active === document.body ||
menu.contains(active)
) {
trigger?.focus();
}
};
}, [menuNode, triggerRef]);
}