import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; export type DropdownOption = { value: T; label: string; hint?: string; badge?: ReactNode; /** A thin rule above this option, to set it apart from the ones before it. */ divider?: boolean; /** Section heading: shown above the first option of each run of options with the same group. */ group?: string; }; /** A themed select: a button showing the choice, and a list that opens under it. Arrow keys, Enter and Escape work * as in a native select. `onChange` may decline a choice (e.g. a premium option on a free plan) by not applying it. */ export function Dropdown({ value, options, onChange, label, }: { value: T; options: DropdownOption[]; onChange: (value: T) => void; label: string; }) { const [open, setOpen] = useState(false); const [active, setActive] = useState(0); const [place, setPlace] = useState({}); const root = useRef(null); const list = useRef(null); const listId = useId(); const current = options.find((o) => o.value === value) ?? options[0]; useEffect(() => { if (!open) return; const close = (e: MouseEvent) => { const t = e.target as Node; if (!root.current?.contains(t) && !list.current?.contains(t)) setOpen(false); }; document.addEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close); }, [open]); // The list lives in (cards create their own stacking layers and would cover it), fixed under the button, // or above it when the window has no room below. useLayoutEffect(() => { if (!open) return; const measure = () => { const r = root.current?.getBoundingClientRect(); if (!r) return; const up = window.innerHeight - r.bottom < 240 && r.top > window.innerHeight - r.bottom; setPlace({ left: r.left, width: r.width, ...(up ? { bottom: window.innerHeight - r.top + 6 } : { top: r.bottom + 6 }) }); }; measure(); window.addEventListener("scroll", measure, true); window.addEventListener("resize", measure); return () => { window.removeEventListener("scroll", measure, true); window.removeEventListener("resize", measure); }; }, [open]); useEffect(() => { if (open) list.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" }); }, [open, active]); const show = () => { setActive(Math.max(options.findIndex((o) => o.value === value), 0)); setOpen(true); }; const choose = (o: DropdownOption) => { setOpen(false); onChange(o.value); }; const onKey = (e: KeyboardEvent) => { if (!open) { if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) { e.preventDefault(); show(); } return; } if (e.key === "Escape") setOpen(false); else if (e.key === "ArrowDown") setActive((i) => Math.min(i + 1, options.length - 1)); else if (e.key === "ArrowUp") setActive((i) => Math.max(i - 1, 0)); else if (e.key === "Enter" || e.key === " ") choose(options[active]); else if (e.key === "Tab") setOpen(false); else return; e.preventDefault(); }; return (
{open && createPortal(
    {options.map((o, i) => [ o.group && o.group !== options[i - 1]?.group && (
  • 0 ? " divider" : ""}`}> {o.group}
  • ),
  • setActive(i)} onMouseDown={(e) => e.preventDefault()} onClick={() => choose(o)}> {o.label} {o.hint && {o.hint}} {o.badge} {o.value === value && ( )}
  • , ])}
, document.body, )}
); }