v1.0

MultiSelect

Preview

Basic

Loading…

Preview

Code

ts
import MultiSelect from '@/components/form/MultiSelect';

src/components/form/MultiSelect.tsx

AI prompt

text
Build a searchable checkbox multi-select dropdown with removable chips in React + TypeScript + Tailwind CSS.

## Look
- Trigger: a button with the house input recipe, `flex items-center gap-2 text-left min-h-[34px]`. Left, a wrapping chip area (`flex-1 flex flex-wrap gap-1 min-w-0`) — the placeholder in slate-400 when empty — so the current selection is readable without opening. Right, a 14px ChevronDown in slate-400 that rotates 180° while open.
- Chip: `inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-indigo-50 text-indigo-700`, dark `bg-indigo-500/15 text-indigo-300`, ending in a 10px X (hover indigo-900 / dark indigo-100) that removes it without opening the panel (stop propagation). A disabled option's chip has no X.
- Panel: absolute `z-50 mt-1 w-full`, opaque floating surface, `p-2 max-h-72 overflow-y-auto` with a thin scrollbar.
  - A filter input at the top ONLY when searchable and there are more than 6 options: 12px Search icon at `left-2.5`, input `pl-7 py-1.5`, placeholder "Filter…", `mb-2`.
  - Options `space-y-1.5`, each a checkbox row with `px-1`: a 16px square (`rounded border-2`, unchecked white / slate-300 border, dark slate-800 / slate-600; checked indigo-600 fill with a white 12px Check), label `text-xs font-medium text-slate-700 dark:text-slate-200`, and an optional ⓘ hint tooltip beside the label.
  - No matches: "No options" (11px slate-400).

## Behaviour
- Clicking an option toggles it; the panel stays open for more picks. Filter is a case-insensitive substring on the label.
- A `disabled` option stays VISIBLE (at 50% opacity, not toggleable, chip not removable) rather than being filtered out — "exists, not yours to give", e.g. a role above the actor's rank.
- Values may be numbers or strings (`type OptionValue = string | number`), so numeric ids and string slugs both work.

## API
`options: { value: OptionValue; label: string; hint?: string; disabled?: boolean }[]`, `selected: OptionValue[]`, `onChange(next: OptionValue[])`, `placeholder = 'Select…'`, `searchable = true`, `emptyLabel = 'No options'`. Export the `Option` and `OptionValue` types.

## Demo
"Select teams" over Engineering, Research, Design, Support, Operations — one instance keyed by numeric ids (seeded [1, 3]) and one by lowercase slugs (seeded ['engineering']), each with the current value echoed below.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useMemo, useRef, useState } from 'react';
import { ChevronDown, Search, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';
import Checkbox from './Checkbox';

/**
 * MERGED from two copies. This is bonus-adjustment's `MultiSelect`, which keys
 * on numbers; marketing-stats' `MultiSelectDropdown` keyed on strings, and each
 * was unusable for the other's data. The value type is now `OptionValue`, so a
 * numeric id and a string slug both work and neither call site had to change
 * shape to migrate.
 */
export type OptionValue = string | number;

export type Option = {
  value: OptionValue;
  label: string;
  hint?: string;
  /** Rendered but not selectable — used for roles above the actor's rank. */
  disabled?: boolean;
};

/**
 * Checkbox dropdown for assigning a set of things (roles to a user, permissions
 * to a role). Selected values show as removable chips on the closed control, so
 * the current selection is readable without opening it.
 *
 * A disabled option stays VISIBLE rather than being filtered out — a role the
 * actor may not grant should be legible as "exists, not yours to give", which is
 * the same rank rule the server enforces.
 */
export default function MultiSelect({
  options,
  selected,
  onChange,
  placeholder = 'Select…',
  searchable = true,
  emptyLabel = 'No options',
}: {
  options: Option[];
  selected: OptionValue[];
  onChange: (next: OptionValue[]) => void;
  placeholder?: string;
  searchable?: boolean;
  emptyLabel?: string;
}) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState('');
  const ref = useRef<HTMLDivElement>(null);

  useDismiss(ref, open, () => setOpen(false));

  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    return q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options;
  }, [options, query]);

  const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);

  const toggle = (value: OptionValue) => {
    const option = byValue.get(value);
    if (option?.disabled) return;
    onChange(selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value]);
  };

  return (
    <div className="relative" ref={ref}>
      <button
        type="button"
        onClick={() => setOpen((o) => !o)}
        className="field-input flex items-center gap-2 text-left min-h-[34px]"
      >
        <span className="flex-1 flex flex-wrap gap-1 min-w-0">
          {selected.length === 0 && <span className="text-slate-400">{placeholder}</span>}
          {selected.map((value) => {
            const option = byValue.get(value);
            if (!option) return null;
            return (
              <span
                key={value}
                className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-indigo-50 dark:bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 text-[10px] font-semibold"
              >
                {option.label}
                {!option.disabled && (
                  <X
                    className="w-2.5 h-2.5 hover:text-indigo-900 dark:hover:text-indigo-100"
                    onClick={(e) => {
                      e.stopPropagation();
                      toggle(value);
                    }}
                  />
                )}
              </span>
            );
          })}
        </span>
        <ChevronDown
          className={cn('w-3.5 h-3.5 text-slate-400 shrink-0 transition-transform', open && 'rotate-180')}
        />
      </button>

      {open && (
        <div data-overlay="picker" className="absolute z-50 mt-1 w-full panel panel-solid p-2 max-h-72 overflow-y-auto custom-scrollbar">
          {searchable && options.length > 6 && (
            <div className="relative mb-2">
              <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-400" />
              <input
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Filter…"
                className="field-input pl-7 py-1.5"
              />
            </div>
          )}

          {filtered.length === 0 && (
            <p className="px-1 py-2 text-[11px] text-slate-400">{emptyLabel}</p>
          )}

          <div className="space-y-1.5">
            {filtered.map((option) => (
              <Checkbox
                key={option.value}
                id={`ms-${option.value}`}
                checked={selected.includes(option.value)}
                disabled={option.disabled}
                onChange={() => toggle(option.value)}
                label={option.label}
                hint={option.hint}
                className="px-1"
              />
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
options*Option[]—
selected*OptionValue[]—
onChange*(next: OptionValue[]) => void—
placeholderstring'Select…'
searchablebooleantrue
emptyLabelstring'No options'