geraete/components/ui/TagMultiCombobox.tsx
2025-11-17 15:26:43 +01:00

234 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
);
}