{"version":3,"file":"DropdownMenu.cjs","names":[],"sources":["../../../src/components/DropdownMenu/DropdownMenu.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, function-lines — the body owns placement, outside-\n * click, and the whole APG menu-button keyboard model, which has to see the entire\n * entry list to know where the next stop is. Splitting it would move the focus\n * bookkeeping away from the list it indexes into.\n */\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, ReactElement, ReactNode } from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport styles from \"./DropdownMenu.module.css\";\n\nexport type DropdownMenuPlacement = \"bottom-start\" | \"bottom-end\" | \"top-start\" | \"top-end\";\n\nexport type DropdownMenuEntry =\n    | {\n          type: \"item\";\n          id: string;\n          label: ReactNode;\n          icon?: ReactNode;\n          danger?: boolean;\n          disabled?: boolean;\n          onSelect: () => void;\n      }\n    | {\n          /**\n           * An entry that carries on/off state.\n           *\n           * Rendered as `role=\"menuitemcheckbox\"` with `aria-checked`, which is how\n           * a screen reader announces \"checked\" instead of leaving the state\n           * invisible. Selecting it does **not** close the menu: toggling two\n           * settings in a row is the ordinary case, and closing after the first\n           * would make the second a second trip.\n           */\n          type: \"checkbox\";\n          id: string;\n          label: ReactNode;\n          icon?: ReactNode;\n          checked: boolean;\n          disabled?: boolean;\n          onSelect: () => void;\n      }\n    | { type: \"separator\"; id: string }\n    | { type: \"label\"; id: string; label: ReactNode };\n\n/** The entry kinds a user can move focus to and activate. */\ntype SelectableEntry = Extract<DropdownMenuEntry, { type: \"item\" | \"checkbox\" }>;\n\nexport interface DropdownMenuProps {\n    trigger: ReactElement<{\n        onClick?: (e: React.MouseEvent) => void;\n        onKeyDown?: (e: ReactKeyboardEvent) => void;\n        \"aria-expanded\"?: boolean;\n        \"aria-controls\"?: string;\n        \"aria-haspopup\"?: boolean | \"menu\";\n    }>;\n    items: DropdownMenuEntry[];\n    placement?: DropdownMenuPlacement;\n    className?: string;\n}\n\nfunction placementClass(placement: DropdownMenuPlacement): string {\n    switch (placement) {\n        case \"bottom-end\":\n            return styles.bottomEnd;\n        case \"top-start\":\n            return styles.topStart;\n        case \"top-end\":\n            return styles.topEnd;\n        case \"bottom-start\":\n        default:\n            return styles.bottomStart;\n    }\n}\n\n/** Whether an entry can take focus — an item or checkbox that is not disabled. */\nfunction isSelectable(entry: DropdownMenuEntry): entry is SelectableEntry {\n    return (entry.type === \"item\" || entry.type === \"checkbox\") && !entry.disabled;\n}\n\n/**\n * Dropdown menu — a list of actions anchored to a trigger, following the\n * [APG menu button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/).\n *\n * `role=\"menu\"` is a promise about the keyboard, and this component keeps it:\n *\n * - `Enter`, `Space` or `ArrowDown` on the trigger opens and focuses the first\n *   entry; `ArrowUp` opens and focuses the last.\n * - `ArrowUp` / `ArrowDown` move with wrap, `Home` / `End` jump to the ends.\n * - `Escape` closes and returns focus to the trigger, so the next `Tab` continues\n *   from where the user was rather than from the top of the document.\n * - `Tab` closes the menu and lets the page's own tab order take over.\n * - Focus is managed: entries carry `tabIndex={-1}` and only the active one is\n *   `0`, which is what stops `Tab` from walking the menu one entry at a time.\n *\n * Disabled entries, separators and labels are skipped by every movement.\n */\nexport function DropdownMenu({\n    trigger,\n    items,\n    placement = \"bottom-start\",\n    className,\n}: DropdownMenuProps) {\n    const [open, setOpen] = useState(false);\n    const [activeIndex, setActiveIndex] = useState<number>(-1);\n    const id = useId();\n    const rootRef = useRef<HTMLSpanElement>(null);\n    const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);\n    const pendingFocus = useRef<\"first\" | \"last\" | null>(null);\n\n    const selectable = items\n        .map((entry, index) => (isSelectable(entry) ? index : -1))\n        .filter((index) => index !== -1);\n\n    /**\n     * Focus the trigger again.\n     *\n     * Found through the `aria-haspopup` this component puts on it, rather than\n     * through a ref: `ref` lives in different places in React 18 and 19, and a\n     * consumer's custom trigger is under no obligation to forward one. The\n     * attribute is on the element either way.\n     *\n     * @returns Nothing.\n     */\n    const focusTrigger = useCallback((): void => {\n        rootRef.current?.querySelector<HTMLElement>(\"[aria-haspopup='menu']\")?.focus();\n    }, []);\n\n    const close = useCallback(\n        (restoreFocus: boolean): void => {\n            setOpen(false);\n            setActiveIndex(-1);\n            if (restoreFocus) focusTrigger();\n        },\n        [focusTrigger],\n    );\n\n    const focusIndex = useCallback((index: number): void => {\n        setActiveIndex(index);\n        itemRefs.current[index]?.focus();\n    }, []);\n\n    /**\n     * Move focus to the entry `step` positions away, wrapping at both ends.\n     *\n     * Walks the selectable positions rather than the raw list, so separators,\n     * labels and disabled entries are never a stop.\n     */\n    const move = (step: number): void => {\n        if (selectable.length === 0) return;\n        const current = selectable.indexOf(activeIndex);\n        const next = (current + step + selectable.length) % selectable.length;\n        focusIndex(selectable[next] ?? -1);\n    };\n\n    useEffect(() => {\n        if (!open || pendingFocus.current === null) return;\n        const target = pendingFocus.current === \"last\" ? selectable.at(-1) : selectable[0];\n        pendingFocus.current = null;\n        if (target !== undefined) focusIndex(target);\n    }, [open, selectable, focusIndex]);\n\n    useEffect(() => {\n        if (!open) return;\n        const onDown = (event: MouseEvent): void => {\n            if (rootRef.current && !rootRef.current.contains(event.target as Node)) close(false);\n        };\n        window.addEventListener(\"mousedown\", onDown);\n        return () => window.removeEventListener(\"mousedown\", onDown);\n    }, [open, close]);\n\n    const openWith = (edge: \"first\" | \"last\"): void => {\n        pendingFocus.current = edge;\n        setOpen(true);\n    };\n\n    const handleTriggerKeyDown = (event: ReactKeyboardEvent): void => {\n        trigger.props.onKeyDown?.(event);\n        if (event.defaultPrevented) return;\n        if (event.key === \"ArrowDown\" || (!open && (event.key === \"Enter\" || event.key === \" \"))) {\n            event.preventDefault();\n            openWith(\"first\");\n            return;\n        }\n        if (event.key === \"ArrowUp\") {\n            event.preventDefault();\n            openWith(\"last\");\n        }\n    };\n\n    /**\n     * Keyboard model of the open menu.\n     *\n     * Handled on the list rather than on `window`: focus is inside the menu by\n     * then, so the keys arrive here by bubbling and cannot be pre-empted by an\n     * unrelated global listener — which is how the arrows used to go missing in a\n     * host application.\n     */\n    const handleMenuKeyDown = (event: ReactKeyboardEvent<HTMLUListElement>): void => {\n        switch (event.key) {\n            case \"ArrowDown\":\n                event.preventDefault();\n                move(1);\n                break;\n            case \"ArrowUp\":\n                event.preventDefault();\n                move(-1);\n                break;\n            case \"Home\":\n                event.preventDefault();\n                if (selectable[0] !== undefined) focusIndex(selectable[0]);\n                break;\n            case \"End\": {\n                event.preventDefault();\n                const last = selectable.at(-1);\n                if (last !== undefined) focusIndex(last);\n                break;\n            }\n            case \"Escape\":\n                event.preventDefault();\n                close(true);\n                break;\n            case \"Tab\":\n                close(false);\n                break;\n            default:\n                break;\n        }\n    };\n\n    const handleTriggerClick = (event: React.MouseEvent): void => {\n        trigger.props.onClick?.(event);\n        if (event.defaultPrevented) return;\n        if (open) {\n            close(false);\n            return;\n        }\n        openWith(\"first\");\n    };\n\n    const triggerClone = {\n        ...trigger,\n        props: {\n            ...trigger.props,\n            onClick: handleTriggerClick,\n            onKeyDown: handleTriggerKeyDown,\n            \"aria-expanded\": open,\n            \"aria-controls\": id,\n            \"aria-haspopup\": \"menu\" as const,\n        },\n    } as ReactElement;\n\n    const handleSelect = (entry: SelectableEntry): void => {\n        entry.onSelect();\n        if (entry.type === \"checkbox\") return;\n        close(true);\n    };\n\n    return (\n        <span ref={rootRef} className={styles.root}>\n            {triggerClone}\n            {open && (\n                <ul\n                    id={id}\n                    role=\"menu\"\n                    className={cn(styles.menu, placementClass(placement), className)}\n                    onKeyDown={handleMenuKeyDown}\n                >\n                    {items.map((entry, index) => {\n                        if (entry.type === \"separator\") {\n                            return (\n                                <li\n                                    key={entry.id}\n                                    role=\"separator\"\n                                    className={styles.separator}\n                                    aria-hidden\n                                />\n                            );\n                        }\n                        if (entry.type === \"label\") {\n                            return (\n                                <li key={entry.id} role=\"presentation\" className={styles.label}>\n                                    {entry.label}\n                                </li>\n                            );\n                        }\n                        const checkbox = entry.type === \"checkbox\";\n                        return (\n                            <li key={entry.id} role=\"none\">\n                                <button\n                                    ref={(el) => {\n                                        itemRefs.current[index] = el;\n                                    }}\n                                    type=\"button\"\n                                    role={checkbox ? \"menuitemcheckbox\" : \"menuitem\"}\n                                    aria-checked={checkbox ? entry.checked : undefined}\n                                    tabIndex={activeIndex === index ? 0 : -1}\n                                    className={cn(\n                                        styles.item,\n                                        entry.type === \"item\" && entry.danger && styles.danger,\n                                        activeIndex === index && styles.active,\n                                    )}\n                                    disabled={entry.disabled}\n                                    onClick={() => handleSelect(entry)}\n                                    onMouseEnter={() => setActiveIndex(index)}\n                                >\n                                    {entry.icon && <span aria-hidden>{entry.icon}</span>}\n                                    {entry.label}\n                                </button>\n                            </li>\n                        );\n                    })}\n                </ul>\n            )}\n        </span>\n    );\n}\n"],"mappings":"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"}