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 ;
}