"use client"; import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { Checkbox, Field, Kbd, menuParts } from "@unlocalhosted/metalui"; import { PlusIcon, SearchIcon } from "@/components/icons"; export type PickerOption = { value: string; label: string; icon?: ReactNode }; type Common = { options: PickerOption[]; /** Search field placeholder, e.g. "Change status…" */ placeholder: string; /** Accessible name of the trigger button. */ label: string; /** Trigger content. */ children: ReactNode; triggerClassName?: string; disabled?: boolean; align?: "left" | "right"; /** Controlled open state, for opening from keyboard shortcuts. */ open?: boolean; onOpenChange?: (open: boolean) => void; /** Offer to create an option named after the search text. */ onCreate?: (name: string) => void; createLabel?: (name: string) => string; }; type Single = Common & { multiple?: false; value: string; onSelect: (value: string) => void }; type Multiple = Common & { multiple: true; value: string[]; onSelect: (value: string[]) => void }; /** * A Linear-style property menu on MetalUI's menu plate: a trigger that opens a compact search well * over menu rows. Arrow keys move the row highlight, Enter picks, and 1–9 pick directly while the * search is empty. A single choice shows the green LED (like Select); several show row checkboxes. */ export function Picker(props: Single | Multiple) { const { options, placeholder, label, children, triggerClassName = "", disabled, align = "left", onCreate } = props; const [internalOpen, setInternalOpen] = useState(false); const open = props.open ?? internalOpen; const [query, setQuery] = useState(""); const [active, setActive] = useState(0); // Inside a modal dialog the rest of the page is inert, so the panel has to live in the dialog. const [container, setContainer] = useState(null); const triggerRef = useRef(null); const panelRef = useRef(null); const listId = useId(); const { onOpenChange } = props; const setOpen = useCallback( (next: boolean) => { setInternalOpen(next); onOpenChange?.(next); if (!next) { setQuery(""); setActive(0); } }, [onOpenChange], ); const q = query.trim().toLowerCase(); const filtered = q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options; const canCreate = !!onCreate && !!q && !options.some((o) => o.label.toLowerCase() === q); const count = filtered.length + (canCreate ? 1 : 0); const selected = (value: string) => (props.multiple ? props.value.includes(value) : props.value === value); function pick(index: number) { if (index === filtered.length && canCreate) { onCreate!(query.trim()); setQuery(""); if (!props.multiple) setOpen(false); return; } const option = filtered[index]; if (!option) return; if (props.multiple) { props.onSelect(selected(option.value) ? props.value.filter((v) => v !== option.value) : [...props.value, option.value]); } else { props.onSelect(option.value); setOpen(false); triggerRef.current?.focus(); } } // Close on outside clicks, and when the page scrolls or resizes under the panel. useEffect(() => { if (!open) return; const inside = (target: EventTarget | null) => target instanceof Node && (panelRef.current?.contains(target) || triggerRef.current?.contains(target)); const onDown = (e: MouseEvent) => !inside(e.target) && setOpen(false); const onScroll = (e: Event) => !inside(e.target) && setOpen(false); const onResize = () => setOpen(false); document.addEventListener("mousedown", onDown); window.addEventListener("scroll", onScroll, true); window.addEventListener("resize", onResize); return () => { document.removeEventListener("mousedown", onDown); window.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onResize); }; }, [open, setOpen]); // Place the panel under the trigger (or above it when there's no room), inside the viewport. const place = useCallback( (el: HTMLDivElement | null) => { panelRef.current = el; const anchor = triggerRef.current; if (!el || !anchor) return; // A dialog popup that is translated becomes the containing block of fixed children: measure // where (0, 0) actually lands and offset by it. // (Measured with the entrance animation off, so its travel doesn't count.) el.style.animation = "none"; el.style.top = "0px"; el.style.left = "0px"; const origin = el.getBoundingClientRect(); el.style.animation = ""; const r = anchor.getBoundingClientRect(); const fitsBelow = r.bottom + 6 + el.offsetHeight <= window.innerHeight - 8; const top = fitsBelow ? r.bottom + 6 : Math.max(8, r.top - 6 - el.offsetHeight); const left = Math.min(Math.max(8, align === "right" ? r.right - el.offsetWidth : r.left), window.innerWidth - el.offsetWidth - 8); el.style.top = `${top - origin.top}px`; el.style.left = `${left - origin.left}px`; el.style.transformOrigin = fitsBelow ? "top" : "bottom"; }, [align], ); function onKeyDown(e: React.KeyboardEvent) { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (count) setActive((i) => (i + (e.key === "ArrowDown" ? 1 : count - 1)) % count); } else if (e.key === "Enter") { e.preventDefault(); pick(Math.min(active, count - 1)); } else if (e.key === "Escape" || (e.key === "Tab" && !e.shiftKey)) { e.preventDefault(); e.stopPropagation(); setOpen(false); triggerRef.current?.focus(); } else if (!query && !props.multiple && /^[1-9]$/.test(e.key)) { e.preventDefault(); pick(Number(e.key) - 1); } } if (disabled) return {children}; const row = `${menuParts.ROW} w-full cursor-pointer`; return ( <> {open && createPortal(
e.stopPropagation()} > { setQuery(e.target.value); setActive(0); }} onKeyDown={onKeyDown} placeholder={placeholder} role="combobox" aria-label={placeholder.replace(/…$/, "")} aria-controls={listId} aria-expanded aria-activedescendant={count ? `${listId}-${Math.min(active, count - 1)}` : undefined} />
    {filtered.map((o, i) => (
  • setActive(i)} onClick={() => pick(i)} className={row} > {props.multiple ? ( ) : ( {selected(o.value) && } )} {o.icon && ( {o.icon} )} {o.label} {!props.multiple && !query && i < 9 && ( {String(i + 1)} )}
  • ))} {canCreate && (
  • setActive(filtered.length)} onClick={() => pick(filtered.length)} className={row} > {props.createLabel ? props.createLabel(query.trim()) : `Create “${query.trim()}”`}
  • )} {count === 0 &&
  • No matches
  • }
, container ?? document.body, )} ); }