Zum Inhalt springen
UI Kit
Accent

Combobox für React

Ein Textfeld, das beim Tippen filtert, Akzente ignoriert, den Treffer markiert und neue Einträge anlegen kann.

Eine von 7 Components in Picker und Auswahlfelder

1 Variant

Ein Select, in das man tippen kann

Eine Combobox ist ein Textfeld mit einer Liste, die beim Tippen filtert, das richtige Element, wenn es zu viele Optionen zum Scrollen gibt. Diese folgt dem Combobox-Muster, die Liste öffnet sich beim Tippen oder mit der Pfeiltaste, Pfeile gehen durch die Treffer, Enter wählt.

„Filtern beim Tippen“ findet Treffer an jeder Stelle des Labels und ignoriert Akzente und Groß- und Kleinschreibung, cafe findet also Café. Sie hebt den passenden Teil jeder Option hervor, nennt Screenreadern die Zahl der Ergebnisse und bietet an, einen neuen Eintrag anzulegen, wenn nichts passt, gut für Tags, Städte und Kunden, die mit der Zeit wachsen.

Nutze eine Combobox ab etwa fünfzehn Optionen. Zeig vor der ersten Taste ein paar Vorschläge, zuletzt genutzte oder beliebte Einträge helfen beim Start. Entscheide, ob neue Einträge erlaubt sind, und sag es im leeren Zustand, ein stummes Kein Treffer lässt Menschen raten.

Variants

Filtern beim Tippen

Treffer in Name und Beschreibung, der getippte Teil markiert, neue Zeilen kommen nacheinander und die Markierung gleitet mit den Pfeiltasten. Escape schließt, ein zweites Escape leert.

Details

Was Combobox mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
1
Version
1.0.1 · seit 1.0.0
Datei
components/ui/combobox.tsx
Bringt mit
utilskiticoninputselectpopover
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 5 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle ComboboxProps aus components/ui/combobox.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von FieldOwnProps an.

  • optionsPflicht
    SelectOption[]
  • valueoptional
    string
  • defaultValueoptional
    string
  • onValueChangeoptional
    (value: string) => void
  • onCreateoptional
    (text: string) => void

    Offer to create the typed text when nothing matches

  • placeholderoptional
    string
  • sizeoptional
    ControlSize
  • iconoptional
    IconName
  • nameoptional
    string
  • idoptional
    string
  • disabledoptional
    boolean
  • requiredoptional
    boolean
  • invalidoptional
    boolean
  • filteroptional
    (option: SelectOption, query: string) => boolean

    Custom matching. Default: every word of the query appears in label or description.

  • defaultQueryoptional
    string

    Open on first render with this query, for demos

  • defaultOpenoptional
    boolean

Eingebaute Texte

Diese Texte schreibt Combobox selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 5 Texte anzeigen
emptyEnglisch No match. Try another word.Deutsch Kein Treffer. Versuch ein anderes Wort.
clearEnglisch ClearDeutsch Leeren
toggleEnglisch Show optionsDeutsch Optionen zeigen
resultsEnglisch resultsDeutsch Treffer
createEnglisch CreateDeutsch Anlegen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Combobox reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter und Tab.
  • Setzt aria-activedescendant, aria-autocomplete, aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen combobox, listbox und option.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 252 Zeilen
components/ui/combobox.tsx
'use client';

import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { CONTROL_HEIGHT, controlBox, useControl, useShake, withField, type ControlSize, type FieldOwnProps } from '@/components/ui/input';
import { ActiveMarker, LIST_STYLE, ListboxGroups, revealOption, unfold, type SelectOption } from '@/components/ui/select';
import { Floating, panelSurface, useDismiss, useMounted } from '@/components/ui/popover';

const TEXTS = {
  en: { empty: 'No match. Try another word.', clear: 'Clear', toggle: 'Show options', results: 'results', create: 'Create' },
  de: { empty: 'Kein Treffer. Versuch ein anderes Wort.', clear: 'Leeren', toggle: 'Optionen zeigen', results: 'Treffer', create: 'Anlegen' },
};

export interface ComboboxProps extends FieldOwnProps {
  options: SelectOption[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Offer to create the typed text when nothing matches */
  onCreate?: (text: string) => void;
  placeholder?: string;
  size?: ControlSize;
  icon?: IconName;
  name?: string;
  id?: string;
  disabled?: boolean;
  required?: boolean;
  invalid?: boolean;
  /** Custom matching. Default: every word of the query appears in label or description. */
  filter?: (option: SelectOption, query: string) => boolean;
  /** Open on first render with this query, for demos */
  defaultQuery?: string;
  defaultOpen?: boolean;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const norm = (s: string) => s.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
const defaultFilter = (o: SelectOption, q: string) => {
  const hay = norm(`${o.label} ${o.description ?? ''}`);
  return norm(q).split(/\s+/).filter(Boolean).every((w) => hay.includes(w));
};

/** Marks the typed part in a label */
function Highlight({ text, query }: { text: string; query: string }) {
  const q = norm(query.trim());
  if (!q) return <>{text}</>;
  const i = norm(text).indexOf(q.split(/\s+/)[0]);
  if (i < 0) return <>{text}</>;
  const len = q.split(/\s+/)[0].length;
  return (
    <>
      {text.slice(0, i)}
      <mark className="rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-inherit">{text.slice(i, i + len)}</mark>
      {text.slice(i + len)}
    </>
  );
}

function ComboboxBox({ options, value: valueProp, defaultValue, onValueChange, onCreate, placeholder, size = 'md', icon = 'search', name, id, disabled, required, invalid, filter = defaultFilter, defaultQuery, defaultOpen, labels, className }: Omit<ComboboxProps, keyof FieldOwnProps>) {
  const t = useTexts(TEXTS, labels);
  const c = useControl({ id, invalid, disabled, required });
  const [inner, setInner] = useState(defaultValue);
  const value = valueProp ?? inner;
  const selected = options.find((o) => o.value === value);
  const [query, setQuery] = useState(defaultQuery ?? selected?.label ?? '');
  const [typing, setTyping] = useState(defaultQuery !== undefined);
  const [open, setOpen] = useState(Boolean(defaultOpen));
  const [active, setActive] = useState(0);
  const mounted = useMounted();
  const boxRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLDivElement>(null);
  const baseId = useId();
  const listId = `${baseId}-list`;

  const shown = useMemo(() => (typing && query.trim() ? options.filter((o) => filter(o, query)) : options), [options, query, typing, filter]);
  const canCreate = Boolean(onCreate && typing && query.trim() && !options.some((o) => norm(o.label) === norm(query.trim())));
  const count = shown.length + (canCreate ? 1 : 0);

  const close = useCallback(() => {
    setOpen(false);
    setTyping(false);
    setQuery((q) => (selected ? selected.label : q));
  }, [selected]);
  const refs = useRef([boxRef, listRef]).current;
  useDismiss(open, close, refs);
  useShake(boxRef, c.invalid);

  useEffect(() => {
    if (open) revealOption(active < shown.length ? `${baseId}-opt-${active}` : `${baseId}-create`);
  }, [open, active, baseId, shown.length]);

  const pick = (o: SelectOption) => {
    if (o.disabled) return;
    setInner(o.value);
    onValueChange?.(o.value);
    setQuery(o.label);
    setTyping(false);
    setOpen(false);
  };
  const create = () => {
    onCreate?.(query.trim());
    setTyping(false);
    setOpen(false);
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      e.preventDefault();
      if (!open) {
        setOpen(true);
        setActive(Math.max(0, shown.findIndex((o) => o.value === value)));
        return;
      }
      setActive((a) => (a + (e.key === 'ArrowDown' ? 1 : -1) + count) % Math.max(count, 1));
    } else if (e.key === 'Enter' && open) {
      e.preventDefault();
      if (active < shown.length && shown[active]) pick(shown[active]);
      else if (canCreate) create();
    } else if (e.key === 'Escape') {
      if (open) close();
      else if (query) {
        setQuery('');
        setTyping(true);
      }
    } else if (e.key === 'Tab' && open) close();
  };

  const activeId = open && count ? (active < shown.length ? `${baseId}-opt-${active}` : `${baseId}-create`) : undefined;

  return (
    <>
      <div ref={boxRef} data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('group/combo relative flex w-full items-center', CONTROL_HEIGHT[size], controlBox, className)}>
        <Icon name={icon} size={16} className="pointer-events-none ms-3.5 shrink-0 text-subtle-foreground transition-colors duration-300 group-focus-within/combo:text-muted-foreground" />
        <input
          ref={inputRef}
          id={c.id}
          role="combobox"
          aria-autocomplete="list"
          aria-expanded={open}
          aria-controls={open ? listId : undefined}
          aria-activedescendant={activeId}
          aria-invalid={c['aria-invalid']}
          aria-describedby={c['aria-describedby']}
          disabled={c.disabled}
          required={c.required}
          autoComplete="off"
          spellCheck={false}
          placeholder={placeholder}
          value={query}
          onChange={(e) => {
            setQuery(e.target.value);
            setTyping(true);
            setActive(0);
            setOpen(true);
          }}
          onClick={() => setOpen(true)}
          onKeyDown={onKeyDown}
          className="h-full w-0 min-w-0 flex-1 bg-transparent ps-2.5 text-inherit outline-none placeholder:text-subtle-foreground"
        />
        {query && !c.disabled && (
          <button
            type="button"
            aria-label={t.clear}
            onClick={() => {
              setQuery('');
              setTyping(true);
              setOpen(true);
              inputRef.current?.focus();
            }}
            className="relative grid size-7 place-items-center rounded-md text-subtle-foreground transition-[background-color,color,scale] duration-200 after:absolute after:-inset-2 after:content-[''] hover:bg-accent hover:text-foreground active:scale-90 starting:scale-50 starting:opacity-0 motion-reduce:transition-none"
          >
            <Icon name="x" size={14} />
          </button>
        )}
        <button
          type="button"
          tabIndex={-1}
          aria-label={t.toggle}
          onClick={() => {
            setOpen(!open);
            inputRef.current?.focus();
          }}
          className="me-1.5 grid size-7 place-items-center rounded-md text-subtle-foreground transition-colors hover:bg-accent hover:text-foreground"
        >
          <Icon name="chevrons-up-down" size={15} className={cn('transition-[scale] duration-300 ease-[var(--ease-kit)]', open && 'scale-110')} />
        </button>
      </div>
      {name && <input type="hidden" name={name} value={value ?? ''} />}
      <span aria-live="polite" className="sr-only">
        {open && typing ? `${shown.length} ${t.results}` : ''}
      </span>
      {mounted && open && (
        <Floating anchor={boxRef} side="bottom" align="start" offset={6} matchWidth ref={listRef}>
          {(pos) => (
            <div
              id={listId}
              role="listbox"
              aria-labelledby={c.id}
              className={cn('kit-scrollbar relative max-h-72 overflow-y-auto overscroll-contain p-1.5', panelSurface, unfold(pos?.side).className)}
            >
              <style dangerouslySetInnerHTML={{ __html: LIST_STYLE }} />
              <ActiveMarker id={activeId} version={`${query}|${shown.length}`} />
              {shown.length > 0 && (
                <ListboxGroups
                  options={shown}
                  baseId={`${baseId}-opt`}
                  activeIndex={active}
                  isSelected={(o) => o.value === value}
                  onPick={pick}
                  onHover={(i) => setActive(i)}
                  render={(o) => <Highlight text={o.label} query={typing ? query : ''} />}
                />
              )}
              {canCreate && (
                <div
                  id={`${baseId}-create`}
                  role="option"
                  aria-selected={false}
                  data-active={active === shown.length || undefined}
                  onPointerMove={() => setActive(shown.length)}
                  onPointerDown={(e) => e.preventDefault()}
                  onClick={create}
                  style={{ '--i': shown.length } as React.CSSProperties}
                  className="kitfx-select-row relative flex min-h-9 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-sm text-foreground max-sm:min-h-11"
                >
                  <Icon name="plus" size={16} className="text-muted-foreground" />
                  <span className="truncate">
                    {t.create} <span className="font-medium">{query.trim()}</span>
                  </span>
                </div>
              )}
              {count === 0 && <p className="px-3 py-6 text-center text-sm text-muted-foreground">{t.empty}</p>}
            </div>
          )}
        </Floating>
      )}
    </>
  );
}

/**
 * A text field that filters a list as you type. Arrows move, Enter picks, Escape closes then clears.
 * Ignores accents and word order, highlights the match, can offer to create a new entry.
 *   <Combobox label="Country" options={countries} placeholder="Search a country" />
 */
export function Combobox({ label, hint, error, optional, labelAction, className, ...rest }: ComboboxProps) {
  const wrapped = Boolean(label || hint || error);
  return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, <ComboboxBox {...rest} className={wrapped ? undefined : className} />);
}

Häufige Fragen

Wie installiere ich Combobox?

Mit aiw add @ai-website/combobox. Der Befehl legt die Datei components/ui/combobox.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, input, select und popover. Weitere npm-Pakete braucht sie nicht.

Kann ich die Texte von Combobox übersetzen?

Ja. Combobox bringt 5 eigene Texte auf Deutsch und Englisch mit, etwa „Kein Treffer. Versuch ein anderes Wort.“, „Leeren“ und „Optionen zeigen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Combobox einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Combobox mit aiw_components und installieren sie mit aiw_add @ai-website/combobox.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert