Vectrum
Sign In
256 lines · 9.8 KB · TypeScript
"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,
        )}
    </>
  );
}