321 lines
9.6 KiB
TypeScript
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-white/5 dark:text-white 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>
|
|
);
|
|
}
|