234 lines
7.4 KiB
TypeScript
234 lines
7.4 KiB
TypeScript
// components/ui/TagMultiCombobox.tsx
|
||
'use client';
|
||
|
||
import {
|
||
Combobox,
|
||
ComboboxButton,
|
||
ComboboxInput,
|
||
ComboboxOption,
|
||
ComboboxOptions,
|
||
Label,
|
||
} from '@headlessui/react';
|
||
import { ChevronDownIcon, CheckIcon } from '@heroicons/react/20/solid';
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import clsx from 'clsx';
|
||
import Badge from '@/components/ui/Badge';
|
||
|
||
export type TagOption = {
|
||
id?: string | null;
|
||
name: string;
|
||
};
|
||
|
||
type TagMultiComboboxProps = {
|
||
label?: string;
|
||
availableTags: TagOption[];
|
||
value: TagOption[]; // ausgewählte Tags
|
||
onChange: (next: TagOption[]) => void;
|
||
placeholder?: string;
|
||
className?: string;
|
||
};
|
||
|
||
export default function TagMultiCombobox({
|
||
label = 'Tags',
|
||
availableTags,
|
||
value,
|
||
onChange,
|
||
placeholder = 'Tags auswählen oder neu eingeben …',
|
||
className,
|
||
}: TagMultiComboboxProps) {
|
||
const [query, setQuery] = useState('');
|
||
const [allTags, setAllTags] = useState<TagOption[]>(availableTags);
|
||
|
||
const selectedNames = useMemo(
|
||
() => new Set(value.map((t) => t.name.toLowerCase())),
|
||
[value],
|
||
);
|
||
|
||
useEffect(() => {
|
||
setAllTags((prev) => {
|
||
const byName = new Map(prev.map((t) => [t.name.toLowerCase(), t]));
|
||
for (const t of availableTags) {
|
||
const key = t.name.toLowerCase();
|
||
if (!byName.has(key)) {
|
||
byName.set(key, t);
|
||
}
|
||
}
|
||
return Array.from(byName.values());
|
||
});
|
||
}, [availableTags]);
|
||
|
||
const filteredTags = useMemo(() => {
|
||
if (!query.trim()) return allTags;
|
||
const q = query.toLowerCase();
|
||
return allTags.filter((tag) => tag.name.toLowerCase().includes(q));
|
||
}, [allTags, query]);
|
||
|
||
function handleRemoveTag(tag: TagOption) {
|
||
onChange(
|
||
value.filter((t) => t.name.toLowerCase() !== tag.name.toLowerCase()),
|
||
);
|
||
}
|
||
|
||
function handleToggleTag(tag: TagOption) {
|
||
const key = tag.name.toLowerCase();
|
||
if (selectedNames.has(key)) {
|
||
onChange(value.filter((t) => t.name.toLowerCase() !== key));
|
||
} else {
|
||
onChange([...value, tag]);
|
||
}
|
||
}
|
||
|
||
function handleCreateTagFromQuery() {
|
||
const name = query.trim();
|
||
if (!name) return;
|
||
|
||
const existing = allTags.find(
|
||
(t) => t.name.toLowerCase() === name.toLowerCase(),
|
||
);
|
||
|
||
if (existing) {
|
||
if (!selectedNames.has(existing.name.toLowerCase())) {
|
||
onChange([...value, existing]);
|
||
}
|
||
setQuery('');
|
||
return;
|
||
}
|
||
|
||
const newTag: TagOption = {
|
||
id: `__new-${Date.now()}`,
|
||
name,
|
||
};
|
||
|
||
setAllTags((prev) => [...prev, newTag]);
|
||
onChange([...value, newTag]);
|
||
setQuery('');
|
||
}
|
||
|
||
return (
|
||
<Combobox
|
||
as="div"
|
||
multiple
|
||
value={value}
|
||
// wir steuern das selbst über handleToggleTag / handleRemoveTag
|
||
onChange={() => {}}
|
||
className={clsx('w-full', className)}
|
||
>
|
||
<Label className="block text-sm font-medium text-gray-900 dark:text-white">
|
||
{label}
|
||
</Label>
|
||
|
||
<div className="mt-2 relative">
|
||
{/* Chips + Input in einem „Feld“ */}
|
||
<div
|
||
className={clsx(
|
||
// neu: relative + extra padding rechts für den Pfeil
|
||
'relative flex flex-wrap items-center gap-1 rounded-md bg-white px-2 py-1.5 text-sm text-gray-900 ' +
|
||
'outline-1 -outline-offset-1 outline-gray-300 focus-within:outline-2 focus-within:-outline-offset-2 ' +
|
||
'focus-within:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-gray-700 ' +
|
||
'dark:focus-within:outline-indigo-500 pr-8', // <– Platz für Chevron
|
||
)}
|
||
>
|
||
{/* Ausgewählte Tags als Badges */}
|
||
{value.map((tag) => (
|
||
<Badge
|
||
key={tag.id ?? tag.name}
|
||
tone="blue"
|
||
variant="flat"
|
||
shape="pill"
|
||
size="md"
|
||
onRemove={() => handleRemoveTag(tag)}
|
||
>
|
||
{tag.name}
|
||
</Badge>
|
||
))}
|
||
|
||
{/* Eingabefeld */}
|
||
<ComboboxInput
|
||
className="flex-1 min-w-[6rem] bg-transparent border-0 px-1 py-0.5 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none dark:text-white dark:placeholder:text-gray-500"
|
||
value={query}
|
||
onChange={(event) => setQuery(event.target.value)}
|
||
displayValue={() => ''}
|
||
placeholder={value.length === 0 ? placeholder : undefined}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
handleCreateTagFromQuery();
|
||
} else if (
|
||
event.key === 'Backspace' &&
|
||
!query &&
|
||
value.length > 0
|
||
) {
|
||
const last = value[value.length - 1];
|
||
handleRemoveTag(last);
|
||
}
|
||
}}
|
||
/>
|
||
|
||
{/* Chevron immer rechts im Feld */}
|
||
<ComboboxButton className="absolute inset-y-0 right-2 flex items-center text-gray-400 focus:outline-none">
|
||
<ChevronDownIcon className="size-5" aria-hidden="true" />
|
||
</ComboboxButton>
|
||
</div>
|
||
|
||
{/* Dropdown */}
|
||
<ComboboxOptions
|
||
transition
|
||
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-sm shadow-lg outline outline-black/5 data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0 dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
||
>
|
||
{query.trim().length > 0 &&
|
||
!allTags.some(
|
||
(t) => t.name.toLowerCase() === query.trim().toLowerCase(),
|
||
) && (
|
||
<ComboboxOption
|
||
value={{ id: `__new-${query}`, name: query.trim() }}
|
||
className="cursor-default select-none px-3 py-2 text-gray-900 data-focus:bg-indigo-600 data-focus:text-white data-focus:outline-hidden dark:text-gray-200 dark:data-focus:bg-indigo-500"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
handleCreateTagFromQuery();
|
||
}}
|
||
>
|
||
<span className="font-medium">
|
||
„{query.trim()}“ erstellen
|
||
</span>
|
||
</ComboboxOption>
|
||
)}
|
||
|
||
{filteredTags.length === 0 && query.trim().length === 0 && (
|
||
<div className="px-3 py-2 text-gray-500 dark:text-gray-400 text-xs">
|
||
Keine Tags vorhanden.
|
||
</div>
|
||
)}
|
||
|
||
{filteredTags.map((tag) => {
|
||
const isSelected = selectedNames.has(tag.name.toLowerCase());
|
||
return (
|
||
<ComboboxOption
|
||
key={tag.id ?? tag.name}
|
||
value={tag}
|
||
className={({ active }) =>
|
||
clsx(
|
||
'flex cursor-default select-none items-center justify-between px-3 py-2 text-gray-900 dark:text-gray-100',
|
||
active && 'bg-indigo-600 text-white dark:bg-indigo-500',
|
||
)
|
||
}
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
handleToggleTag(tag);
|
||
}}
|
||
>
|
||
<span className="truncate">{tag.name}</span>
|
||
{isSelected && (
|
||
<CheckIcon
|
||
className="size-4 text-indigo-600 dark:text-indigo-300 data-focus:text-white"
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
</ComboboxOption>
|
||
);
|
||
})}
|
||
</ComboboxOptions>
|
||
</div>
|
||
</Combobox>
|
||
);
|
||
}
|