import clsx from "clsx"; import * as React from "react"; import { Link } from "react-router"; import { type FocusMove, focusMoveForKey, rovingFocusIndex, } from "~/utils/roving-focus"; import { Image } from "../Image"; import { useAnchorPositioning } from "./anchor-positioning"; import styles from "./Menu.module.css"; import { focusLeftTo, isOwnToggle, useAnchorSafeId, useShowPopoverOnOpen, } from "./Popover"; import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; type MenuPlacement = "bottom start" | "bottom end" | "bottom right"; interface SendouMenuProps { trigger: React.ReactElement>; scrolling?: boolean; opensLeft?: boolean; children: React.ReactNode; popoverClassName?: string; placement?: MenuPlacement; /** Render the items while closed too, so the menu works before hydration (and without JavaScript). */ eager?: boolean; } const MenuContext = React.createContext<{ close: () => void }>({ close: () => {}, }); export function SendouMenu({ children, trigger, opensLeft, scrolling, placement, popoverClassName, eager, }: SendouMenuProps) { const uid = useAnchorSafeId(); const popoverId = `${uid}-menu`; const anchorName = `--menu-anchor-${uid}`; const [open, setOpen] = React.useState(false); const popoverRef = React.useRef(null); const triggerContainerRef = React.useRef(null); // an eager menu can be open before hydration, its toggle event long gone React.useEffect(() => { if (popoverRef.current?.matches(":popover-open")) setOpen(true); }, []); const onBeforeToggle = useShowPopoverOnOpen({ popoverRef, open, onOpen: () => setOpen(true), }); useCloseOnScrollClip(open, popoverRef, () => popoverRef.current?.hidePopover(), ); useAnchorPositioning({ isOpen: open, popoverRef, getAnchor: () => triggerContainerRef.current?.firstElementChild ?? null, placement: opensLeft || (placement && placement !== "bottom start") ? "bottom end" : "bottom start", }); const onToggle = (event: React.ToggleEvent) => { if (!isOwnToggle(event)) return; const next = event.newState === "open"; if (next) { popoverRef.current?.focus(); } if (next !== open) { setOpen(next); } }; const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); popoverRef.current?.hidePopover(); return; } const move = focusMoveForKey(event.key); if (!move) return; event.preventDefault(); focusItem(popoverRef.current, move); }; const onBlur = (event: React.FocusEvent) => { if ( open && focusLeftTo(event, [triggerContainerRef.current, popoverRef.current]) ) { popoverRef.current?.hidePopover(); } }; return ( <> {/* biome-ignore lint/a11y/noStaticElementInteractions: only observes focus leaving the trigger */} {React.cloneElement(trigger, { popoverTarget: popoverId, "aria-expanded": open, "aria-haspopup": "menu", })} {/* biome-ignore lint/a11y/noStaticElementInteractions: keydown steers the menu items inside */}
{open || eager ? ( popoverRef.current?.hidePopover() }} > {children} ) : null}
); } function focusItem(popover: HTMLElement | null, move: FocusMove) { const elements = [ ...(popover?.querySelectorAll( '[role="menuitem"]:not([aria-disabled="true"])', ) ?? []), ]; if (elements.length === 0) return; const activeIndex = elements.indexOf(document.activeElement as HTMLElement); elements[ rovingFocusIndex(move, activeIndex, elements.length, { wrap: true }) ].focus(); } export interface SendouMenuItemProps { id?: string; children: React.ReactNode; onAction?: () => void; href?: string; target?: string; rel?: string; icon?: React.ReactNode; imagePath?: string; isActive?: boolean; isDestructive?: boolean; isDisabled?: boolean; "data-testid"?: string; } export function SendouMenuSection({ children, headerText, headerClassName, }: { children: React.ReactNode; headerText?: React.ReactNode; headerClassName?: string; }) { return ( // biome-ignore lint/a11y/useSemanticElements: a fieldset would carry form semantics this menu group does not have
{headerText ? (
{headerText}
) : null} {children}
); } export function SendouMenuItem(props: SendouMenuItemProps) { const menu = React.use(MenuContext); const className = clsx(styles.item, { [styles.itemDisabled]: props.isDisabled, [styles.itemActive]: props.isActive, [styles.itemDestructive]: props.isDestructive, }); const content = ( <> {props.icon ? ( {props.icon} ) : null} {props.imagePath ? ( ) : null} {props.children} ); const act = (event: React.MouseEvent) => { if (props.isDisabled) { event.preventDefault(); return; } menu.close(); props.onAction?.(); }; if (props.href) { const linkProps = { id: props.id, role: "menuitem", tabIndex: -1, className, target: props.target, rel: props.rel, "aria-disabled": props.isDisabled || undefined, "data-testid": props["data-testid"], onClick: act, }; return props.href.startsWith("/") ? ( {content} ) : ( {content} ); } return ( ); }