Mehrfachauswahl
Mehrere Werte wählen, mit Suche, Gruppen, Anzahlen und Grenze. Als Chips im Feld, als Filterknopf, als offene Facettenliste oder als Stapel von Personen.
Eine von 7 Components in Picker und Auswahlfelder
Variants
Details
Was Mehrfachauswahl mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Picker und Auswahlfelder
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/multi-select.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 13 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle MultiSelectProps aus components/ui/multi-select.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von FieldOwnProps an.
variantoptionalMultiSelectVariantchipspopoverliststack
optionsPflichtMultiSelectOption[]valueoptionalstring[]defaultValueoptionalstring[]onValueChangeoptional(value: string[]) => voidplaceholderoptionalstringsearchPlaceholderoptionalstringPlaceholder of the search field in popover and list
maxoptionalnumberAt most this many. Further options are disabled and a counter shows the room left.
triggerLabeloptionalstringpopover: the text on the button, e.g. "Status"
triggerIconoptionalIconNamepopover: icon on the button
searchoptionalbooleanpopover and list: a search field above the rows. Default when there are more than six options.
visibleoptionalnumberlist: rows shown before "Show all". Default 6.
sizeoptionalControlSizenameoptionalstringidoptionalstringdisabledoptionalbooleanrequiredoptionalbooleaninvalidoptionalbooleandefaultOpenoptionalbooleanOpen on first render, for demos
Eingebaute Texte
Diese Texte schreibt Mehrfachauswahl selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 13 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| placeholder | Englisch Choose… | Deutsch Auswählen… |
| search | Englisch Search | Deutsch Suchen |
| empty | Englisch No match. Try another word. | Deutsch Kein Treffer. Versuch ein anderes Wort. |
| remove | Englisch Remove | Deutsch Entfernen |
| clearAll | Englisch Clear all | Deutsch Alle entfernen |
| selectAll | Englisch Select all | Deutsch Alle wählen |
| selected | Englisch selected | Deutsch ausgewählt |
| added | Englisch added | Deutsch hinzugefügt |
| removed | Englisch removed | Deutsch entfernt |
| of | Englisch of | Deutsch von |
| more | Englisch Show all | Deutsch Alle zeigen |
| less | Englisch Show fewer | Deutsch Weniger zeigen |
| none | Englisch Nothing chosen yet | Deutsch Noch nichts ausgewählt |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Mehrfachauswahl reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pos1, Ende, Tab, Rücktaste und Leertaste.
- Setzt aria-activedescendant, aria-autocomplete, aria-controls, aria-describedby, aria-disabled, aria-expanded, aria-haspopup, aria-invalid, aria-label, aria-labelledby, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen combobox, dialog, group, 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.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 5 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 654 Zeilen
'use client';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Avatar } from '@/components/ui/avatar';
import { CONTROL_HEIGHT, controlBox, controlBoxFocus, useControl, useShake, withField, type ControlSize, type FieldOwnProps } from '@/components/ui/input';
import { ActiveMarker, LIST_STYLE, groupOptions, revealOption, unfold } from '@/components/ui/select';
import { Floating, floatingPop, originFor, panelSurface, useDismiss, useMounted, usePresence } from '@/components/ui/popover';
/*
* Pick several values from a list. A listbox with aria-multiselectable after
* the WAI-ARIA APG: arrows move, Space or Enter toggles, Home and End jump,
* typing filters. Four looks: chips inside the field, a filter button with a
* searchable panel, a list that is always open (shop facets) and a field
* with the chosen items stacked below it (people, reviewers).
*/
const TEXTS = {
en: {
placeholder: 'Choose…', search: 'Search', empty: 'No match. Try another word.', remove: 'Remove', clearAll: 'Clear all', selectAll: 'Select all',
selected: 'selected', added: 'added', removed: 'removed', of: 'of', more: 'Show all', less: 'Show fewer', none: 'Nothing chosen yet',
},
de: {
placeholder: 'Auswählen…', search: 'Suchen', empty: 'Kein Treffer. Versuch ein anderes Wort.', remove: 'Entfernen', clearAll: 'Alle entfernen', selectAll: 'Alle wählen',
selected: 'ausgewählt', added: 'hinzugefügt', removed: 'entfernt', of: 'von', more: 'Alle zeigen', less: 'Weniger zeigen', none: 'Noch nichts ausgewählt',
},
};
type Texts = (typeof TEXTS)['en'];
export interface MultiSelectOption {
value: string;
label: string;
description?: string;
icon?: IconName;
/** A person: shows an avatar with initials (or the image) before the label */
avatar?: string;
/** A small colored dot before the label, as a CSS color or token, e.g. "var(--success)" */
dot?: string;
/** A count at the end of the row, e.g. matching products */
count?: number;
group?: string;
disabled?: boolean;
}
export type MultiSelectVariant = 'chips' | 'popover' | 'list' | 'stack';
export interface MultiSelectProps extends FieldOwnProps {
variant?: MultiSelectVariant;
options: MultiSelectOption[];
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
placeholder?: string;
/** Placeholder of the search field in popover and list */
searchPlaceholder?: string;
/** At most this many. Further options are disabled and a counter shows the room left. */
max?: number;
/** popover: the text on the button, e.g. "Status" */
triggerLabel?: string;
/** popover: icon on the button */
triggerIcon?: IconName;
/** popover and list: a search field above the rows. Default when there are more than six options. */
search?: boolean;
/** list: rows shown before "Show all". Default 6. */
visible?: number;
size?: ControlSize;
name?: string;
id?: string;
disabled?: boolean;
required?: boolean;
invalid?: boolean;
/** Open on first render, for demos */
defaultOpen?: boolean;
labels?: Partial<Texts>;
className?: string;
}
const norm = (s: string) => s.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
const matches = (o: MultiSelectOption, q: string) => {
const hay = norm(`${o.label} ${o.description ?? ''} ${o.group ?? ''}`);
return norm(q).split(/\s+/).filter(Boolean).every((w) => hay.includes(w));
};
/** The square that fills with a check when a row is chosen */
function CheckMark({ on, dim }: { on: boolean; dim?: boolean }) {
return (
<span
aria-hidden
className={cn(
'grid size-[17px] shrink-0 place-items-center rounded-[5px] border transition-[background-color,border-color,scale] duration-200 ease-[var(--ease-kit)] motion-reduce:transition-none',
on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background',
dim && 'opacity-50',
)}
>
<svg viewBox="0 0 24 24" width={12} height={12} fill="none" className={cn('transition-[opacity,scale] duration-200 motion-reduce:transition-none', on ? 'scale-100 opacity-100' : 'scale-50 opacity-0')}>
<path d="m5 12.5 4.5 4.5L19 7.5" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
);
}
function Leading({ o }: { o: MultiSelectOption }) {
if (o.avatar) return <Avatar name={o.avatar} size="xs" alt="" />;
if (o.dot) return <span aria-hidden className="size-2 shrink-0 rounded-full" style={{ background: o.dot }} />;
if (o.icon) return <Icon name={o.icon} size={16} className="shrink-0 text-muted-foreground" />;
return null;
}
/** Marks the typed part of a label */
function Hit({ text, query }: { text: string; query: string }) {
const q = norm(query.trim()).split(/\s+/)[0];
const i = q ? norm(text).indexOf(q) : -1;
if (i < 0) return <>{text}</>;
return (
<>
{text.slice(0, i)}
<mark className="rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_18%,transparent)] text-inherit">{text.slice(i, i + q.length)}</mark>
{text.slice(i + q.length)}
</>
);
}
interface ListProps {
shown: MultiSelectOption[];
baseId: string;
active: number;
setActive: (i: number) => void;
isOn: (v: string) => boolean;
isFull: boolean;
toggle: (o: MultiSelectOption) => void;
query: string;
showGroups?: boolean;
}
/** The rows, grouped, with a check square. Shared by every look. */
function Rows({ shown, baseId, active, setActive, isOn, isFull, toggle, query, showGroups = true }: ListProps) {
const locale = useLocale();
const groups = showGroups ? groupOptions(shown as never) : [{ name: undefined, items: shown.map((option, index) => ({ option, index })) }];
return (
<>
{groups.map((g, gi) => {
const rows = g.items.map(({ option, index }) => {
const o = option as MultiSelectOption;
const on = isOn(o.value);
const off = o.disabled || (isFull && !on);
return (
<div
key={o.value}
id={`${baseId}-${index}`}
role="option"
aria-selected={on}
aria-disabled={off || undefined}
data-active={index === active || undefined}
onPointerMove={() => setActive(index)}
onPointerDown={(e) => e.preventDefault()}
onClick={() => !off && toggle(o)}
style={{ '--i': index } as React.CSSProperties}
className="kitfx-select-row relative flex min-h-9 cursor-default select-none items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm text-foreground aria-disabled:cursor-not-allowed max-sm:min-h-11"
>
<CheckMark on={on} dim={off} />
<Leading o={o} />
<span className={cn('min-w-0 flex-1', off && 'opacity-50')}>
<span className="block truncate">
<Hit text={o.label} query={query} />
</span>
{o.description && <span className="block truncate text-xs text-muted-foreground">{o.description}</span>}
</span>
{o.count !== undefined && <span className="shrink-0 text-xs tabular-nums text-subtle-foreground">{o.count.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</span>}
</div>
);
});
if (!g.name) return <div key={gi}>{rows}</div>;
return (
<div key={gi} role="group" aria-labelledby={`${baseId}-g${gi}`} className={cn(gi > 0 && 'mt-1 border-t border-border pt-1')}>
<div id={`${baseId}-g${gi}`} className="px-2.5 pb-1 pt-2 text-[11px] font-medium uppercase tracking-[0.08em] text-subtle-foreground">
{g.name}
</div>
{rows}
</div>
);
})}
</>
);
}
/** Keyboard on a list with an active row: arrows, Home, End, Enter and Space toggle */
function listKeys(e: React.KeyboardEvent, count: number, active: number, setActive: (i: number) => void, toggleActive: () => void, spaceToggles: boolean) {
const k = e.key;
if (!count) return false;
if (k === 'ArrowDown' || k === 'ArrowUp') {
e.preventDefault();
setActive(active < 0 ? (k === 'ArrowDown' ? 0 : count - 1) : (active + (k === 'ArrowDown' ? 1 : -1) + count) % count);
return true;
}
if ((k === 'Home' || k === 'End') && (e.ctrlKey || spaceToggles)) {
e.preventDefault();
setActive(k === 'Home' ? 0 : count - 1);
return true;
}
if (k === 'Enter' || (k === ' ' && spaceToggles)) {
if (active < 0) return false;
e.preventDefault();
toggleActive();
return true;
}
return false;
}
function MultiBox(props: Omit<MultiSelectProps, keyof FieldOwnProps> & { listLabel?: string }) {
const {
variant = 'chips', options, value: valueProp, defaultValue, onValueChange, placeholder, searchPlaceholder, max, triggerLabel, triggerIcon = 'filter',
visible = 6, size = 'md', name, id, disabled, required, invalid, defaultOpen, labels, className, listLabel,
} = props;
const searchable = props.search ?? options.length > 6;
const t = useTexts(TEXTS, labels);
const c = useControl({ id, invalid, disabled, required });
const [inner, setInner] = useState<string[]>(defaultValue ?? []);
const value = valueProp ?? inner;
const [query, setQuery] = useState('');
const [open, setOpen] = useState(Boolean(defaultOpen));
const [active, setActive] = useState(-1);
const [expanded, setExpanded] = useState(false);
const [said, setSaid] = useState('');
const mounted = useMounted();
const boxRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const baseId = useId();
const listId = `${baseId}-list`;
useShake(boxRef, c.invalid);
const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
const chosen = value.map((v) => byValue.get(v)).filter(Boolean) as MultiSelectOption[];
const isOn = (v: string) => value.includes(v);
const isFull = max !== undefined && value.length >= max;
const filtered = useMemo(() => (query.trim() ? options.filter((o) => matches(o, query)) : options), [options, query]);
// The list look keeps the chosen rows on top only after a change of query, so rows never jump under the pointer
const limited = variant === 'list' && !expanded && !query.trim() ? filtered.slice(0, visible) : filtered;
const shown = limited;
const set = (next: string[]) => {
setInner(next);
onValueChange?.(next);
};
const toggle = (o: MultiSelectOption) => {
if (o.disabled) return;
const on = isOn(o.value);
if (!on && isFull) return;
set(on ? value.filter((v) => v !== o.value) : [...value, o.value]);
setSaid(`${o.label} ${on ? t.removed : t.added}`);
// In the field looks a pick clears the typed text, ready for the next one
if ((variant === 'chips' || variant === 'stack') && query) {
setQuery('');
setActive(Math.max(0, options.indexOf(o)));
}
};
const removeValue = (v: string) => {
const o = byValue.get(v);
set(value.filter((x) => x !== v));
if (o) setSaid(`${o.label} ${t.removed}`);
};
const close = useCallback(() => {
setOpen(false);
setActive(-1);
}, []);
const refs = useRef([boxRef, panelRef]).current;
useDismiss(open && variant !== 'list', close, refs);
useEffect(() => {
if (active >= 0 && (open || variant === 'list')) revealOption(`${baseId}-${active}`);
}, [active, open, baseId, variant]);
useEffect(() => {
if (active >= shown.length) setActive(shown.length ? shown.length - 1 : -1);
}, [shown.length, active]);
// The popover hands the focus to its search field, or to its list when there is none.
// The panel stays hidden until it is placed, so this waits two frames instead of autofocus.
useEffect(() => {
if (!open || variant !== 'popover') return;
let inner = 0;
const id = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => (searchable ? inputRef.current : document.getElementById(listId))?.focus());
});
return () => {
cancelAnimationFrame(id);
cancelAnimationFrame(inner);
};
}, [open, variant, searchable, listId]);
const activeId = active >= 0 && active < shown.length && (open || variant === 'list') ? `${baseId}-${active}` : undefined;
const counter = max !== undefined && (
<span className={cn('shrink-0 text-xs tabular-nums transition-colors duration-300', isFull ? 'text-warning-foreground' : 'text-subtle-foreground')}>
{value.length} {t.of} {max}
</span>
);
const live = (
<span aria-live="polite" className="sr-only">
{said}
</span>
);
const hidden = name ? value.map((v) => <input key={v} type="hidden" name={name} value={v} />) : null;
const rows = (
<Rows shown={shown} baseId={baseId} active={active} setActive={setActive} isOn={isOn} isFull={isFull} toggle={toggle} query={query} />
);
const emptyRow = shown.length === 0 && <p className="px-3 py-6 text-center text-sm text-muted-foreground">{t.empty}</p>;
const panel = (inner: React.ReactNode, extraClass?: string) =>
mounted &&
open && (
<Floating anchor={variant === 'popover' ? triggerRef : boxRef} side="bottom" align="start" offset={6} matchWidth={variant !== 'popover'} ref={panelRef}>
{(pos) => (
<div className={cn(panelSurface, unfold(pos?.side).className, extraClass)}>
<style dangerouslySetInnerHTML={{ __html: LIST_STYLE }} />
{inner}
</div>
)}
</Floating>
);
// Without a search field the list itself takes the focus and the keys
const listbox = (cls?: string, labelled?: string, focusable?: boolean) => (
<div
id={listId}
role="listbox"
aria-multiselectable
aria-labelledby={labelled}
aria-label={labelled ? undefined : listLabel}
tabIndex={focusable ? 0 : undefined}
aria-activedescendant={focusable ? activeId : undefined}
onFocus={focusable ? () => active < 0 && setActive(0) : undefined}
onBlur={focusable && variant === 'list' ? () => setActive(-1) : undefined}
onKeyDown={focusable ? (e) => listKeys(e, shown.length, active, setActive, () => toggle(shown[active]), true) : undefined}
className={cn('kit-scrollbar relative overflow-y-auto overscroll-contain', focusable && 'outline-none [&:focus-visible_[data-active]]:shadow-[inset_0_0_0_1.5px_color-mix(in_oklab,var(--primary)_55%,transparent)]', cls)}
>
<ActiveMarker id={activeId} version={`${query}|${shown.length}|${expanded}`} />
{rows}
{emptyRow}
</div>
);
const comboKeys = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown' && !open) {
e.preventDefault();
setOpen(true);
setActive(0);
return;
}
if (open && listKeys(e, shown.length, active, setActive, () => toggle(shown[active]), false)) return;
if (e.key === 'Escape') {
if (open) {
e.preventDefault();
close();
} else if (query) setQuery('');
} else if (e.key === 'Backspace' && !query && value.length && variant === 'chips') {
removeValue(value[value.length - 1]);
} else if (e.key === 'Tab' && open) close();
};
const searchInput = (opts: { combobox?: boolean; className?: string; autoFocus?: boolean }) => (
<input
ref={inputRef}
id={opts.combobox ? c.id : undefined}
type="text"
role="combobox"
aria-autocomplete="list"
aria-expanded={variant === 'list' ? true : open}
aria-controls={open || variant === 'list' ? listId : undefined}
aria-activedescendant={activeId}
aria-invalid={opts.combobox ? c['aria-invalid'] : undefined}
aria-describedby={opts.combobox ? c['aria-describedby'] : undefined}
aria-label={opts.combobox ? undefined : searchPlaceholder ?? t.search}
autoFocus={opts.autoFocus}
disabled={c.disabled}
autoComplete="off"
spellCheck={false}
placeholder={opts.combobox ? (value.length && variant === 'chips' ? '' : placeholder ?? t.placeholder) : searchPlaceholder ?? t.search}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
if (variant !== 'list') setOpen(true);
}}
onClick={() => variant !== 'list' && variant !== 'popover' && setOpen(true)}
onKeyDown={variant === 'list' ? (e) => listKeys(e, shown.length, active, setActive, () => toggle(shown[active]), false) : comboKeys}
className={cn('min-w-0 bg-transparent text-inherit outline-none placeholder:text-subtle-foreground', opts.className)}
/>
);
/* ---------------- chips: tokens inside the field ---------------- */
if (variant === 'chips') {
return (
<>
<div
ref={boxRef}
data-invalid={c.invalid || undefined}
data-disabled={c.disabled || undefined}
onPointerDown={(e) => {
if ((e.target as HTMLElement).closest('button')) return;
e.preventDefault();
inputRef.current?.focus();
setOpen(true);
}}
className={cn('group/multi relative flex min-h-11 w-full cursor-text flex-wrap items-center gap-1.5 rounded-lg px-1.5 py-1.5 text-base sm:text-sm', controlBox, className)}
>
{chosen.map((o) => (
<span
key={o.value}
className="inline-flex h-8 max-w-full items-center gap-1.5 rounded-md border border-border bg-muted ps-2 text-[13px] font-medium text-foreground transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] starting:scale-75 starting:opacity-0 motion-reduce:transition-none sm:h-7"
>
<Leading o={o} />
<span className="truncate">{o.label}</span>
<button
type="button"
aria-label={`${t.remove}: ${o.label}`}
disabled={c.disabled}
onClick={() => {
removeValue(o.value);
inputRef.current?.focus();
}}
className="relative grid h-full w-10 place-items-center rounded-e-[5px] text-subtle-foreground outline-none transition-colors after:absolute after:-inset-y-2 after:-inset-x-0.5 after:content-[''] sm:after:-inset-y-1.5 sm:after:inset-x-0 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:w-6"
>
<Icon name="x" size={12} strokeWidth={2} />
</button>
</span>
))}
{/* Input and counter wrap together, so the counter always closes the last line */}
<span className="flex min-w-[9rem] flex-1 items-center">
{searchInput({ combobox: true, className: 'h-8 w-0 flex-1 ps-1.5 sm:h-7' })}
<span className="pointer-events-none flex h-8 shrink-0 items-center gap-2 pe-1.5 ps-1 sm:h-7">
{counter}
<Icon name="chevrons-up-down" size={15} className="text-subtle-foreground" />
</span>
</span>
</div>
{panel(listbox('max-h-72 p-1.5'))}
{live}
{hidden}
</>
);
}
/* ---------------- stack: chosen items listed below the field ---------------- */
if (variant === 'stack') {
return (
<div className={cn('grid w-full grid-cols-[minmax(0,1fr)] gap-2.5', className)}>
<div ref={boxRef} data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('group/multi relative flex w-full items-center', CONTROL_HEIGHT[size], controlBox)}>
<Icon name="search" size={16} className="pointer-events-none ms-3.5 shrink-0 text-subtle-foreground" />
{searchInput({ combobox: true, className: 'h-full w-0 flex-1 ps-2.5' })}
<span className="pointer-events-none me-3 flex items-center gap-2">{counter}</span>
</div>
{panel(listbox('max-h-72 p-1.5'))}
<ul className="grid grid-cols-[minmax(0,1fr)] overflow-hidden rounded-lg border border-border bg-card">
{chosen.length === 0 && <li className="px-3.5 py-3 text-[13px] text-muted-foreground">{t.none}</li>}
{chosen.map((o, i) => (
<li
key={o.value}
className={cn(
'flex min-h-14 items-center gap-3 px-3.5 py-2.5 transition-[opacity,translate] duration-300 ease-[var(--ease-kit)] starting:-translate-y-1 starting:opacity-0 motion-reduce:transition-none',
i > 0 && 'border-t border-border',
)}
>
{o.avatar ? <Avatar name={o.avatar} size="sm" alt="" /> : <Leading o={o} />}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">{o.label}</span>
{o.description && <span className="block truncate text-[13px] text-muted-foreground">{o.description}</span>}
</span>
<button
type="button"
aria-label={`${t.remove}: ${o.label}`}
disabled={c.disabled}
onClick={() => removeValue(o.value)}
className="grid size-11 shrink-0 place-items-center rounded-md text-subtle-foreground outline-none transition-[background-color,color,scale] duration-200 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] active:scale-90 sm:size-8"
>
<Icon name="x" size={15} />
</button>
</li>
))}
</ul>
{live}
{hidden}
</div>
);
}
/* ---------------- list: always open, for filter sidebars ---------------- */
if (variant === 'list') {
const rest = filtered.length - visible;
return (
<div ref={boxRef} className={cn('grid w-full grid-cols-[minmax(0,1fr)] gap-2', className)}>
{searchable && (
<div data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('relative flex h-11 items-center rounded-lg text-base sm:h-9 sm:rounded-md sm:text-[13px]', controlBox)}>
<Icon name="search" size={15} className="pointer-events-none ms-3 shrink-0 text-subtle-foreground" />
{searchInput({ className: 'h-full w-0 flex-1 ps-2 pe-3' })}
</div>
)}
<div className="relative">
<style dangerouslySetInnerHTML={{ __html: LIST_STYLE }} />
{listbox(cn('max-h-[22rem]', searchable ? '-mx-2.5' : '-mx-2.5 p-0.5'), undefined, !searchable)}
</div>
{((rest > 0 && !query.trim()) || value.length > 0) && (
<div className="flex min-h-6 items-center justify-between gap-3 text-[13px]">
{rest > 0 && !query.trim() ? (
<button
type="button"
aria-expanded={expanded}
aria-controls={listId}
onClick={() => setExpanded(!expanded)}
className="-mx-1.5 inline-flex min-h-11 items-center gap-1 rounded-md px-1.5 font-medium text-foreground outline-none hover:underline focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:min-h-7"
>
{expanded ? t.less : `${t.more} (${filtered.length})`}
<Icon name="chevron-down" size={14} className={cn('transition-[rotate] duration-300 ease-[var(--ease-kit)]', expanded && 'rotate-180')} />
</button>
) : (
<span />
)}
{value.length > 0 && (
<button type="button" onClick={() => set([])} className="-mx-1.5 inline-flex min-h-11 items-center rounded-md px-1.5 text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:min-h-7 starting:opacity-0">
{t.clearAll}
</button>
)}
</div>
)}
{live}
{hidden}
</div>
);
}
/* ---------------- popover: a filter button with a searchable panel ---------------- */
const summary = chosen.length === 0 ? null : chosen.length <= 2 ? chosen.map((o) => o.label).join(', ') : `${chosen.length} ${t.selected}`;
const enabled = options.filter((o) => !o.disabled);
const allOn = enabled.length > 0 && enabled.every((o) => isOn(o.value));
return (
<>
<div ref={boxRef} className={cn('inline-flex max-w-full', className)}>
<button
ref={triggerRef}
type="button"
id={c.id}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? `${baseId}-panel` : undefined}
aria-describedby={c['aria-describedby']}
disabled={c.disabled}
onClick={() => {
setOpen(!open);
setActive(-1);
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' && !open) {
e.preventDefault();
setOpen(true);
setActive(0);
}
}}
className={cn(
'group/trigger inline-flex h-11 max-w-full items-center gap-2 rounded-lg px-3.5 text-sm font-medium sm:h-9 sm:px-3 sm:text-[13px]',
controlBoxFocus,
chosen.length > 0 && 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_7%,var(--background))]',
)}
>
<Icon name={triggerIcon} size={15} className="shrink-0 text-muted-foreground" />
<span className="shrink-0">{triggerLabel ?? placeholder ?? t.placeholder}</span>
{summary && (
<>
<span aria-hidden className="h-4 w-px shrink-0 bg-border-strong" />
<span key={summary} className="truncate font-normal text-primary transition-[opacity,translate] duration-300 ease-[var(--ease-kit)] starting:translate-y-1 starting:opacity-0 motion-reduce:transition-none">
{summary}
</span>
</>
)}
<Icon name="chevron-down" size={14} className={cn('shrink-0 text-subtle-foreground transition-[rotate] duration-300 ease-[var(--ease-kit)]', open && 'rotate-180')} />
</button>
</div>
<PopoverPanel open={open} mounted={mounted} anchor={triggerRef} panelRef={panelRef} id={`${baseId}-panel`} label={triggerLabel ?? placeholder ?? t.placeholder} onEscape={() => { close(); triggerRef.current?.focus(); }}>
{searchable && (
<div className="flex h-11 items-center gap-2 border-b border-border px-3">
<Icon name="search" size={15} className="shrink-0 text-subtle-foreground" />
{searchInput({ className: 'h-full w-0 flex-1 text-base sm:text-[13px]' })}
{counter}
</div>
)}
{listbox('max-h-64 p-1.5', undefined, !searchable)}
<div className="flex items-center justify-between gap-2 border-t border-border p-1.5">
<button
type="button"
disabled={max !== undefined || allOn}
onClick={() => set(enabled.map((o) => o.value))}
className="inline-flex h-11 items-center rounded-md px-2.5 text-[13px] font-medium text-foreground outline-none transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] disabled:pointer-events-none disabled:opacity-40 sm:h-8"
>
{t.selectAll}
</button>
<button
type="button"
disabled={value.length === 0}
onClick={() => set([])}
className="inline-flex h-11 items-center rounded-md px-2.5 text-[13px] text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] disabled:pointer-events-none disabled:opacity-40 sm:h-8"
>
{t.clearAll}
</button>
</div>
</PopoverPanel>
{live}
{hidden}
</>
);
}
/** The filter panel of the popover look, kept mounted for its exit */
function PopoverPanel({ open, mounted, anchor, panelRef, id, label, onEscape, children }: { open: boolean; mounted: boolean; anchor: React.RefObject<HTMLButtonElement | null>; panelRef: React.RefObject<HTMLDivElement | null>; id: string; label: string; onEscape: () => void; children: React.ReactNode }) {
const { present, closing } = usePresence(open);
if (!mounted || !present) return null;
return (
<Floating anchor={anchor} side="bottom" align="start" offset={6} ref={panelRef}>
{(pos) => (
<div
id={id}
role="dialog"
aria-label={label}
data-closing={closing || undefined}
inert={closing || undefined}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation();
onEscape();
}
}}
className={cn('w-72 max-w-[calc(100vw-16px)] overflow-hidden', panelSurface, floatingPop)}
style={{ '--origin': originFor(pos, 'bottom') } as React.CSSProperties}
>
<style dangerouslySetInnerHTML={{ __html: LIST_STYLE }} />
{children}
</div>
)}
</Floating>
);
}
/**
* Pick several values from a list, with search, groups, counts and a limit.
* <MultiSelect label="Skills" options={skills} defaultValue={['research']} />
* <MultiSelect variant="popover" triggerLabel="Status" options={statuses} />
* <MultiSelect variant="list" label="Material" options={materials} visible={5} />
* <MultiSelect variant="stack" label="Reviewers" options={people} max={3} />
*/
export function MultiSelect({ label, hint, error, optional, labelAction, className, ...rest }: MultiSelectProps) {
const wrapped = Boolean(label || hint || error);
return withField(
{ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className },
<MultiBox {...rest} listLabel={rest.triggerLabel ?? (typeof label === 'string' ? label : undefined)} invalid={rest.invalid ?? (error ? true : undefined)} className={wrapped ? undefined : className} />,
);
}Häufige Fragen
Wie installiere ich Mehrfachauswahl?
Mit aiw add @ai-website/multi-select. Der Befehl legt die Datei components/ui/multi-select.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, avatar, input, select und popover. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Mehrfachauswahl?
4 Varianten, Chips, Filterknopf, Facettenliste und Stapel. Du wählst sie mit der Prop variant, zum Beispiel variant="list".
Kann ich die Texte von Mehrfachauswahl übersetzen?
Ja. Mehrfachauswahl bringt 13 eigene Texte auf Deutsch und Englisch mit, etwa „Auswählen…“, „Suchen“ und „Kein Treffer. Versuch ein anderes Wort.“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Mehrfachauswahl einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Mehrfachauswahl mit aiw_components und installieren sie mit aiw_add @ai-website/multi-select. Nenn im Prompt die Variante, etwa variant="popover", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Mehrfachauswahl ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden MultiSelect-Code zum Kopieren.