"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<Element | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement | null>(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 <span className={triggerClassName}>{children}</span>;
const row = `${menuParts.ROW} w-full cursor-pointer`;
return (
<>
<button
ref={triggerRef}
type="button"
aria-label={label}
title={label}
aria-haspopup="listbox"
aria-expanded={open}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setContainer(e.currentTarget.closest('dialog, [role="dialog"]'));
setOpen(!open);
}}
className={triggerClassName}
>
{children}
</button>
{open &&
createPortal(
<div
ref={place}
className={`${menuParts.PLATE} fixed z-50 w-64 animate-pop text-left`}
onClick={(e) => e.stopPropagation()}
>
<Field size="compact" className="mb-1.5">
<Field.Icon>
<SearchIcon />
</Field.Icon>
<Field.Input
autoFocus
value={query}
onChange={(e) => {
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}
/>
</Field>
<ul id={listId} role="listbox" aria-multiselectable={props.multiple} className="max-h-72 overflow-y-auto">
{filtered.map((o, i) => (
<li
key={o.value}
id={`${listId}-${i}`}
role="option"
aria-selected={selected(o.value)}
data-highlighted={i === active ? "" : undefined}
onMouseMove={() => setActive(i)}
onClick={() => pick(i)}
className={row}
>
{props.multiple ? (
<span className="pointer-events-none inline-flex" aria-hidden>
<Checkbox size="row" checked={selected(o.value)} tabIndex={-1} />
</span>
) : (
<span className="select-led-slot" aria-hidden>
{selected(o.value) && <span className="mu-select-led select-led" />}
</span>
)}
{o.icon && (
<span aria-hidden className={menuParts.GLYPH}>
{o.icon}
</span>
)}
<span className={menuParts.LABEL}>{o.label}</span>
{!props.multiple && !query && i < 9 && (
<Kbd size="small" className={menuParts.KEY}>
{String(i + 1)}
</Kbd>
)}
</li>
))}
{canCreate && (
<li
id={`${listId}-${filtered.length}`}
role="option"
aria-selected={false}
data-highlighted={active === filtered.length ? "" : undefined}
onMouseMove={() => setActive(filtered.length)}
onClick={() => pick(filtered.length)}
className={row}
>
<span aria-hidden className={menuParts.GLYPH}>
<PlusIcon />
</span>
<span className={menuParts.LABEL}>
{props.createLabel ? props.createLabel(query.trim()) : `Create “${query.trim()}”`}
</span>
</li>
)}
{count === 0 && <li className="px-2.5 py-2 type-ui text-ink3">No matches</li>}
</ul>
</div>,
container ?? document.body,
)}
</>
);
}