import { useEffect, useRef, type ButtonHTMLAttributes, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type RefObject, } from 'react'; interface MenuProps { open: boolean; /** Доступное имя меню (озвучивается скринридером). */ label: string; onClose: () => void; children: ReactNode; /** * Элемент-якорь: клик по нему не считается кликом «вне меню» (иначе меню * закрывалось бы и тут же открывалось заново), ему же возвращается фокус. */ anchorRef?: RefObject; className?: string; } const ITEM_SELECTOR = '[role="menuitem"]:not([disabled]),[role="menuitemradio"]:not([disabled]),[role="menuitemcheckbox"]:not([disabled])'; /** * Всплывающее меню (`role="menu"`): закрывается по Escape и клику вне, * открывается с фокусом на первом пункте, стрелки/Home/End перемещают фокус, * Tab закрывает меню и возвращает управление странице. * * Позиционирование задаёт родитель: панель абсолютная и растёт вверх * (`.gl-popover`), поэтому меню удобно вешать над нижней панелью пользователя. */ export function Menu({ open, label, onClose, children, anchorRef, className = '' }: MenuProps) { const panelRef = useRef(null); const onCloseRef = useRef(onClose); useEffect(() => { onCloseRef.current = onClose; }, [onClose]); // Фокус — на первом пункте: меню сразу доступно с клавиатуры. useEffect(() => { if (!open) { return; } const items = panelRef.current?.querySelectorAll(ITEM_SELECTOR); items?.[0]?.focus(); }, [open]); // Клик вне меню (и вне якоря) закрывает его. useEffect(() => { if (!open) { return; } const onPointerDown = (event: MouseEvent): void => { const target = event.target; if (!(target instanceof Node)) { return; } if (panelRef.current?.contains(target) === true) { return; } if (anchorRef?.current?.contains(target) === true) { return; } onCloseRef.current(); }; document.addEventListener('mousedown', onPointerDown); document.addEventListener('touchstart', onPointerDown); return () => { document.removeEventListener('mousedown', onPointerDown); document.removeEventListener('touchstart', onPointerDown); }; }, [open, anchorRef]); // Escape работает и когда фокус ушёл на якорь. useEffect(() => { if (!open) { return; } const onKeyDown = (event: KeyboardEvent): void => { if (event.key === 'Escape') { onCloseRef.current(); anchorRef?.current?.focus(); } }; document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('keydown', onKeyDown); }; }, [open, anchorRef]); const onKeyDown = (event: ReactKeyboardEvent): void => { if (event.key === 'Tab') { // Меню — не часть tab-порядка: уводим фокус дальше по странице. onClose(); return; } const items = Array.from(panelRef.current?.querySelectorAll(ITEM_SELECTOR) ?? []); if (items.length === 0) { return; } const current = items.findIndex((item) => item === document.activeElement); const move = (next: number): void => { event.preventDefault(); const target = items[(next + items.length) % items.length]; target?.focus(); }; switch (event.key) { case 'ArrowDown': move(current + 1); return; case 'ArrowUp': move(current <= 0 ? items.length - 1 : current - 1); return; case 'Home': move(0); return; case 'End': move(items.length - 1); return; default: return; } }; if (!open) { return null; } return (
{children}
); } interface MenuItemProps extends ButtonHTMLAttributes { /** Иконка слева (декоративная). */ icon?: ReactNode; /** Для `role="menuitemradio"`: выбран ли пункт (рисуется галочка). */ selected?: boolean; danger?: boolean; } /** Пункт меню: обычный (`menuitem`) или переключатель (`menuitemradio`). */ export function MenuItem({ icon, selected = false, danger = false, className = '', children, ...rest }: MenuItemProps) { const role = rest.role ?? 'menuitem'; return ( ); } /** Заголовок группы пунктов внутри меню. */ export function MenuGroupLabel({ children }: { children: ReactNode }) { return (

{children}

); } /** Разделитель между группами пунктов. */ export function MenuSeparator() { return
; }