Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils";

export type ComboOption = {
  value: string;
  label: string;
  description?: string;
  /** Any CSS colour for the chip dot. */
  color?: string;
  disabled?: boolean;
};

export type ComboGroup = { label: string; options: ComboOption[] };

export interface MultiSelectComboboxProps {
  groups?: ComboGroup[];
  value?: string[];
  defaultValue?: string[];
  onChange?: (values: string[], options: ComboOption[]) => void;
  /** Allow creating options that don't exist yet. */
  creatable?: boolean;
  onCreate?: (option: ComboOption) => void;
  maxSelected?: number;
  label?: string;
  placeholder?: string;
  emptyText?: string;
  /** Emits one hidden input per selected value. */
  name?: string;
  defaultOpen?: boolean;
  className?: string;
}

const DEFAULT_GROUPS: ComboGroup[] = [
  {
    label: "Frontend",
    options: [
      { value: "react", label: "React", color: "#0ea5e9" },
      { value: "vue", label: "Vue", color: "#10b981" },
      { value: "svelte", label: "Svelte", color: "#f97316" },
      { value: "solid", label: "Solid", color: "#3b82f6" },
    ],
  },
  {
    label: "Backend",
    options: [
      { value: "node", label: "Node.js", color: "#22c55e" },
      { value: "go", label: "Go", color: "#06b6d4" },
      { value: "rust", label: "Rust", color: "#ea580c" },
      { value: "elixir", label: "Elixir", color: "#8b5cf6" },
    ],
  },
];

const norm = (s: string) => s.trim().toLowerCase();
const slugify = (s: string) => norm(s).replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "item";

export function MultiSelectCombobox({
  groups: groupsProp = DEFAULT_GROUPS,
  value,
  defaultValue = [],
  onChange,
  creatable = true,
  onCreate,
  maxSelected,
  label = "Technologies",
  placeholder = "Search or add…",
  emptyText = "No matches",
  name,
  defaultOpen = false,
  className,
}: MultiSelectComboboxProps) {
  const uid = React.useId();
  const reduce = useReducedMotion();
  const [inner, setInner] = React.useState<string[]>(defaultValue);
  const selected = value ?? inner;
  const [created, setCreated] = React.useState<ComboOption[]>([]);
  const [open, setOpen] = React.useState(defaultOpen);
  const [query, setQuery] = React.useState("");
  const [active, setActive] = React.useState<string | null>(null);
  const [chipFocus, setChipFocus] = React.useState<number | null>(null);
  const [announce, setAnnounce] = React.useState("");
  const [shake, setShake] = React.useState(0);

  const rootRef = React.useRef<HTMLDivElement>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);
  const listRef = React.useRef<HTMLUListElement>(null);

  const groups = React.useMemo(
    () => (created.length ? [...groupsProp, { label: "Created", options: created }] : groupsProp),
    [groupsProp, created],
  );
  const all = React.useMemo(() => groups.flatMap((g) => g.options), [groups]);
  const byValue = React.useMemo(() => new Map(all.map((o) => [o.value, o])), [all]);
  const selectedOpts = selected.map((v) => byValue.get(v) ?? { value: v, label: v });
  const limitReached = maxSelected !== undefined && selected.length >= maxSelected;

  const q = norm(query);
  const filtered = React.useMemo(
    () =>
      groups
        .map((g) => ({ ...g, options: g.options.filter((o) => !q || norm(o.label).includes(q) || norm(o.description ?? "").includes(q)) }))
        .filter((g) => g.options.length),
    [groups, q],
  );
  const exact = all.some((o) => norm(o.label) === q);
  const canCreate = creatable && !!q && !exact;
  const CREATE = "__create__";

  const navigable = [
    ...filtered.flatMap((g) => g.options.filter((o) => !o.disabled && (!limitReached || selected.includes(o.value))).map((o) => o.value)),
    ...(canCreate && !limitReached ? [CREATE] : []),
  ];
  const activeValue = active && navigable.includes(active) ? active : navigable[0] ?? null;
  const optId = (v: string) => `${uid}-o-${v.replace(/[^a-zA-Z0-9_-]/g, "_")}`;

  const setSelected = (next: string[]) => {
    if (value === undefined) setInner(next);
    onChange?.(next, next.map((v) => byValue.get(v) ?? { value: v, label: v }));
  };

  const toggle = (v: string) => {
    const opt = byValue.get(v);
    if (selected.includes(v)) {
      setSelected(selected.filter((x) => x !== v));
      setAnnounce(`${opt?.label ?? v} removed`);
      return;
    }
    if (limitReached) {
      setShake((n) => n + 1);
      setAnnounce(`Limit of ${maxSelected} reached`);
      return;
    }
    setSelected([...selected, v]);
    setAnnounce(`${opt?.label ?? v} selected`);
  };

  const create = () => {
    const text = query.trim();
    if (!text) return;
    if (limitReached) {
      setShake((n) => n + 1);
      return;
    }
    let v = slugify(text);
    while (byValue.has(v)) v += "-1";
    const opt: ComboOption = { value: v, label: text, color: "var(--color-primary)" };
    setCreated((c) => [...c, opt]);
    onCreate?.(opt);
    setSelected([...selected, v]);
    setAnnounce(`Created ${text}`);
    setQuery("");
  };

  const removeAt = (i: number) => {
    const v = selected[i];
    if (v === undefined) return;
    toggle(v);
  };

  // close on outside press
  React.useEffect(() => {
    if (!open) return;
    const onDown = (e: PointerEvent) => {
      if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [open]);

  React.useEffect(() => {
    if (!open || !activeValue) return;
    document.getElementById(optId(activeValue))?.scrollIntoView({ block: "nearest" });
    // eslint-disable-next-line react-hooks/exhaustive-deps -- optId depends only on uid
  }, [open, activeValue]);

  const move = (delta: number) => {
    if (!navigable.length) return;
    const i = activeValue ? navigable.indexOf(activeValue) : -1;
    setActive(navigable[(i + delta + navigable.length) % navigable.length]);
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    const caretAtStart = e.currentTarget.selectionStart === 0 && e.currentTarget.selectionEnd === 0;
    if (chipFocus !== null) {
      if (e.key === "ArrowLeft") {
        e.preventDefault();
        setChipFocus(Math.max(0, chipFocus - 1));
        return;
      }
      if (e.key === "ArrowRight") {
        e.preventDefault();
        setChipFocus(chipFocus + 1 >= selected.length ? null : chipFocus + 1);
        return;
      }
      if (e.key === "Backspace" || e.key === "Delete") {
        e.preventDefault();
        removeAt(chipFocus);
        const nextLen = selected.length - 1;
        setChipFocus(nextLen === 0 ? null : Math.min(chipFocus, nextLen - 1));
        return;
      }
      if (e.key !== "Shift") setChipFocus(null);
    }
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        if (!open) setOpen(true);
        else move(1);
        break;
      case "ArrowUp":
        e.preventDefault();
        if (!open) setOpen(true);
        else move(-1);
        break;
      case "Enter":
        if (!open) return;
        e.preventDefault();
        if (activeValue === CREATE) create();
        else if (activeValue) toggle(activeValue);
        break;
      case "Escape":
        if (open) {
          e.preventDefault();
          setOpen(false);
        } else if (query) setQuery("");
        break;
      case "Tab":
        setOpen(false);
        break;
      case "ArrowLeft":
        if (caretAtStart && selected.length) {
          e.preventDefault();
          setChipFocus(selected.length - 1);
        }
        break;
      case "Backspace":
        if (!query && selected.length) {
          e.preventDefault();
          setChipFocus(selected.length - 1); // first press highlights, second removes
        }
        break;
    }
  };

  return (
    <div ref={rootRef} className={cn("relative w-full max-w-md", className)}>
      <div className="mb-1.5 flex items-baseline justify-between gap-2">
        <label htmlFor={`${uid}-input`} id={`${uid}-label`} className="text-sm font-medium">
          {label}
        </label>
        {maxSelected !== undefined && (
          <motion.span
            key={shake}
            animate={shake && !reduce ? { x: [0, -5, 5, -3, 3, 0] } : undefined}
            transition={{ duration: 0.35 }}
            className={cn("text-xs tabular-nums", limitReached ? "font-medium text-amber-600 dark:text-amber-400" : "text-muted-foreground")}
          >
            {selected.length}/{maxSelected}
          </motion.span>
        )}
      </div>

      <div
        onClick={() => {
          inputRef.current?.focus();
          setOpen(true);
        }}
        className={cn(
          "flex min-h-11 w-full cursor-text flex-wrap items-center gap-1.5 rounded-xl border bg-background py-1.5 pl-1.5 pr-9 shadow-xs transition",
          "hover:border-foreground/20 focus-within:border-ring focus-within:ring-4 focus-within:ring-ring/15",
        )}
      >
        <ul aria-label="Selected" className="contents">
          <AnimatePresence initial={false}>
            {selectedOpts.map((o, i) => (
              <motion.li
                key={o.value}
                layout={!reduce}
                initial={{ opacity: 0, scale: 0.6 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.12 } }}
                transition={{ type: "spring", stiffness: 600, damping: 32 }}
                className={cn(
                  "inline-flex h-7 max-w-full items-center gap-1.5 rounded-lg border bg-muted/70 pl-2 pr-1 text-[13px] font-medium",
                  chipFocus === i && "border-ring bg-accent ring-2 ring-ring/30",
                )}
              >
                <span aria-hidden className="size-2 shrink-0 rounded-full" style={{ background: o.color ?? "currentColor" }} />
                <span className="truncate">{o.label}</span>
                <button
                  type="button"
                  tabIndex={-1}
                  aria-label={`Remove ${o.label}`}
                  onClick={(e) => {
                    e.stopPropagation();
                    toggle(o.value);
                    inputRef.current?.focus();
                  }}
                  className="grid size-5 place-items-center rounded-md text-muted-foreground transition hover:bg-background hover:text-foreground"
                >
                  <X className="size-3" />
                </button>
              </motion.li>
            ))}
          </AnimatePresence>
        </ul>
        <input
          ref={inputRef}
          id={`${uid}-input`}
          role="combobox"
          aria-expanded={open}
          aria-controls={`${uid}-list`}
          aria-autocomplete="list"
          aria-activedescendant={open && activeValue ? optId(activeValue) : undefined}
          aria-describedby={`${uid}-help`}
          value={query}
          onChange={(e) => {
            setQuery(e.target.value);
            setActive(null);
            setChipFocus(null);
            setOpen(true);
          }}
          onKeyDown={onKeyDown}
          onFocus={() => setOpen(true)}
          onBlur={() => setChipFocus(null)}
          placeholder={selected.length ? "" : placeholder}
          autoComplete="off"
          className="h-7 min-w-24 flex-1 bg-transparent px-1.5 text-sm outline-none placeholder:text-muted-foreground/70"
        />
      </div>
      <button
        type="button"
        tabIndex={-1}
        aria-label={open ? "Close options" : "Open options"}
        onClick={() => {
          setOpen((o) => !o);
          inputRef.current?.focus();
        }}
        className="absolute right-2 top-[calc(1.25rem+0.375rem+0.5rem)] grid size-7 place-items-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
      >
        <ChevronDown className={cn("size-4 transition-transform duration-200", open && "rotate-180")} />
      </button>
      <p id={`${uid}-help`} className="sr-only">
        Use arrow keys to browse, Enter to toggle, Backspace to remove the last chip.
      </p>
      <span className="sr-only" aria-live="polite">
        {announce}
      </span>

      <AnimatePresence>
        {open && (
          <motion.div
            initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.98, transition: { duration: 0.12 } }}
            transition={{ type: "spring", stiffness: 520, damping: 34 }}
            className="absolute inset-x-0 top-[calc(100%+6px)] z-50 origin-top overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-xl shadow-black/10 dark:shadow-black/40"
          >
            <ul
              ref={listRef}
              id={`${uid}-list`}
              role="listbox"
              tabIndex={-1}
              aria-multiselectable
              aria-labelledby={`${uid}-label`}
              className="max-h-64 overflow-y-auto overscroll-contain p-1.5 [scrollbar-width:thin]"
            >
              {filtered.map((g) => (
                <li key={g.label} role="presentation">
                  <div id={`${uid}-g-${slugify(g.label)}`} className="px-2 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/80">
                    {g.label}
                  </div>
                  <ul role="group" aria-labelledby={`${uid}-g-${slugify(g.label)}`}>
                    {g.options.map((o) => {
                      const isSel = selected.includes(o.value);
                      const blocked = o.disabled || (limitReached && !isSel);
                      const isActive = activeValue === o.value;
                      return (
                        <li
                          key={o.value}
                          id={optId(o.value)}
                          role="option"
                          aria-selected={isSel}
                          aria-disabled={blocked || undefined}
                          onPointerDown={(e) => e.preventDefault()}
                          onPointerMove={() => !blocked && active !== o.value && setActive(o.value)}
                          onClick={() => !blocked && toggle(o.value)}
                          className={cn(
                            "relative flex h-9 cursor-pointer select-none items-center gap-2.5 rounded-lg px-2 text-sm transition-colors",
                            isActive && "bg-accent",
                            blocked && "cursor-not-allowed opacity-40",
                          )}
                        >
                          <span
                            aria-hidden
                            className={cn(
                              "grid size-4 shrink-0 place-items-center rounded-[5px] border transition-colors",
                              isSel ? "border-primary bg-primary text-primary-foreground" : "border-foreground/25 bg-background",
                            )}
                          >
                            <AnimatePresence initial={false}>
                              {isSel && (
                                <motion.span
                                  initial={{ scale: 0, rotate: -30 }}
                                  animate={{ scale: 1, rotate: 0 }}
                                  exit={{ scale: 0 }}
                                  transition={{ type: "spring", stiffness: 700, damping: 26 }}
                                >
                                  <Check className="size-3" strokeWidth={3} />
                                </motion.span>
                              )}
                            </AnimatePresence>
                          </span>
                          <span aria-hidden className="size-2 shrink-0 rounded-full" style={{ background: o.color ?? "currentColor" }} />
                          <span className="min-w-0 flex-1 truncate">{o.label}</span>
                          {o.description && <span className="truncate text-xs text-muted-foreground">{o.description}</span>}
                        </li>
                      );
                    })}
                  </ul>
                </li>
              ))}
              {canCreate && (
                <li
                  id={optId(CREATE)}
                  role="option"
                  aria-selected={false}
                  aria-disabled={limitReached || undefined}
                  onPointerDown={(e) => e.preventDefault()}
                  onPointerMove={() => setActive(CREATE)}
                  onClick={create}
                  className={cn(
                    "mt-1 flex h-9 cursor-pointer items-center gap-2.5 rounded-lg border-t px-2 text-sm",
                    activeValue === CREATE && "bg-accent",
                    limitReached && "cursor-not-allowed opacity-40",
                  )}
                >
                  <span className="grid size-4 place-items-center rounded-[5px] bg-primary/15 text-primary">
                    <Plus className="size-3" strokeWidth={3} />
                  </span>
                  <span className="truncate">
                    Create <span className="font-semibold">“{query.trim()}”</span>
                  </span>
                </li>
              )}
              {!filtered.length && !canCreate && (
                <li role="presentation" className="px-3 py-6 text-center text-sm text-muted-foreground">
                  {emptyText}
                </li>
              )}
            </ul>
            {limitReached && (
              <div className="border-t bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
                You can pick up to {maxSelected}. Remove one to choose another.
              </div>
            )}
          </motion.div>
        )}
      </AnimatePresence>

      {name && selected.map((v) => <input key={v} type="hidden" name={name} value={v} />)}
    </div>
  );
}

More in Forms & Inputs

View all →