geraete/components/ui/Combobox.tsx
2025-12-05 13:53:29 +01:00

321 lines
9.6 KiB
TypeScript

// 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<string | boolean | null | undefined>) {
return classes.filter(Boolean).join(' ');
}
export type AppComboboxProps<T> = {
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<T>({
label = 'Auswahl',
options,
value,
onChange,
getKey,
getPrimaryLabel,
getSecondaryLabel,
getSearchText,
getImageUrl,
getStatus,
placeholder = 'Auswählen…',
allowCreateFromQuery = false,
onCreateFromQuery,
filterBySecondary = true,
className,
}: AppComboboxProps<T>) {
// 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 (
<div className="flex items-center">
{hasAvatar && (
<div className="grid size-6 shrink-0 place-items-center rounded-full bg-gray-300 in-data-focus:bg-white dark:bg-gray-600">
<UserIcon
className="size-4 fill-white in-data-focus:fill-indigo-600 dark:in-data-focus:fill-indigo-500"
aria-hidden="true"
/>
</div>
)}
{hasStatus && (
<span
className="inline-block size-2 shrink-0 rounded-full border border-gray-400 in-aria-active:border-white/75 dark:border-gray-600"
aria-hidden="true"
/>
)}
<span
className={classNames(
'block truncate',
hasAvatar || hasStatus ? 'ml-3' : null,
)}
>
{q}
</span>
</div>
);
}
// 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 (
<div className="flex items-center">
{hasAvatar && (
<img
src={imageUrl}
alt=""
className="size-6 shrink-0 rounded-full bg-gray-100 outline -outline-offset-1 outline-black/5 dark:bg-gray-700 dark:outline-white/10"
/>
)}
{hasStatusDot && (
<span
className={classNames(
'inline-block size-2 shrink-0 rounded-full',
status === 'online'
? 'bg-green-400 dark:bg-green-500'
: 'bg-gray-200 dark:bg-white/20',
)}
aria-hidden="true"
/>
)}
<span
className={classNames(
'block truncate',
hasAvatar || hasStatusDot ? 'ml-3' : null,
)}
>
{labelText}
</span>
{secondary && (
<span className="ml-2 block truncate text-gray-500 in-data-focus:text-white dark:text-gray-400 dark:in-data-focus:text-white">
{secondary}
</span>
)}
</div>
);
};
return (
<Combobox
as="div"
value={value}
onChange={handleChange}
className={classNames('w-full', className)}
>
{label && (
<Label className="text-xs font-semibold uppercase tracking-wide text-gray-400">
{label}
</Label>
)}
<div className="relative mt-2">
<ComboboxInput
className="block w-full rounded-md bg-white py-1.5 pr-12 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-gray-900 dark:text-gray-100 dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
/>
<ComboboxButton className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden">
<ChevronDownIcon
className="size-5 text-gray-400"
aria-hidden="true"
/>
</ComboboxButton>
<ComboboxOptions
transition
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg outline outline-black/5 data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0 sm:text-sm dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
>
{/* Freitext-Option */}
{showCreateRow && (
<ComboboxOption
value={{ __isCreateOption: true, query } as any}
className="cursor-default px-3 py-2 text-gray-900 select-none data-focus:bg-indigo-600 data-focus:text-white data-focus:outline-hidden dark:text-white dark:data-focus:bg-indigo-500"
>
{renderOptionInner({
__isCreateOption: true,
query,
} as any)}
</ComboboxOption>
)}
{/* 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 (
<ComboboxOption
key={key}
value={opt}
className="cursor-default px-3 py-2 text-gray-900 select-none data-focus:bg-indigo-600 data-focus:text-white data-focus:outline-hidden dark:text-gray-300 dark:data-focus:bg-indigo-500"
>
{renderOptionInner(opt as any)}
</ComboboxOption>
);
})}
{/* Keine Treffer */}
{filtered.length === 0 && !showCreateRow && (
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Keine Treffer
</div>
)}
</ComboboxOptions>
</div>
</Combobox>
);
}