# Tag select

> Multi-select whose picks become chips in the field: chips pop in with a spring and fold into “+N”, options tick a drawn checkbox, arrows walk the chips and Backspace removes them, with Reset and Select all in the footer.

- Collection: Components
- Tier: free
- Docs: https://geturui.com/docs/tag-select
- Install: `npx shadcn@latest add @geturui/tag-select`
- Dependencies: motion, lucide-react

## Usage

```tsx
import { TagSelect } from "@/components/ui/tag-select";

const reviewers = [
  { value: "maya", label: "Maya Okafor", description: "Design lead", avatar: true },
  { value: "theo", label: "Theo Lindqvist", description: "Frontend", avatar: true },
  { value: "priya", label: "Priya Raman", description: "Design systems", avatar: true },
];

export function ReviewersField() {
  return <TagSelect label="Reviewers" options={reviewers} defaultValue={["maya"]} max={2} name="reviewers" />;
}
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| options | `TagSelectEntry[]` | — | { value, label, description?, icon?, avatar?: boolean | string, dot?: "success" | "warning" | "danger" | "info" | "neutral", meta?, shortcut?, disabled?, keywords? }, a group { heading, options } or { separator: true } |
| value / defaultValue | `string[]` | — | Selected values in pick order. |
| onValueChange | `(value: string[]) => void` | — | Fires on every toggle, removal, reset or select all. |
| size | `"sm" | "md" | "lg"` | "md" | Well height 32 / 40 / 48, matching Button. Rows, icons and the list height scale with it. |
| label / hint / error | `ReactNode` | — | Field chrome. Any error marks the control invalid and swaps the hint for the message. |
| required | `boolean` | false | Volt asterisk on the label and aria-required on the control. |
| disabled | `boolean` | false | Dims the well and blocks interaction. |
| name | `string` | — | Form field name; values post through hidden inputs. |
| placeholder | `string` | "Select options" | Shown while nothing is picked. |
| maxVisible | `number` | 3 | Chips shown before the rest collapse into “+N”. |
| max | `number` | — | Most options that can be picked; the others disable once it's reached. |
| searchable | `boolean` | true | Search box at the top of the panel, with a Clear search link when nothing matches. |
| footer | `boolean` | true | Selected count with Reset and Select all (Select all respects the search and max). |
| iconLeading | `ComponentType<{ className }>` | — | Icon at the start of the well. |

## Accessibility

- The listbox is aria-multiselectable; Enter (and Space without search) toggles the highlighted option and the panel stays open.
- ⌘/Ctrl+A selects or clears every visible option when focus is on the trigger.
- Left arrow or Backspace on the trigger moves into the chips; Left/Right/Home/End walk them, Backspace or Delete removes one and focus lands on its neighbour.
- Each chip's remove button is labelled “Remove name”; the “+N” chip reads out the hidden names.
- The footer count is a polite live region.

## Source

### components/ui/tag-select.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown, Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Field, fieldWellClasses } from "@/components/ui/field";
import { buttonClasses } from "@/components/ui/button";
import {
  Listbox,
  ListboxPopover,
  OptionMedia,
  defaultFilter,
  filterEntries,
  flattenEntries,
  isPrintableKey,
  listboxIconSize,
  mapEntries,
  optionId,
  useListboxNavigation,
  useSafeId,
  type ListboxEntry,
  type ListboxIcon,
  type ListboxOption,
  type ListboxSize,
} from "@/components/ui/listbox";

export type { ListboxEntry as TagSelectEntry, ListboxOption as TagSelectOption } from "@/components/ui/listbox";

export interface TagSelectProps {
  /** Options, groups (`{ heading, options }`) and separators (`{ separator: true }`). */
  options: ListboxEntry[];
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
  size?: ListboxSize;
  label?: React.ReactNode;
  hint?: React.ReactNode;
  error?: React.ReactNode;
  required?: boolean;
  disabled?: boolean;
  labelAside?: React.ReactNode;
  id?: string;
  /** @default "Select options" */
  placeholder?: string;
  iconLeading?: ListboxIcon;
  /** Chips shown before collapsing the rest into "+N". @default 3 */
  maxVisible?: number;
  /** Most options that can be picked; the rest disable once reached. */
  max?: number;
  /** Search box at the top of the panel. @default true */
  searchable?: boolean;
  searchPlaceholder?: string;
  /** Footer with the selected count, Reset and Select all. @default true */
  footer?: boolean;
  emptyText?: React.ReactNode;
  /** Form field name; each value is posted through its own hidden input. */
  name?: string;
  "aria-label"?: string;
  className?: string;
}

const chipSize: Record<ListboxSize, string> = {
  sm: "h-5 text-[11px] gap-1",
  md: "h-6 text-xs gap-1.5",
  lg: "h-7 text-[13px] gap-1.5",
};

/** Multi-select whose picks become chips in the field: chips pop in with a spring, overflow folds into "+N", arrows walk the chips and Backspace removes them. */
export function TagSelect({
  options,
  value,
  defaultValue = [],
  onValueChange,
  size = "md",
  label,
  hint,
  error,
  required,
  disabled,
  labelAside,
  id,
  placeholder = "Select options",
  iconLeading: IconLeading,
  maxVisible = 3,
  max,
  searchable = true,
  searchPlaceholder = "Search",
  footer = true,
  emptyText = "No matches",
  name,
  "aria-label": ariaLabel,
  className,
}: TagSelectProps) {
  const reduce = useReducedMotion();
  const listId = useSafeId("ts");
  const wellRef = React.useRef<HTMLDivElement>(null);
  const triggerRef = React.useRef<HTMLButtonElement>(null);
  const chipRefs = React.useRef(new Map<string, HTMLButtonElement>());
  const pendingFocus = React.useRef<string | null>(null);

  const [own, setOwn] = React.useState<string[]>(defaultValue);
  const values = value ?? own;
  const [isOpen, setIsOpen] = React.useState(false);
  const open = isOpen && !disabled;
  const [query, setQuery] = React.useState("");

  const all = React.useMemo(() => flattenEntries(options), [options]);
  const byValue = React.useMemo(() => new Map(all.map((o) => [o.value, o])), [all]);
  const picked = values.map((v) => byValue.get(v)).filter((o): o is ListboxOption => Boolean(o));
  const shown = picked.slice(0, maxVisible);
  const hidden = picked.slice(maxVisible);
  const full = max !== undefined && values.length >= max;

  const visible = React.useMemo(() => {
    const filtered = searchable && query.trim() ? filterEntries(options, (o) => defaultFilter(o, query)) : options;
    return full ? mapEntries(filtered, (o) => (values.includes(o.value) ? o : { ...o, disabled: true })) : filtered;
  }, [options, searchable, query, full, values]);
  const visibleFlat = React.useMemo(() => flattenEntries(visible), [visible]);
  const nav = useListboxNavigation(visibleFlat, { autoActivateFirst: Boolean(query.trim()) });

  // Move focus after the chip list re-renders (removal shifts chips around).
  React.useEffect(() => {
    const target = pendingFocus.current;
    if (target === null) return;
    pendingFocus.current = null;
    if (target === "") triggerRef.current?.focus();
    else chipRefs.current.get(target)?.focus();
  });

  const set = (next: string[]) => {
    if (value === undefined) setOwn(next);
    onValueChange?.(next);
  };
  const toggle = (option: ListboxOption) => {
    if (option.disabled) return;
    set(values.includes(option.value) ? values.filter((v) => v !== option.value) : [...values, option.value]);
  };
  const selectableVisible = visibleFlat.filter((o) => !byValue.get(o.value)?.disabled);
  const allVisibleSelected = selectableVisible.length > 0 && selectableVisible.every((o) => values.includes(o.value));
  const selectAll = () => {
    const next = [...values];
    for (const o of selectableVisible) {
      if (max !== undefined && next.length >= max) break;
      if (!next.includes(o.value)) next.push(o.value);
    }
    set(next);
  };

  const setOpen = (next: boolean, restoreFocus = true) => {
    setIsOpen(next);
    if (!next) {
      setQuery("");
      if (restoreFocus) triggerRef.current?.focus();
    }
  };
  const openList = () => {
    const first = visibleFlat.find((o) => !o.disabled);
    nav.setActive(first?.value ?? null);
    setIsOpen(true);
  };

  const remove = (option: ListboxOption, index: number, direction: "prev" | "next") => {
    const next = values.filter((v) => v !== option.value);
    set(next);
    const nextShown = next.slice(0, maxVisible);
    const target = direction === "prev" && index > 0 ? nextShown[index - 1] : nextShown[Math.min(index, nextShown.length - 1)];
    pendingFocus.current = target ?? "";
  };

  const focusChip = (index: number) => {
    const o = shown[index];
    if (o) chipRefs.current.get(o.value)?.focus();
    else triggerRef.current?.focus();
  };

  const onChipKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, option: ListboxOption, index: number) => {
    switch (e.key) {
      case "ArrowLeft":
        e.preventDefault();
        focusChip(Math.max(0, index - 1));
        return;
      case "ArrowRight":
        e.preventDefault();
        focusChip(index + 1);
        return;
      case "Home":
        e.preventDefault();
        focusChip(0);
        return;
      case "End":
        e.preventDefault();
        focusChip(shown.length - 1);
        return;
      case "Backspace":
        e.preventDefault();
        remove(option, index, "prev");
        return;
      case "Delete":
        e.preventDefault();
        remove(option, index, "next");
        return;
      case "Escape":
        e.preventDefault();
        triggerRef.current?.focus();
        return;
      case "ArrowDown":
        e.preventDefault();
        triggerRef.current?.focus();
        openList();
        return;
    }
  };

  // Listbox keys shared by the trigger (no search) and the search box.
  const onListKey = (e: React.KeyboardEvent, textInput: boolean) => {
    const toggleActive = () => {
      const o = visibleFlat.find((x) => x.value === nav.active);
      if (o) toggle(o);
    };
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        nav.step(1);
        return;
      case "ArrowUp":
        e.preventDefault();
        nav.step(-1);
        return;
      case "PageDown":
      case "PageUp":
        e.preventDefault();
        nav.step(e.key === "PageDown" ? 10 : -10);
        return;
      case "Home":
      case "End":
        if (textInput) return;
        e.preventDefault();
        nav.edge(e.key === "Home" ? "first" : "last");
        return;
      case "Enter":
        e.preventDefault();
        toggleActive();
        return;
      case " ":
        if (textInput) return;
        e.preventDefault();
        toggleActive();
        return;
      case "Escape":
        e.preventDefault();
        e.stopPropagation();
        setOpen(false);
        return;
      case "Tab":
        if (!textInput) setOpen(false, false);
        return;
      default:
        if (!textInput && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "a") {
          e.preventDefault();
          if (allVisibleSelected) set(values.filter((v) => !selectableVisible.some((o) => o.value === v)));
          else selectAll();
          return;
        }
        if (!textInput && isPrintableKey(e)) nav.typeahead(e.key, e.timeStamp);
    }
  };

  const onTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
    if ((e.key === "ArrowLeft" || e.key === "Backspace") && shown.length) {
      e.preventDefault();
      if (open) setOpen(false, false);
      focusChip(shown.length - 1);
      return;
    }
    if (!open) {
      if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === " ") {
        e.preventDefault();
        openList();
      } else if (searchable && isPrintableKey(e)) {
        setQuery(e.key);
        setIsOpen(true);
      }
      return;
    }
    if (!searchable) onListKey(e, false);
  };

  // Pressing empty well space acts like pressing the trigger.
  const onBlankPress = (e: React.PointerEvent<HTMLDivElement>) => {
    if (e.target !== e.currentTarget || disabled) return;
    e.preventDefault();
    triggerRef.current?.focus();
    if (open) setOpen(false);
    else openList();
  };

  const activeId = open && nav.active ? optionId(listId, nav.active) : undefined;
  const countLabel = `${values.length} selected`;

  return (
    <Field label={label} hint={hint} error={error} required={required} labelAside={labelAside} id={id} disabled={disabled} className={className}>
      {({ id: controlId, labelId, describedBy, invalid }) => (
        <>
          <div
            ref={wellRef}
            role="group"
            aria-labelledby={label ? labelId : undefined}
            aria-label={label ? undefined : ariaLabel}
            onPointerDown={onBlankPress}
            className={cn(
              fieldWellClasses({ size, invalid, disabled }),
              "h-auto cursor-pointer py-1",
              open && !invalid && "border-volt-ink shadow-[0_0_0_3px_var(--volt-soft)]",
            )}
          >
            {IconLeading && (
              <span aria-hidden className="inline-flex shrink-0 text-ink-subtle">
                <IconLeading className={listboxIconSize[size]} />
              </span>
            )}
            <div
              onPointerDown={onBlankPress}
              className="relative flex min-w-0 flex-1 flex-wrap items-center gap-1"
            >
              {picked.length > 0 && (
                <div role="list" aria-label="Selected" className="contents">
                  <AnimatePresence mode="popLayout" initial={false}>
                    {shown.map((o, i) => (
                      <motion.span
                        key={o.value}
                        role="listitem"
                        layout={!reduce}
                        initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: "blur(4px)" }}
                        animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                        exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: "blur(4px)" }}
                        transition={{ type: "spring", stiffness: 480, damping: 30 }}
                        className={cn(
                          "inline-flex max-w-[11rem] items-center rounded-full border border-border bg-surface pr-0.5 text-ink",
                          "has-[:focus-visible]:border-volt-ink has-[:focus-visible]:shadow-[0_0_0_2px_var(--volt-soft)]",
                          chipSize[size],
                          o.avatar || o.dot || o.icon ? "pl-0.5" : "pl-2",
                        )}
                      >
                        <OptionMedia option={o} size="sm" className={cn(!o.avatar && "ml-1")} />
                        <span className="truncate">{o.label}</span>
                        <button
                          ref={(node) => {
                            if (node) chipRefs.current.set(o.value, node);
                            else chipRefs.current.delete(o.value);
                          }}
                          type="button"
                          tabIndex={-1}
                          aria-label={`Remove ${o.label}`}
                          disabled={disabled}
                          onPointerDown={(e) => e.stopPropagation()}
                          onClick={() => remove(o, i, "next")}
                          onKeyDown={(e) => onChipKeyDown(e, o, i)}
                          className="grid size-5 shrink-0 place-items-center rounded-full text-ink-subtle outline-none hover:bg-surface-hover hover:text-ink focus-visible:bg-surface-hover focus-visible:text-ink"
                        >
                          <X aria-hidden className="size-3" />
                        </button>
                      </motion.span>
                    ))}
                    {hidden.length > 0 && (
                      <motion.span
                        key="overflow"
                        role="listitem"
                        layout={!reduce}
                        initial={{ opacity: 0, scale: 0.6 }}
                        animate={{ opacity: 1, scale: 1 }}
                        exit={{ opacity: 0, scale: 0.6 }}
                        transition={{ type: "spring", stiffness: 480, damping: 30 }}
                        title={hidden.map((o) => o.label).join(", ")}
                        className={cn("inline-flex items-center rounded-full bg-volt-soft px-2 font-medium tabular-nums text-volt-ink", chipSize[size])}
                      >
                        +{hidden.length}
                        <span className="sr-only"> more: {hidden.map((o) => o.label).join(", ")}</span>
                      </motion.span>
                    )}
                  </AnimatePresence>
                </div>
              )}
              <button
                ref={triggerRef}
                id={controlId}
                type="button"
                role="combobox"
                aria-haspopup="listbox"
                aria-expanded={open}
                aria-controls={open ? listId : undefined}
                aria-activedescendant={!searchable ? activeId : undefined}
                aria-labelledby={label ? `${labelId} ${controlId}-count` : undefined}
                aria-label={label ? undefined : ariaLabel}
                aria-describedby={describedBy}
                aria-invalid={invalid || undefined}
                aria-required={required || undefined}
                disabled={disabled}
                onClick={() => (open ? setOpen(false) : openList())}
                onKeyDown={onTriggerKeyDown}
                className={cn("flex h-6 flex-1 cursor-pointer items-center justify-between gap-2 rounded-[6px] text-left outline-none focus-visible:shadow-[var(--focus-ring)]", picked.length ? "min-w-6" : "min-w-16")}
              >
                <span id={`${controlId}-count`} className={cn("truncate", picked.length && "sr-only")}>
                  {picked.length ? countLabel : placeholder}
                </span>
                <motion.span
                  aria-hidden
                  animate={{ rotate: open ? 180 : 0 }}
                  transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 28 }}
                  className="ml-auto inline-flex shrink-0 text-ink-subtle"
                >
                  <ChevronDown className={listboxIconSize[size]} />
                </motion.span>
              </button>
            </div>
          </div>
          {name && values.map((v) => <input key={v} type="hidden" name={name} value={v} />)}
          <ListboxPopover open={open} anchorRef={wellRef} onDismiss={() => setOpen(false, false)}>
            {searchable && (
              <div className="flex shrink-0 items-center gap-2 border-b border-border px-3">
                <Search aria-hidden className="size-4 shrink-0 text-ink-subtle" />
                <input
                  autoFocus
                  role="combobox"
                  aria-expanded
                  aria-controls={listId}
                  aria-autocomplete="list"
                  aria-activedescendant={activeId}
                  aria-label={typeof label === "string" ? `Search ${label.toLowerCase()}` : "Search options"}
                  value={query}
                  placeholder={searchPlaceholder}
                  onChange={(e) => {
                    setQuery(e.target.value);
                    nav.setActive(null);
                  }}
                  onKeyDown={(e) => onListKey(e, true)}
                  className="h-10 min-w-0 flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-ink-subtle"
                />
              </div>
            )}
            <Listbox
              id={listId}
              entries={visible}
              selected={values}
              active={nav.active}
              onActiveChange={nav.setActive}
              onPick={toggle}
              multiple
              indicator="checkbox"
              size={size}
              query={searchable ? query : undefined}
              aria-labelledby={label ? labelId : undefined}
              aria-label={label ? undefined : (ariaLabel ?? "Options")}
              empty={
                <div role="status" className="grid justify-items-center gap-2 px-3 py-6 text-center text-sm text-ink-subtle">
                  <span>
                    {emptyText}
                    {query.trim() && <> for “{query.trim()}”</>}
                  </span>
                  {query && (
                    <button
                      type="button"
                      onClick={() => setQuery("")}
                      className="rounded-full text-xs font-medium text-volt-ink underline-offset-4 outline-none hover:underline focus-visible:shadow-[var(--focus-ring)]"
                    >
                      Clear search
                    </button>
                  )}
                </div>
              }
            />
            {footer && (
              <div className="flex shrink-0 items-center gap-1.5 border-t border-border px-2 py-2">
                <span aria-live="polite" className="mr-auto flex items-center gap-1 pl-1.5 text-xs text-ink-subtle">
                  <span className="relative inline-flex overflow-hidden tabular-nums text-ink">
                    <AnimatePresence mode="popLayout" initial={false}>
                      <motion.span
                        key={values.length}
                        initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}
                        animate={{ opacity: 1, y: 0 }}
                        exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
                        transition={{ type: "spring", stiffness: 500, damping: 34 }}
                      >
                        {values.length}
                      </motion.span>
                    </AnimatePresence>
                  </span>
                  {max !== undefined ? `of ${max} selected` : "selected"}
                </span>
                <button
                  type="button"
                  disabled={values.length === 0}
                  onMouseDown={(e) => e.preventDefault()}
                  onClick={() => set([])}
                  className={buttonClasses({ variant: "ghost", size: "sm", className: "h-7 px-3 text-xs" })}
                >
                  Reset
                </button>
                <button
                  type="button"
                  disabled={allVisibleSelected || full || selectableVisible.length === 0}
                  onMouseDown={(e) => e.preventDefault()}
                  onClick={selectAll}
                  className={buttonClasses({ variant: "secondary", size: "sm", className: "h-7 px-3 text-xs" })}
                >
                  Select all
                </button>
              </div>
            )}
          </ListboxPopover>
        </>
      )}
    </Field>
  );
}

```

### components/ui/listbox.tsx

```tsx
"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

/*
 * Shared listbox core for Select, Combobox and TagSelect.
 * Option model, filtering, APG keyboard navigation, the option list with a gliding
 * highlight, and a portalled panel that springs open and flips near the viewport edge.
 */

export type ListboxSize = "sm" | "md" | "lg";
export type ListboxTone = "success" | "warning" | "danger" | "info" | "neutral";
/** Any icon component that takes a className (every lucide-react icon fits). */
export type ListboxIcon = React.ComponentType<{ className?: string }>;

export interface ListboxOption {
  value: string;
  /** Visible name; also used for typeahead and filtering. */
  label: string;
  /** Supporting line under the label. Strings are searchable. */
  description?: React.ReactNode;
  icon?: ListboxIcon;
  /** Initials avatar. `true` uses the label; a string is the name to take initials from. */
  avatar?: boolean | string;
  /** Status dot before the label. */
  dot?: ListboxTone;
  /** Right-aligned text: a count, a price, a role. */
  meta?: React.ReactNode;
  /** Right-aligned key hint, e.g. "⌘1". */
  shortcut?: string;
  disabled?: boolean;
  /** Extra words that match when filtering. */
  keywords?: string[];
}

export interface ListboxGroup {
  heading?: string;
  options: ListboxOption[];
}

export interface ListboxSeparator {
  separator: true;
}

export type ListboxEntry = ListboxOption | ListboxGroup | ListboxSeparator;

export function isListboxGroup(entry: ListboxEntry): entry is ListboxGroup {
  return "options" in entry;
}

export function isListboxSeparator(entry: ListboxEntry): entry is ListboxSeparator {
  return "separator" in entry;
}

/** Every option in visual order. */
export function flattenEntries(entries: readonly ListboxEntry[]): ListboxOption[] {
  const out: ListboxOption[] = [];
  for (const e of entries) {
    if (isListboxSeparator(e)) continue;
    if (isListboxGroup(e)) out.push(...e.options);
    else out.push(e);
  }
  return out;
}

/** Keep options that pass `keep`; empty groups and dangling separators are dropped. */
export function filterEntries(entries: readonly ListboxEntry[], keep: (option: ListboxOption) => boolean): ListboxEntry[] {
  const out: ListboxEntry[] = [];
  for (const e of entries) {
    if (isListboxSeparator(e)) {
      if (out.length && !isListboxSeparator(out[out.length - 1])) out.push(e);
      continue;
    }
    if (isListboxGroup(e)) {
      const options = e.options.filter(keep);
      if (options.length) out.push({ ...e, options });
      continue;
    }
    if (keep(e)) out.push(e);
  }
  while (out.length && isListboxSeparator(out[out.length - 1])) out.pop();
  return out;
}

/** Map every option, keeping the group structure. */
export function mapEntries(entries: readonly ListboxEntry[], fn: (option: ListboxOption) => ListboxOption): ListboxEntry[] {
  return entries.map((e) => (isListboxSeparator(e) ? e : isListboxGroup(e) ? { ...e, options: e.options.map(fn) } : fn(e)));
}

const foldChar = (c: string) => c.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();

/** Case- and accent-insensitive form of a string. */
export function fold(text: string) {
  return Array.from(text).map(foldChar).join("");
}

/** Default filter: label, string description and keywords contain the query, ignoring case and accents. */
export function defaultFilter(option: ListboxOption, query: string) {
  const q = fold(query.trim());
  if (!q) return true;
  const hay = [option.label, typeof option.description === "string" ? option.description : "", ...(option.keywords ?? [])].join(" ");
  return fold(hay).includes(q);
}

/** Start/end (in characters) of the query inside the label, or null. */
export function matchRange(label: string, query: string): [number, number] | null {
  const q = fold(query.trim());
  if (!q) return null;
  const folded = Array.from(label).map(foldChar);
  if (folded.some((f) => f.length !== 1)) return null;
  const at = folded.join("").indexOf(q);
  return at < 0 ? null : [at, at + q.length];
}

/** Label with the matched part in volt. */
export function HighlightMatch({ text, query }: { text: string; query?: string }) {
  const range = query ? matchRange(text, query) : null;
  if (!range) return <>{text}</>;
  const chars = Array.from(text);
  return (
    <>
      {chars.slice(0, range[0]).join("")}
      <mark className="rounded-[3px] bg-transparent font-semibold text-volt-ink">{chars.slice(range[0], range[1]).join("")}</mark>
      {chars.slice(range[1]).join("")}
    </>
  );
}

/** DOM id of an option, safe for aria-activedescendant. */
export function optionId(listId: string, value: string) {
  return `${listId}-o-${value.replace(/[^a-zA-Z0-9_-]/g, (c) => `_${c.charCodeAt(0).toString(36)}`)}`;
}

/** Strip characters React's useId adds that some selectors dislike. */
export function useSafeId(prefix: string) {
  const raw = React.useId();
  return `${prefix}${raw.replace(/[^a-zA-Z0-9_-]/g, "")}`;
}

const noopSubscribe = () => () => {};

/** True on Apple platforms (server renders the ⌘ form, the client corrects after hydration). */
export function useIsMac() {
  return React.useSyncExternalStore(
    noopSubscribe,
    () => /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent),
    () => true,
  );
}

/* ------------------------------------------------------------------ */
/* Visual pieces                                                       */
/* ------------------------------------------------------------------ */

export const listboxRowSize: Record<ListboxSize, string> = {
  sm: "min-h-8 px-2 py-1.5 gap-2 text-[13px] rounded-[8px]",
  md: "min-h-9 px-2.5 py-2 gap-2.5 text-sm rounded-[10px]",
  lg: "min-h-11 px-3 py-2.5 gap-3 text-[15px] rounded-xs",
};
export const listboxIconSize: Record<ListboxSize, string> = { sm: "size-3.5", md: "size-4", lg: "size-[18px]" };
const avatarSize: Record<ListboxSize, string> = { sm: "size-5 text-[9px]", md: "size-6 text-[10px]", lg: "size-7 text-[11px]" };
/** List height cap per size, px. */
export const listboxMaxHeight: Record<ListboxSize, number> = { sm: 224, md: 288, lg: 336 };

const avatarTones = [
  "bg-volt-soft text-volt-ink",
  "bg-ember-soft text-ember-ink",
  "bg-info-soft text-info",
  "bg-success-soft text-success",
  "bg-warning-soft text-warning",
];
const dotTones: Record<ListboxTone, string> = {
  success: "bg-success shadow-[0_0_0_3px_var(--success-soft)]",
  warning: "bg-warning shadow-[0_0_0_3px_var(--warning-soft)]",
  danger: "bg-danger shadow-[0_0_0_3px_var(--danger-soft)]",
  info: "bg-info shadow-[0_0_0_3px_var(--info-soft)]",
  neutral: "bg-ink-subtle shadow-[0_0_0_3px_var(--surface-hover)]",
};

/** Up to two initials from a name. */
export function initials(name: string) {
  const words = name.trim().split(/\s+/).filter(Boolean);
  if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase();
  return (words[0] ?? "").slice(0, 2).toUpperCase();
}

function toneFor(seed: string) {
  let h = 0;
  for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
  return avatarTones[h % avatarTones.length];
}

/** Leading avatar, dot or icon for an option (used in rows, triggers and chips). */
export function OptionMedia({ option, size = "md", className }: { option: ListboxOption; size?: ListboxSize; className?: string }) {
  if (option.avatar) {
    const name = typeof option.avatar === "string" ? option.avatar : option.label;
    return (
      <span aria-hidden className={cn("grid shrink-0 place-items-center rounded-full font-semibold tracking-wide", avatarSize[size], toneFor(name), className)}>
        {initials(name)}
      </span>
    );
  }
  if (option.dot) {
    return (
      <span aria-hidden className={cn("grid shrink-0 place-items-center", listboxIconSize[size], className)}>
        <span className={cn("size-2 rounded-full", dotTones[option.dot])} />
      </span>
    );
  }
  if (option.icon) {
    const Icon = option.icon;
    return (
      <span aria-hidden className={cn("inline-flex shrink-0 text-ink-subtle", className)}>
        <Icon className={listboxIconSize[size]} />
      </span>
    );
  }
  return null;
}

/** Small key-hint chip. */
export function Kbd({ children, className }: { children: React.ReactNode; className?: string }) {
  return (
    <kbd
      className={cn(
        "inline-flex h-5 min-w-5 items-center justify-center rounded-[5px] border border-border bg-surface px-1 font-mono text-[11px] font-medium leading-none text-ink-subtle",
        className,
      )}
    >
      {children}
    </kbd>
  );
}

function CheckGlyph({ className }: { className?: string }) {
  const reduce = useReducedMotion();
  return (
    <svg aria-hidden viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" className={className}>
      <motion.path
        d="M3.5 8.5l3 3 6-7"
        initial={reduce ? false : { pathLength: 0 }}
        animate={{ pathLength: 1 }}
        transition={{ type: "spring", stiffness: 420, damping: 30 }}
      />
    </svg>
  );
}

const boxSize: Record<ListboxSize, string> = { sm: "size-3.5 rounded-[4px]", md: "size-4 rounded-[5px]", lg: "size-[18px] rounded-[5px]" };

/** Checkbox look-alike for multi-select rows (not an input: the row is the option). */
export function CheckboxGlyph({ checked, size = "md" }: { checked: boolean; size?: ListboxSize }) {
  const reduce = useReducedMotion();
  return (
    <span
      aria-hidden
      className={cn(
        "relative grid shrink-0 place-items-center border transition-[background-color,border-color] duration-150",
        boxSize[size],
        checked ? "border-volt bg-volt text-on-volt" : "border-border-strong bg-surface-sunken",
      )}
    >
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" className="size-[80%]">
        <motion.path
          d="M3.5 8.5l3 3 6-7"
          initial={false}
          animate={{ pathLength: checked ? 1 : 0, opacity: checked ? 1 : 0 }}
          transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 30 }}
        />
      </svg>
    </span>
  );
}

/* ------------------------------------------------------------------ */
/* Keyboard navigation                                                 */
/* ------------------------------------------------------------------ */

export interface ListboxNavigation {
  /** Value of the highlighted option. */
  active: string | null;
  setActive: (value: string | null) => void;
  /** Move by `delta` enabled options (clamped, no wrap). Returns the new value. */
  step: (delta: number) => string | null;
  /** Jump to the first or last enabled option. */
  edge: (where: "first" | "last") => string | null;
  /** Feed one printed character; matches labels by prefix (repeat a letter to cycle). */
  typeahead: (char: string, timeStamp: number) => string | null;
  enabled: ListboxOption[];
}

/** Active-option state and APG movement helpers for a flat option list. */
export function useListboxNavigation(options: readonly ListboxOption[], { autoActivateFirst = false }: { autoActivateFirst?: boolean } = {}): ListboxNavigation {
  const [raw, setActive] = React.useState<string | null>(null);
  const enabled = React.useMemo(() => options.filter((o) => !o.disabled), [options]);
  const active = raw !== null && enabled.some((o) => o.value === raw) ? raw : autoActivateFirst ? (enabled[0]?.value ?? null) : null;
  const buffer = React.useRef({ text: "", at: 0 });

  const step = (delta: number) => {
    if (!enabled.length) return null;
    const i = enabled.findIndex((o) => o.value === active);
    const next = i === -1 ? (delta > 0 ? enabled[0] : enabled[enabled.length - 1]) : enabled[Math.max(0, Math.min(enabled.length - 1, i + delta))];
    setActive(next.value);
    return next.value;
  };
  const edge = (where: "first" | "last") => {
    const next = where === "first" ? enabled[0] : enabled[enabled.length - 1];
    setActive(next?.value ?? null);
    return next?.value ?? null;
  };
  const typeahead = (char: string, timeStamp: number) => {
    const b = buffer.current;
    b.text = timeStamp - b.at > 600 ? char : b.text + char;
    b.at = timeStamp;
    const q = fold(b.text);
    const start = enabled.findIndex((o) => o.value === active);
    const from = q.length > 1 ? Math.max(0, start) : start + 1;
    const ordered = [...enabled.slice(from), ...enabled.slice(0, from)];
    let match = ordered.find((o) => fold(o.label).startsWith(q));
    if (!match && Array.from(q).every((c) => c === q[0])) match = ordered.find((o) => fold(o.label).startsWith(q[0]));
    if (match) setActive(match.value);
    return match?.value ?? null;
  };
  return { active, setActive, step, edge, typeahead, enabled };
}

/** True for a key that types one visible character (no modifiers). */
export function isPrintableKey(e: React.KeyboardEvent) {
  return e.key.length === 1 && e.key !== " " && !e.ctrlKey && !e.metaKey && !e.altKey;
}

/* ------------------------------------------------------------------ */
/* Option list                                                         */
/* ------------------------------------------------------------------ */

export interface ListboxProps {
  id: string;
  entries: readonly ListboxEntry[];
  selected: readonly string[];
  active: string | null;
  onActiveChange: (value: string) => void;
  onPick: (option: ListboxOption) => void;
  multiple?: boolean;
  /** Selection mark: a drawn check on the right, a checkbox on the left, or none. */
  indicator?: "check" | "checkbox" | "none";
  size?: ListboxSize;
  /** Highlight this text inside labels. */
  query?: string;
  "aria-label"?: string;
  "aria-labelledby"?: string;
  /** Rendered (outside the listbox role) when there are no options: empty or loading state. */
  empty?: React.ReactNode;
  busy?: boolean;
  maxHeight?: number;
  className?: string;
}

/** Option list with group headings, separators, media slots and one volt highlight that glides between rows. */
export function Listbox({
  id,
  entries,
  selected,
  active,
  onActiveChange,
  onPick,
  multiple,
  indicator = "check",
  size = "md",
  query,
  empty,
  busy,
  maxHeight,
  className,
  ...aria
}: ListboxProps) {
  const reduce = useReducedMotion();
  const picked = React.useMemo(() => new Set(selected), [selected]);
  const hasOptions = flattenEntries(entries).length > 0;

  // Keep the active row in view (keyboard moves, filtering, first open).
  React.useEffect(() => {
    if (active) document.getElementById(optionId(id, active))?.scrollIntoView({ block: "nearest" });
  }, [id, active]);

  const renderOption = (o: ListboxOption) => {
    const isSelected = picked.has(o.value);
    const isActive = active === o.value;
    return (
      <div
        key={o.value}
        id={optionId(id, o.value)}
        role="option"
        aria-selected={isSelected}
        aria-disabled={o.disabled || undefined}
        data-active={isActive || undefined}
        onPointerMove={() => {
          if (!o.disabled && !isActive) onActiveChange(o.value);
        }}
        onMouseDown={(e) => e.preventDefault()}
        onClick={() => {
          if (!o.disabled) onPick(o);
        }}
        className={cn(
          "relative flex cursor-pointer select-none items-center text-ink-muted transition-colors duration-100",
          listboxRowSize[size],
          isActive && "text-ink",
          isSelected && "text-ink",
          o.disabled && "cursor-not-allowed opacity-45",
        )}
      >
        {isActive && (
          <motion.span
            aria-hidden
            layoutId={`${id}-highlight`}
            transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 40, mass: 0.7 }}
            className="absolute inset-0 rounded-[inherit] bg-volt-soft"
          />
        )}
        {indicator === "checkbox" && (
          <span className="relative flex">
            <CheckboxGlyph checked={isSelected} size={size} />
          </span>
        )}
        <OptionMedia option={o} size={size} className="relative" />
        <span className="relative grid min-w-0 flex-1">
          <span className={cn("truncate", isSelected && "font-medium")}>
            <HighlightMatch text={o.label} query={query} />
          </span>
          {o.description && <span className="truncate text-xs leading-4 text-ink-subtle">{o.description}</span>}
        </span>
        {o.meta && <span className="relative shrink-0 text-xs tabular-nums text-ink-subtle">{o.meta}</span>}
        {o.shortcut && <Kbd className="relative">{o.shortcut}</Kbd>}
        {indicator === "check" && (
          <span className={cn("relative grid shrink-0 place-items-center text-volt-ink", listboxIconSize[size])}>
            {isSelected && <CheckGlyph className="size-full" />}
          </span>
        )}
      </div>
    );
  };

  let blocks = 0;
  return (
    <>
      <motion.div
        layoutScroll
        data-lb-scroll=""
        id={id}
        role="listbox"
        aria-multiselectable={multiple || undefined}
        aria-busy={busy || undefined}
        tabIndex={-1}
        style={{ maxHeight: maxHeight ?? listboxMaxHeight[size] }}
        className={cn("min-h-0 overflow-y-auto overscroll-contain outline-none", hasOptions && "p-1", className)}
        {...aria}
      >
        {entries.map((e, i) => {
          if (isListboxSeparator(e)) return <div key={`sep-${i}`} aria-hidden className="-mx-1 my-1 h-px bg-border" />;
          const first = blocks++ === 0;
          if (!isListboxGroup(e)) return renderOption(e);
          const headingId = `${id}-g${i}`;
          const prev = entries[i - 1];
          return (
            <div
              key={`grp-${i}`}
              role="group"
              aria-labelledby={e.heading ? headingId : undefined}
              className={cn(!first && prev && !isListboxSeparator(prev) && "-mx-1 mt-1 border-t border-border px-1 pt-1")}
            >
              {e.heading && (
                <div id={headingId} role="presentation" className="px-2.5 pb-1 pt-2 text-[11px] font-medium uppercase tracking-[0.08em] text-ink-subtle">
                  {e.heading}
                </div>
              )}
              {e.options.map(renderOption)}
            </div>
          );
        })}
      </motion.div>
      {!hasOptions && empty}
    </>
  );
}

/* ------------------------------------------------------------------ */
/* Popover panel                                                       */
/* ------------------------------------------------------------------ */

export interface ListboxPopoverProps {
  open: boolean;
  /** Element the panel lines up with (usually the field well). */
  anchorRef: React.RefObject<HTMLElement | null>;
  /** Called on outside press or when focus leaves both anchor and panel. */
  onDismiss: () => void;
  children: React.ReactNode;
  className?: string;
  /** Gap to the anchor, px. @default 6 */
  sideOffset?: number;
  /** Minimum panel width; otherwise it matches the anchor. @default 220 */
  minWidth?: number;
}

type Pos = { left: number; top?: number; bottom?: number; width: number; avail: number; side: "top" | "bottom"; theme: string | null };

const EDGE = 8;
const r2 = (n: number) => Math.round(n * 100) / 100;

function measure(anchor: HTMLElement, panel: HTMLElement, offset: number, minWidth: number): Pos {
  const r = anchor.getBoundingClientRect();
  const vw = window.innerWidth;
  const vh = window.innerHeight;
  const list = panel.querySelector<HTMLElement>("[data-lb-scroll]");
  const chrome = panel.offsetHeight - (list?.clientHeight ?? 0);
  const listMax = list ? parseFloat(getComputedStyle(list).maxHeight) : NaN;
  const natural = list ? chrome + Math.min(list.scrollHeight, Number.isFinite(listMax) ? listMax : list.scrollHeight) : panel.scrollHeight;
  const below = vh - r.bottom - offset - EDGE;
  const above = r.top - offset - EDGE;
  const side = natural > below && above > below ? "top" : "bottom";
  const width = Math.min(Math.max(r.width, minWidth), vw - EDGE * 2);
  const left = Math.max(EDGE, Math.min(r.left, vw - EDGE - width));
  return {
    side,
    left: r2(left),
    width: r2(width),
    avail: Math.max(140, r2(side === "bottom" ? below : above)),
    top: side === "bottom" ? r2(r.bottom + offset) : undefined,
    bottom: side === "top" ? r2(vh - r.top + offset) : undefined,
    theme: anchor.closest("[data-theme]")?.getAttribute("data-theme") ?? null,
  };
}

const samePos = (a: Pos | null, b: Pos) =>
  !!a && a.left === b.left && a.top === b.top && a.bottom === b.bottom && a.width === b.width && a.avail === b.avail && a.side === b.side && a.theme === b.theme;

function Panel({ anchorRef, onDismiss, children, className, sideOffset = 6, minWidth = 220 }: Omit<ListboxPopoverProps, "open">) {
  const reduce = useReducedMotion();
  const ref = React.useRef<HTMLDivElement>(null);
  const [pos, setPos] = React.useState<Pos | null>(null);
  const dismiss = React.useRef(onDismiss);
  React.useEffect(() => {
    dismiss.current = onDismiss;
  });

  React.useLayoutEffect(() => {
    const panel = ref.current;
    const anchor = anchorRef.current;
    if (!panel || !anchor) return;
    const update = () => {
      const next = measure(anchor, panel, sideOffset, minWidth);
      setPos((prev) => (samePos(prev, next) ? prev : next));
    };
    update();
    const ro = new ResizeObserver(update);
    ro.observe(panel);
    ro.observe(anchor);
    const list = panel.querySelector("[data-lb-scroll]");
    const mo = new MutationObserver(update);
    if (list) mo.observe(list, { childList: true, subtree: true });
    window.addEventListener("scroll", update, true);
    window.addEventListener("resize", update);
    return () => {
      ro.disconnect();
      mo.disconnect();
      window.removeEventListener("scroll", update, true);
      window.removeEventListener("resize", update);
    };
  }, [anchorRef, sideOffset, minWidth]);

  React.useEffect(() => {
    const inside = (t: EventTarget | null) => t instanceof Node && (!!ref.current?.contains(t) || !!anchorRef.current?.contains(t));
    const onDown = (e: PointerEvent) => {
      if (!inside(e.target)) dismiss.current();
    };
    const onFocus = (e: FocusEvent) => {
      if (!inside(e.target)) dismiss.current();
    };
    document.addEventListener("pointerdown", onDown, true);
    document.addEventListener("focusin", onFocus);
    return () => {
      document.removeEventListener("pointerdown", onDown, true);
      document.removeEventListener("focusin", onFocus);
    };
  }, [anchorRef]);

  const side = pos?.side ?? "bottom";
  const hidden = { opacity: 0, scale: reduce ? 1 : 0.96, y: reduce ? 0 : side === "bottom" ? -6 : 6, filter: reduce ? "blur(0px)" : "blur(6px)" };
  return (
    <motion.div
      ref={ref}
      data-theme={pos?.theme ?? undefined}
      initial={hidden}
      animate={pos ? { opacity: 1, scale: 1, y: 0, filter: "blur(0px)" } : hidden}
      exit={{ ...hidden, transition: { duration: 0.12 } }}
      transition={{ type: "spring", stiffness: 380, damping: 30, opacity: { duration: 0.14 } }}
      style={{
        position: "fixed",
        left: pos?.left ?? 0,
        top: pos ? pos.top : 0,
        bottom: pos?.bottom,
        width: pos?.width ?? minWidth,
        maxHeight: pos?.avail,
        transformOrigin: side === "bottom" ? "50% 0%" : "50% 100%",
        pointerEvents: pos ? undefined : "none",
      }}
      className={cn("z-[90] flex flex-col overflow-hidden rounded-md border border-border bg-surface-raised text-ink shadow-lg", className)}
    >
      {children}
    </motion.div>
  );
}

/** Portalled panel that lines up with its anchor, springs open, flips above when there is no room below and repositions on scroll. */
export function ListboxPopover({ open, ...props }: ListboxPopoverProps) {
  const mounted = React.useSyncExternalStore(noopSubscribe, () => true, () => false);
  if (!mounted) return null;
  return createPortal(<AnimatePresence>{open && <Panel key="panel" {...props} />}</AnimatePresence>, document.body);
}

```
