231 lines
7.3 KiB
TypeScript
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>
|
|
)
|
|
} |