v1.0

AutocompleteDropdown

Preview

Basic

Loading…

Preview

Code

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

src/components/form/AutocompleteDropdown.tsx

AI prompt

text
Build a single-select autocomplete combobox (type-ahead, optional option groups) component in React + TypeScript + Tailwind CSS.

## Look
- ONE text input is the whole trigger (house input recipe, `pr-12`): closed it shows the selected option's label (or the placeholder); open it is the search box, emptied, with `searchPlaceholder`. Disabled: `opacity-60 cursor-not-allowed`.
- Right side (`absolute inset-y-0 right-0 pr-2 flex items-center`): a clear X button (14px, `p-1`, slate-400 → hover slate-600 / dark slate-300), only while a value is set; then a 14px lucide ChevronsUpDown in slate-400.
- Panel: absolute `z-50 w-full`, opaque floating surface, `p-1 text-xs`, max-height 15rem scrolling with a thin scrollbar, 200ms fade-in. Opens below (`mt-1`) or, when fewer than 280px of viewport remain under the input at focus time, above (`bottom-full mb-1`).
- Options (`space-y-0.5`): `relative py-1.5 pl-7 pr-3 rounded-lg cursor-pointer select-none transition-colors`, label truncating. The highlighted row is `bg-indigo-600 text-white`; others slate-700 / dark slate-200. The selected option has a 14px Check at `left-2`, vertically centred (it inherits the text colour, so white on the highlight).
- Groups: if any option carries `group`, render groups in first-appearance order under STICKY headings (`sticky top-0 z-10 bg-white dark:bg-slate-800 px-2 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500`); otherwise a flat list.
- No matches for a non-empty query: `emptyText` in 11px slate-400, `px-3 py-2`.

## Behaviour
- Focus opens the panel, clears the query, and highlights the currently selected option (else the first) — so Enter is a no-op instead of silently reassigning the field.
- Filter: case-insensitive substring on the label OR the group, so typing a group name narrows to that group.
- Typing resets the highlight to the first match. ArrowUp/ArrowDown wrap, Home/End jump, the highlighted row scrolls into view (`block: 'nearest'`). Keyboard order is RENDER order (grouped). Hover moves the highlight too, so mouse and keyboard always agree on what Enter will pick.
- Enter commits the highlighted option with `preventDefault`, so it never submits a surrounding `<form>`. Arrow keys or Enter on a focused, closed input reopen it.
- Picking the already-selected option deselects it (`onChange(null)`). Picking closes and clears the query. Escape closes and blurs; Tab closes without committing.

## API
`options: { value: string; label: string; group?: string }[]`, `value: string | null`, `onChange(value: string | null)`, `placeholder`, `searchPlaceholder`, `emptyText` (all required strings), `className?`, `disabled = false`. Named export `AutocompleteDropdown`.

## Accessibility
Input `role="combobox"`, `aria-haspopup="listbox"`, `aria-expanded`, `autoComplete="off"`; list `role="listbox"`; rows `role="option"` with `aria-selected` on the chosen one; clear button "Clear selection".

## Demo
"Select a person" over people grouped by team — Engineering: Ada Lovelace, Grace Hopper, Barbara Liskov; Research: Alan Turing, Katherine J., Edsger D.; Design: Alan Kay, Margaret H. — search placeholder "Search people…", empty text "No one matches", selected 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';

/* Origin: marketing-stats (96S1), verbatim. */

import * as React from 'react';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import { useDismiss } from '@/lib/use-dismiss';

interface AutocompleteDropdownOption {
  value: string;
  label: string;
  /**
   * Optional heading this option sits under. When any option carries one, the list
   * renders as sticky group headings with their options indented beneath, instead of a
   * flat list — useful when the label alone is ambiguous (two agents can share a name
   * across brands) or when the list is long enough that a flat scroll is hard to scan.
   *
   * Purely presentational: `value` is still what's selected, and search still matches on
   * `label` AND `group`, so typing a group name narrows to that group's options.
   */
  group?: string;
}

interface AutocompleteDropdownProps {
  options: AutocompleteDropdownOption[];
  value: string | null;
  onChange: (value: string | null) => void;
  placeholder: string;
  searchPlaceholder: string;
  emptyText: string;
  className?: string;
  disabled?: boolean;
}

export function AutocompleteDropdown({ options, value, onChange, placeholder, searchPlaceholder, emptyText, className = '', disabled = false }: AutocompleteDropdownProps) {
  const [open, setOpen] = React.useState(false);
  const [query, setQuery] = React.useState('');
  const [position, setPosition] = React.useState<'top' | 'bottom'>('bottom');
  
  const filteredOptions =
    query === ''
      ? options
      // Group is searchable too — otherwise grouping would hide the narrowing the
      // caller previously got from a separate group-level picker.
      : options.filter(option =>
          option.label.toLowerCase().includes(query.toLowerCase())
          || (option.group ?? '').toLowerCase().includes(query.toLowerCase())
        );

  // Grouped rendering kicks in only when options actually carry groups, so every
  // existing caller keeps its flat list untouched. Insertion order is preserved so the
  // caller controls group order by sorting its options.
  const isGrouped = filteredOptions.some(option => option.group);
  const groupedOptions = React.useMemo(() => {
    const map = new Map<string, AutocompleteDropdownOption[]>();
    for (const option of filteredOptions) {
      const key = option.group ?? '';
      const list = map.get(key) ?? [];
      list.push(option);
      map.set(key, list);
    }
    return [...map.entries()];
  }, [filteredOptions]);

  // Flat list in RENDER order (grouped or not) — the sequence arrow keys walk, so
  // keyboard order always matches what the eye sees.
  const flatOptions = React.useMemo(
    () => (isGrouped ? groupedOptions.flatMap(([, groupOptions]) => groupOptions) : filteredOptions),
    [isGrouped, groupedOptions, filteredOptions]
  );

  // Index into flatOptions of the keyboard-highlighted row. Hovering syncs it, so
  // mouse and keyboard can't disagree about what Enter would pick.
  const [activeIndex, setActiveIndex] = React.useState(0);

  const selectedLabel = options.find(option => option.value === value)?.label;

  const containerRef = React.useRef<HTMLDivElement>(null);
  const dropdownRef = React.useRef<HTMLInputElement>(null);
  const listRef = React.useRef<HTMLDivElement>(null);

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

  // Typing re-filters the list, so the highlight goes back to the best (first)
  // match — the usual type-ahead behaviour.
  React.useEffect(() => { setActiveIndex(0); }, [query]);

  // Keep the highlighted row in view when arrowing past the visible window.
  React.useEffect(() => {
    if (!open) return;
    listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' });
  }, [activeIndex, open]);

  // Focusing the field itself opens the dropdown and starts a fresh search —
  // there's no separate nested search input anymore, this one field shows
  // the selected label while closed and doubles as the filter text while open.
  const handleFocus = () => {
    if (dropdownRef.current) {
      const rect = dropdownRef.current.getBoundingClientRect();
      const spaceBelow = window.innerHeight - rect.bottom;
      // Estimate dropdown height (max-h-60 is 15rem/240px + padding/input)
      setPosition(spaceBelow < 280 ? 'top' : 'bottom');
    }
    setQuery('');
    // Opening with a selection already made starts the highlight on that row
    // rather than the top of the list, so Enter is a no-op instead of silently
    // reassigning the field to whatever happens to sort first.
    const selectedIndex = options.findIndex(option => option.value === value);
    setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
    setOpen(true);
  };

  const commitSelection = (option: AutocompleteDropdownOption) => {
    onChange(option.value === value ? null : option.value);
    setOpen(false);
    setQuery('');
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Escape') {
      setOpen(false);
      e.currentTarget.blur();
      return;
    }
    // Tab commits nothing and just lets focus leave — closing here stops an
    // orphaned dropdown hanging over the next field.
    if (e.key === 'Tab') {
      setOpen(false);
      return;
    }
    if (!open) {
      // Arrow/Enter on a closed, focused field reopens it rather than doing nothing.
      if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
        e.preventDefault();
        handleFocus();
      }
      return;
    }
    if (flatOptions.length === 0) return;

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex(i => (i + 1) % flatOptions.length);
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex(i => (i - 1 + flatOptions.length) % flatOptions.length);
        break;
      case 'Home':
        e.preventDefault();
        setActiveIndex(0);
        break;
      case 'End':
        e.preventDefault();
        setActiveIndex(flatOptions.length - 1);
        break;
      case 'Enter': {
        // preventDefault matters beyond the dropdown: these fields sit inside
        // <form> panels (BalanceForm, PaymentForm, …), where a bare Enter would
        // submit the form instead of picking the highlighted option.
        e.preventDefault();
        const option = flatOptions[activeIndex];
        if (option) commitSelection(option);
        break;
      }
    }
  };

  return (
    <div ref={containerRef} className={`relative ${className}`}>
      <input
        ref={dropdownRef}
        type="text"
        role="combobox"
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-label="open menu"
        value={open ? query : (selectedLabel ?? '')}
        onChange={(e) => setQuery(e.target.value)}
        onFocus={handleFocus}
        onKeyDown={handleKeyDown}
        placeholder={open ? searchPlaceholder : placeholder}
        autoComplete="off"
        className={`field-input pr-12 ${disabled ? 'cursor-not-allowed opacity-60' : ''}`}
        disabled={disabled}
      />
      <div className="absolute inset-y-0 right-0 flex items-center pr-2">
        {value && (
          <button
            type="button"
            onClick={(e) => {
              e.stopPropagation();
              onChange(null);
            }}
            className="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
            aria-label="Clear selection"
          >
            <X className="h-3.5 w-3.5" />
          </button>
        )}
        <ChevronsUpDown className="h-3.5 w-3.5 text-slate-400" aria-hidden="true" />
      </div>

      {open && (
        <div data-overlay="picker" className={`absolute z-50 w-full overflow-auto panel panel-solid p-1 text-xs animate-fade-in custom-scrollbar ${position === 'bottom' ? 'mt-1' : 'bottom-full mb-1'}`}
        style={{ maxHeight: '15rem' }} // Equivalent to max-h-60
        >
          {filteredOptions.length === 0 && query !== '' ? (
            <div className="px-3 py-2 text-[11px] text-slate-400">{emptyText}</div>
          ) : (
            <div ref={listRef} role="listbox" className="space-y-0.5">
              {(isGrouped ? groupedOptions : [['', filteredOptions] as const]).map(([group, groupOptions]) => (
                <div key={group || '__ungrouped'}>
                  {group && (
                    // Sticky so the heading stays visible while scrolling a long group.
                    <div className="sticky top-0 z-10 bg-white dark:bg-slate-800 px-2 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
                      {group}
                    </div>
                  )}
                  {groupOptions.map(option => {
                    const isActive = flatOptions[activeIndex]?.value === option.value;
                    return (
                      <div
                        key={option.value}
                        role="option"
                        aria-selected={value === option.value}
                        data-active={isActive || undefined}
                        // Hover moves the highlight, so the mouse and the keyboard
                        // always agree on what Enter would commit.
                        onMouseEnter={() => setActiveIndex(flatOptions.findIndex(o => o.value === option.value))}
                        onClick={() => commitSelection(option)}
                        className={`relative cursor-pointer select-none py-1.5 pl-7 pr-3 rounded-lg transition-colors ${
                          isActive ? 'bg-indigo-600 text-white' : 'text-slate-700 dark:text-slate-200'
                        }`}
                      >
                        <span className="block truncate">{option.label}</span>
                        {value === option.value && <Check className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5" />}
                      </div>
                    );
                  })}
                </div>
              ))}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
options*AutocompleteDropdownOption[]—
value*string | null—
onChange*(value: string | null) => void—
placeholder*string—
searchPlaceholder*string—
emptyText*string—
classNamestring''
disabledbooleanfalse