"use client"; import * as SelectPrimitive from "@radix-ui/react-select"; import { Check, ChevronDown } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, } from "react"; import pillStyles from "@prototype/components/prototypes/prototype-floating-pill.module.scss"; import { MiniPillLabel } from "@prototype/components/prototypes/mini-pill-label"; import { usePrototypeReviewOptional } from "@prototype/lib/prototypes/prototype-review-context"; import { usePrototypeToolTheme } from "@prototype/lib/prototypes/use-prototype-tool-theme"; import { DropdownMenu, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, } from "@prototype/components/ui/dropdown-menu"; import { cn } from "@prototype/lib/utils"; import { ControlsPanelOptionSeparator } from "./controls-panel-options"; export type ControlsPanelSelectOption = { value: T; label: string; }; export type ControlsPanelSelectSeparator = { type: "separator"; }; export type ControlsPanelSelectSubmenuOption = { value: string; label: string; }; export type ControlsPanelSelectSubmenu = { type: "submenu"; value: T; label: string; options: ControlsPanelSelectSubmenuOption[]; }; export type ControlsPanelSelectEntry = | ControlsPanelSelectOption | ControlsPanelSelectSeparator | ControlsPanelSelectSubmenu; function isControlsPanelSelectSeparator( entry: ControlsPanelSelectEntry, ): entry is ControlsPanelSelectSeparator { return "type" in entry && entry.type === "separator"; } function isControlsPanelSelectSubmenu( entry: ControlsPanelSelectEntry, ): entry is ControlsPanelSelectSubmenu { return "type" in entry && entry.type === "submenu"; } function getSelectableOptions( entries: ControlsPanelSelectEntry[], ): ControlsPanelSelectOption[] { return entries.flatMap((entry) => { if (isControlsPanelSelectSeparator(entry)) return []; if (isControlsPanelSelectSubmenu(entry)) { return [{ value: entry.value, label: entry.label }]; } return [entry]; }); } function resolveActivePreviewStateId( value: T, submenuValue: string | undefined, options: ControlsPanelSelectEntry[], ): string { const submenuEntry = options.find( (entry) => isControlsPanelSelectSubmenu(entry) && entry.value === value, ); if ( submenuEntry && isControlsPanelSelectSubmenu(submenuEntry) && submenuValue ) { return submenuValue; } return value; } type ControlsPanelSelectProps = { value: T; onValueChange: (value: T) => void; options: ControlsPanelSelectEntry[]; className?: string; /** Renders as a half-height pill for the review chrome control row. */ appearance?: "panel" | "miniPill" | "menuList"; /** Overrides the mini pill trigger label (dropdown options are unchanged). */ miniPillLabel?: string; /** Active nested option when the current value matches a submenu entry. */ submenuValue?: string; onSubmenuValueChange?: (submenuValue: string) => void; }; /** * Marks the portaled select surface so the surrounding review controls menu * can keep itself open while the user interacts with this select. */ export const CONTROLS_PANEL_SELECT_CONTENT_ATTR = "data-controls-panel-select-content"; /** * Select for the review controls panel, built on Radix Select. The option * list is portaled to document.body and positioned over page content, so opening * it overlays rather than reflowing the surrounding menu. */ export function ControlsPanelSelect({ value, onValueChange, options, className, appearance = "panel", miniPillLabel, submenuValue, onSubmenuValueChange, }: ControlsPanelSelectProps) { const selectableOptions = getSelectableOptions(options); const activeLabel = selectableOptions.find((option) => option.value === value)?.label ?? "Select"; const isMiniPill = appearance === "miniPill"; const isMenuList = appearance === "menuList"; const review = usePrototypeReviewOptional(); const registerPreviewStates = review?.registerPreviewStates; const setActivePreviewStateId = review?.setActivePreviewStateId; const { commentTheme } = usePrototypeToolTheme(); const triggerLabel = isMiniPill ? (miniPillLabel ?? activeLabel) : activeLabel; const [open, setOpen] = useState(false); const triggerRef = useRef(null); useEffect(() => { if (!isMenuList || !registerPreviewStates) return; registerPreviewStates( getSelectableOptions(options).map((option) => ({ id: option.value, label: option.label, })), ); }, [isMenuList, options, registerPreviewStates]); useEffect(() => { if (!isMenuList || !setActivePreviewStateId) return; setActivePreviewStateId( resolveActivePreviewStateId(value, submenuValue, options), ); }, [isMenuList, options, setActivePreviewStateId, submenuValue, value]); useEffect(() => { if (!isMenuList || !setActivePreviewStateId) return; return () => setActivePreviewStateId(null); }, [isMenuList, setActivePreviewStateId]); const selectSubmenuEntry = useCallback( ( entry: ControlsPanelSelectSubmenu, submenuOption: ControlsPanelSelectSubmenuOption, ) => { onValueChange(entry.value); onSubmenuValueChange?.(submenuOption.value); }, [onSubmenuValueChange, onValueChange], ); const handleValueChange = useCallback( (next: T) => { onValueChange(next); const submenuEntry = options.find( (entry) => isControlsPanelSelectSubmenu(entry) && entry.value === next, ); if (submenuEntry && isControlsPanelSelectSubmenu(submenuEntry)) { const firstOption = submenuEntry.options[0]; if (firstOption) { onSubmenuValueChange?.(firstOption.value); } } }, [onSubmenuValueChange, onValueChange, options], ); const closeSelect = useCallback(() => { setOpen(false); requestAnimationFrame(() => triggerRef.current?.blur()); }, []); useEffect(() => { if (!open) return; const handlePointerDown = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Element)) return; if (triggerRef.current?.contains(target)) return; if (target.closest(`[${CONTROLS_PANEL_SELECT_CONTENT_ATTR}]`)) return; closeSelect(); }; document.addEventListener("pointerdown", handlePointerDown, true); return () => document.removeEventListener("pointerdown", handlePointerDown, true); }, [closeSelect, open]); const handleOpenChange = (next: boolean) => { setOpen(next); if (!next) { requestAnimationFrame(() => triggerRef.current?.blur()); } }; const handleTriggerPointerDown = (event: ReactPointerEvent) => { event.stopPropagation(); }; if (isMenuList) { return (
{options.map((entry, index) => { if (isControlsPanelSelectSeparator(entry)) { return ( ); } if (isControlsPanelSelectSubmenu(entry)) { const parentSelected = entry.value === value; const firstOption = entry.options[0]; return ( { if (parentSelected) { // Hover opens the sub-layout; suppress click-to-open submenu. event.preventDefault(); return; } if (!firstOption) return; event.preventDefault(); selectSubmenuEntry(entry, firstOption); requestAnimationFrame(() => { event.currentTarget.ownerDocument.dispatchEvent( new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true, }), ); }); }} > {entry.label} {entry.options.map((subOption) => { const selected = parentSelected && submenuValue === subOption.value; return ( selectSubmenuEntry(entry, subOption)} > {subOption.label} ); })} ); } const selected = entry.value === value; return ( handleValueChange(entry.value)} > {entry.label} ); })}
); } return ( handleValueChange(next as T)} > {isMiniPill ? ( ) : (
{activeLabel}
)} event.preventDefault()} className={cn( pillStyles.dropdownContent, "border-0 bg-transparent p-0 text-foreground ring-0", )} style={{ // Above review chrome toolbar (prototype-review-chrome.module.scss z-index: 1050). zIndex: 1051, minWidth: "max(var(--radix-select-trigger-width), 12rem)", }} > {options.map((entry, index) => { if (isControlsPanelSelectSeparator(entry)) { return ( ); } if (isControlsPanelSelectSubmenu(entry)) { return ( {entry.label} ); } return ( {entry.label} ); })}
); }