// components/ui/Combobox.ts 'use client'; import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions, Label, } from '@headlessui/react'; import { ChevronDownIcon } from '@heroicons/react/20/solid'; import { UserIcon } from '@heroicons/react/16/solid'; import { useEffect, useMemo, useState } from 'react'; function classNames(...classes: Array) { return classes.filter(Boolean).join(' '); } export type AppComboboxProps = { label?: string; options: T[]; value: T | null; onChange: (value: T | null) => void; /** Eindeutiger Key pro Option */ getKey: (option: T) => string | number; /** Primärer Text (wie 'name') */ getPrimaryLabel: (option: T) => string; /** Optional: sekundärer Text (z.B. Gruppe) */ getSecondaryLabel?: (option: T) => string | null; /** Optional: zusätzlicher Suchstring über mehrere Felder */ getSearchText?: (option: T) => string | null; /** Optional: Avatar-URL */ getImageUrl?: (option: T) => string | null; /** * Optional: Status für Punkt-Indikator * Wird nur benutzt, wenn KEIN Avatar gesetzt ist. */ getStatus?: (option: T) => 'online' | 'offline' | 'neutral' | null; placeholder?: string; /** Query darf einen neuen Wert erzeugen */ allowCreateFromQuery?: boolean; /** * Wenn gesetzt, wird bei Auswahl der "Neuen Wert aus Query"-Option * diese Funktion aufgerufen und das Ergebnis als Auswahl übernommen. */ onCreateFromQuery?: (query: string) => T; /** Ob auch im Secondary-Text gesucht werden soll */ filterBySecondary?: boolean; className?: string; }; export default function AppCombobox({ label = 'Auswahl', options, value, onChange, getKey, getPrimaryLabel, getSecondaryLabel, getSearchText, getImageUrl, getStatus, placeholder = 'Auswählen…', allowCreateFromQuery = false, onCreateFromQuery, filterBySecondary = true, className, }: AppComboboxProps) { // Einzige Quelle der Wahrheit für den Text im Input const [query, setQuery] = useState(''); // Wenn sich die ausgewählte Option von außen ändert, // den Input-Text entsprechend synchronisieren. useEffect(() => { if (value) { setQuery(getPrimaryLabel(value)); } else { setQuery(''); } // getPrimaryLabel absichtlich NICHT in deps, // sonst triggern wir bei jedem Render ein setState. // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return options; return options.filter((opt) => { // Primär-Label const primary = getPrimaryLabel(opt).toLowerCase(); if (primary.includes(q)) return true; // Secondary (optional, z.B. Gruppe) if (filterBySecondary && getSecondaryLabel) { const sec = (getSecondaryLabel(opt) ?? '').toLowerCase(); if (sec.includes(q)) return true; } // zusätzlicher Suchtext (z.B. Vorname, Nachname, nwkennung, Gruppe) if (getSearchText) { const extra = (getSearchText(opt) ?? '').toLowerCase(); if (extra.includes(q)) return true; } return false; }); }, [ options, query, getPrimaryLabel, getSecondaryLabel, getSearchText, filterBySecondary, ]); const showCreateRow = allowCreateFromQuery && query.trim().length > 0 && !options.some( (opt) => getPrimaryLabel(opt).toLowerCase() === query.trim().toLowerCase(), ); const handleChange = (selected: any) => { // Sonderfall: "neuen Wert aus Query" gewählt if (selected && selected.__isCreateOption && onCreateFromQuery) { const created = onCreateFromQuery(selected.query as string); // Query auf Label der neu erzeugten Option setzen setQuery(getPrimaryLabel(created)); onChange(created); return; } if (selected) { // Query auf Label der ausgewählten Option setzen setQuery(getPrimaryLabel(selected)); onChange(selected); } else { // Auswahl gelöscht setQuery(''); onChange(null); } }; const renderOptionInner = ( opt: T | { __isCreateOption: true; query: string }, ) => { // Create-Row (Freitext) if ((opt as any).__isCreateOption) { const q = (opt as any).query as string; const hasAvatar = !!getImageUrl; const hasStatus = !!getStatus && !hasAvatar; return (
{hasAvatar && (
)} {hasStatus && (
); } // Normale Option const o = opt as T; const labelText = getPrimaryLabel(o); const secondary = getSecondaryLabel?.(o) ?? null; const imageUrl = getImageUrl?.(o) ?? null; const status = getStatus?.(o) ?? null; const hasAvatar = !!imageUrl; const hasStatusDot = !!getStatus && !hasAvatar; return (
{hasAvatar && ( )} {hasStatusDot && (
); }; return ( {label && ( )}
setQuery(event.target.value)} placeholder={placeholder} /> {/* Freitext-Option */} {showCreateRow && ( {renderOptionInner({ __isCreateOption: true, query, } as any)} )} {/* Normale Optionen */} {filtered.map((opt) => { const baseKey = String(getKey(opt)); // Position der Option in der *vollen* options-Liste suchen const idx = options.indexOf(opt); const key = idx === -1 ? baseKey : `${baseKey}__${idx}`; return ( {renderOptionInner(opt as any)} ); })} {/* Keine Treffer */} {filtered.length === 0 && !showCreateRow && (
Keine Treffer
)}
); }