teg/frontend/src/components/Combobox.tsx
2026-05-19 18:07:22 +02:00

231 lines
7.3 KiB
TypeScript

'use client'
import { useState } from 'react'
import {
Combobox as HeadlessCombobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
Label,
} from '@headlessui/react'
import { ChevronDownIcon, UserIcon } from '@heroicons/react/20/solid'
export type ComboboxVariant = 'simple' | 'status' | 'image' | 'secondary'
export type ComboboxItem = {
id: string | number | null
name: string
online?: boolean
imageUrl?: string
secondaryText?: string
username?: string
}
type ComboboxProps = {
label?: string
items: ComboboxItem[]
value: ComboboxItem | null
onChange: (item: ComboboxItem | null) => void
variant?: ComboboxVariant
placeholder?: string
allowCustomValue?: boolean
createOptionLabel?: (value: string) => string
emptyText?: string
disabled?: boolean
required?: boolean
className?: string
inputClassName?: string
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function getSecondaryText(item: ComboboxItem) {
return item.secondaryText ?? item.username
}
export default function Combobox({
label,
items,
value,
onChange,
variant = 'simple',
placeholder,
allowCustomValue = false,
createOptionLabel,
emptyText = 'Keine Ergebnisse gefunden.',
disabled = false,
required = false,
className,
inputClassName,
}: ComboboxProps) {
const [query, setQuery] = useState('')
const normalizedQuery = query.trim().toLowerCase()
const filteredItems =
normalizedQuery === ''
? items
: items.filter((item) => {
const secondaryText = getSecondaryText(item)
return (
item.name.toLowerCase().includes(normalizedQuery) ||
secondaryText?.toLowerCase().includes(normalizedQuery)
)
})
function renderOptionContent(item: ComboboxItem, isCustomValue = false) {
if (variant === 'status') {
return (
<div className="flex items-center">
<span
className={classNames(
'inline-block size-2 shrink-0 rounded-full',
isCustomValue
? 'border border-gray-400 in-data-focus:border-white/75 dark:border-gray-600'
: item.online
? 'bg-green-400 dark:bg-green-500'
: 'bg-gray-200 dark:bg-white/20',
)}
aria-hidden="true"
/>
<span className="ml-3 block truncate">
{item.name}
{!isCustomValue && (
<span className="sr-only">
{' '}
ist {item.online ? 'online' : 'offline'}
</span>
)}
</span>
</div>
)
}
if (variant === 'image') {
return (
<div className="flex items-center">
{item.imageUrl ? (
<img
src={item.imageUrl}
alt=""
className="size-6 shrink-0 rounded-full bg-gray-100 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-700 dark:outline-white/10"
/>
) : (
<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
aria-hidden="true"
className="size-4 fill-white in-data-focus:fill-indigo-600 dark:in-data-focus:fill-indigo-500"
/>
</div>
)}
<span className="ml-3 block truncate">{item.name}</span>
</div>
)
}
if (variant === 'secondary') {
const secondaryText = getSecondaryText(item)
return (
<div className="flex min-w-0">
<span className="block truncate">{item.name}</span>
{secondaryText && (
<span className="ml-2 block truncate text-gray-500 in-data-focus:text-white dark:text-gray-400 dark:in-data-focus:text-white">
{secondaryText}
</span>
)}
</div>
)
}
return <span className="block truncate">{item.name}</span>
}
return (
<HeadlessCombobox
as="div"
value={value}
onChange={(item: ComboboxItem | null) => {
setQuery('')
onChange(item)
}}
disabled={disabled}
className={className}
>
{label && (
<Label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
{label}
</Label>
)}
<div className={classNames('relative', label && 'mt-2')}>
<ComboboxInput
required={required}
placeholder={placeholder}
className={classNames(
'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 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 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 dark:disabled:bg-white/5 dark:disabled:text-white/40',
inputClassName,
)}
onChange={(event) => setQuery(event.target.value)}
onBlur={() => setQuery('')}
displayValue={(item: ComboboxItem | null) => item?.name ?? ''}
/>
<ComboboxButton className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden disabled:cursor-not-allowed">
<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"
>
{allowCustomValue && query.trim().length > 0 && (
<ComboboxOption
value={{
id: null,
name: query.trim(),
}}
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"
>
{renderOptionContent(
{
id: null,
name: createOptionLabel
? createOptionLabel(query.trim())
: query.trim(),
},
true,
)}
</ComboboxOption>
)}
{filteredItems.map((item) => (
<ComboboxOption
key={item.id}
value={item}
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"
>
{renderOptionContent(item)}
</ComboboxOption>
))}
{filteredItems.length === 0 && !allowCustomValue && (
<div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
{emptyText}
</div>
)}
</ComboboxOptions>
</div>
</HeadlessCombobox>
)
}