import * as React from "react" import { useCascaderActions } from "@/components/reui/cascader/cascader-context" import { CASCADER_ACTION_CLASS, CascaderGroup, CascaderLabel, } from "@/components/reui/cascader/cascader-item" import { CASCADER_LIST_PAD_CLASS, getCascaderFooterStops, isCascaderRtl, } from "@/components/reui/cascader/cascader-lib" import type { CascaderActionItem } from "@/components/reui/cascader/cascader-types" import { Popover as PopoverPrimitive } from "@base-ui/react" import { useDirection } from "@base-ui/react/direction-provider" import { cn } from "@evobgp/ui/lib/utils" import { ChevronRightIcon } from "lucide-react" /** * The pinned footer, and the side-anchored flyout a footer row can open. These * are COMMANDS: nothing here joins the selection, the filter set or the * highlight. The flyout is a Base UI `Popover` rendered as a REACT CHILD of * `Combobox.Popup` with its OWN `Portal` and NO `container`: a nested portal * resolves to the parent portal node, so it is a DOM sibling of the combobox * popup (not clipped, not `aria-hidden`) but a React descendant, which is what * the outside-press and focus-out whitelists read. `Combobox` builds no * `FloatingTree`, so the flyout is not consulted first and one Escape would * dismiss both; hence `CascaderSubmenu` registering with the root to turn one * Escape into two. `Combobox.List` clicks its highlighted row on Enter, hence * the footer sitting outside `CascaderList`. And a `Positioner` throws without * its `Portal`, while `modal` stays `false` on the `Root` so the combobox * keeps its own dismissal behaviour. */ /* -------------------------------------------------------------------------- */ /* Footer */ /* -------------------------------------------------------------------------- */ /** * Keys the option list acts on, swallowed at the footer boundary. Escape and * Tab are absent on purpose: Escape must reach the root, Tab must keep moving. */ const FOOTER_SWALLOWED_KEYS = new Set([ "Enter", " ", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown", ]) export type CascaderFooterProps = React.ComponentProps<"div"> /** * Actions pinned below the list, a SIBLING of `CascaderList`. Children win * over the root's `actions` prop; with neither it renders nothing. */ function CascaderFooter({ className, children, onKeyDown, ...props }: CascaderFooterProps) { const { actions, labels } = useCascaderActions() const hasChildren = React.Children.count(children) > 0 const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { onKeyDown?.(event) if (event.defaultPrevented) return if (!FOOTER_SWALLOWED_KEYS.has(event.key)) return event.stopPropagation() // The strip's own vertical movement, and the way back from the list's // hand-off: either end returns focus to the search field, from which // Base UI's empty highlight resumes the list. Down wraps to the FIELD, // not a command (traps the arrows) or a row (no imperative highlight). if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return const footer = event.currentTarget const stops = getCascaderFooterStops(footer) const active = document.activeElement as HTMLElement | null const index = active ? stops.indexOf(active) : -1 if (index === -1) return event.preventDefault() const next = event.key === "ArrowDown" ? stops[index + 1] : stops[index - 1] if (next) { next.focus() return } if (event.key === "ArrowUp" && index > 0) return footer .closest('[data-slot="cascader-panel"]') ?.querySelector('[data-slot="cascader-input"]') ?.focus() }, [onKeyDown] ) if (!hasChildren && actions.length === 0) return null return (
{hasChildren ? children : }
) } function CascaderFooterActions({ actions }: { actions: CascaderActionItem[] }) { return ( <> {actions.map((action, i) => action.items?.length ? ( {action.label} ) : ( {action.label} ) )} ) } function actionKey(action: CascaderActionItem, index: number): string { if (action.value != null) return action.value if (typeof action.label === "string") return action.label return String(index) } /** * Consecutive entries sharing a `group`, as runs not buckets: two separated * runs with the same name stay two, so the author's order survives. */ function groupActionRuns( items: CascaderActionItem[] ): { group?: string; items: CascaderActionItem[] }[] { const runs: { group?: string; items: CascaderActionItem[] }[] = [] for (const item of items) { const last = runs[runs.length - 1] if (last && last.group === item.group) last.items.push(item) else runs.push({ group: item.group, items: [item] }) } return runs } /** * Flyout body for a data-driven submenu. A named run becomes a real * `CascaderGroup`; unnamed runs stay unwrapped, as an unnamed group is noise. */ function CascaderActionList({ items }: { items: CascaderActionItem[] }) { const { close } = useCascaderSubmenu() const runs = React.useMemo(() => groupActionRuns(items), [items]) const renderAction = (item: CascaderActionItem, i: number) => ( { item.onSelect?.() /* Closes behind the command, or the entries would read as toggles. */ close() }} > {item.label} ) return ( <> {runs.map((run, runIndex) => run.group ? ( {run.group} {run.items.map(renderAction)} ) : ( {run.items.map(renderAction)} ) )} ) } /* -------------------------------------------------------------------------- */ /* Action */ /* -------------------------------------------------------------------------- */ export interface CascaderActionProps extends Omit< React.ComponentProps<"button">, "onSelect" > { icon?: React.ReactNode /** Fires on press, after `onClick`, and not at all when disabled. */ onSelect?: () => void } /** * The cascader popup's panel per style, spelled with `style-:` variants * rather than ReUI theme CSS so an installed footer needs only Tailwind. */ const FLYOUT_SURFACE_CLASS = "bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-72 overflow-hidden ring-1 duration-100 ring-foreground/10 shadow-md rounded-lg" /** * One footer command, shaped like a row and deliberately NOT one. A real * ` ) } /* -------------------------------------------------------------------------- */ /* Submenu */ /* -------------------------------------------------------------------------- */ interface CascaderSubmenuContextValue { rowRef: React.RefObject open: boolean setOpen: (open: boolean) => void close: () => void /** Names the flyout: a menu is labelled by the control that opens it. */ triggerId: string /** * Whether the pending open came from the KEYBOARD. Base UI's own `openType` * calls the opening arrow a POINTER open, because the trigger intercepts it * and calls `setOpen` (measured: focus landed on the popup, not the first * entry). A ref, so reading it in the focus phase cannot render. */ keyboardRef: React.RefObject } /** Marks the subtree INSIDE a flyout. See `inMenu` in `CascaderAction`. */ const CascaderMenuContext = React.createContext(false) /** * Every entry a menu's roving focus may land on, in DOM order, read from the * DOM because the entries are whatever the consumer composed. A disabled * `CascaderAction` is INCLUDED: it carries `aria-disabled`, not the native * attribute, so `:not([disabled])` excludes only a consumer's own natively * disabled `menuitem`, which cannot take focus. */ function menuItems(popup: HTMLElement | null): HTMLElement[] { if (!popup) return [] return Array.from( popup.querySelectorAll('[role="menuitem"]:not([disabled])') ) } const CascaderSubmenuContext = React.createContext< CascaderSubmenuContextValue | undefined >(undefined) /** The flyout's own state. `close()` is the one a custom entry usually wants. */ export function useCascaderSubmenu(): CascaderSubmenuContextValue { const context = React.useContext(CascaderSubmenuContext) if (!context) { throw new Error("useCascaderSubmenu must be used within a CascaderSubmenu") } return context } export interface CascaderSubmenuProps { open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void children?: React.ReactNode } /** * A footer row plus the flyout it opens. Registers with the cascader root * while open, which turns one Escape into two. Cleared in an EFFECT, so the * flyout still reads as open during the event that closed it. */ function CascaderSubmenu({ open: openProp, defaultOpen = false, onOpenChange, children, }: CascaderSubmenuProps) { const { setFlyoutOpen } = useCascaderActions() const key = React.useId() const triggerId = React.useId() const rowRef = React.useRef(null) const keyboardRef = React.useRef(false) const [uncontrolled, setUncontrolled] = React.useState(defaultOpen) const open = openProp ?? uncontrolled const setOpen = React.useCallback( (next: boolean) => { if (openProp == null) setUncontrolled(next) onOpenChange?.(next) }, [openProp, onOpenChange] ) const close = React.useCallback(() => setOpen(false), [setOpen]) React.useEffect(() => { setFlyoutOpen(key, open) return () => setFlyoutOpen(key, false) }, [setFlyoutOpen, key, open]) const context = React.useMemo( () => ({ rowRef, open, setOpen, close, triggerId, keyboardRef }), [open, setOpen, close, triggerId] ) return ( {children} ) } export interface CascaderSubmenuTriggerProps extends Omit< React.ComponentProps<"button">, "onSelect" > { icon?: React.ReactNode } /** Carries the handler-veto hook, derived so a Base UI bump cannot drift. */ type CascaderSubmenuTriggerClickEvent = Parameters< NonNullable >[0] /** * The footer row that opens the flyout, and its anchor. `aria-haspopup="menu"` * rather than the `dialog` Base UI would announce: what opens is a list of * commands with roving focus. `disabled` is intercepted, not forwarded: * `Popover.Trigger` runs it through `useButton`, which writes the NATIVE * attribute for a native `