You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
155 lines
5.7 KiB
155 lines
5.7 KiB
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useInfiniteQuery } from '@tanstack/react-query'
|
|
import { Ic } from '@/icons'
|
|
import { cn } from '@/lib/utils'
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
|
|
type SingleSelectProps = {
|
|
label?: string
|
|
items?: string[]
|
|
value: string
|
|
onChange: (value: string) => void
|
|
placeholder?: string
|
|
searchPlaceholder?: string
|
|
emptyLabel?: string
|
|
className?: string
|
|
contentClassName?: string
|
|
searchable?: boolean
|
|
queryKey?: readonly unknown[]
|
|
loadOptions?: (params: { query: string; page: number }) => Promise<{ items: string[]; nextPage?: number }>
|
|
}
|
|
|
|
export default function SingleSelect({
|
|
label,
|
|
items = [],
|
|
value,
|
|
onChange,
|
|
placeholder = 'انتخاب کنید…',
|
|
searchPlaceholder = 'جستجو…',
|
|
emptyLabel,
|
|
className,
|
|
contentClassName,
|
|
searchable = true,
|
|
queryKey,
|
|
loadOptions,
|
|
}: SingleSelectProps) {
|
|
const [open, setOpen] = useState(false)
|
|
const [query, setQuery] = useState('')
|
|
const searchRef = useRef<HTMLInputElement>(null)
|
|
const listRef = useRef<HTMLDivElement>(null)
|
|
|
|
const infinite = useInfiniteQuery({
|
|
queryKey: [...(queryKey ?? ['single-select-options']), query],
|
|
queryFn: ({ pageParam }) => loadOptions!({ query, page: pageParam }),
|
|
initialPageParam: 1,
|
|
getNextPageParam: (lastPage) => lastPage.nextPage,
|
|
enabled: open && !!loadOptions,
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (open && searchable) setTimeout(() => searchRef.current?.focus(), 50)
|
|
}, [open, searchable])
|
|
|
|
const setPopoverOpen = (nextOpen: boolean) => {
|
|
if (!nextOpen) setQuery('')
|
|
setOpen(nextOpen)
|
|
}
|
|
|
|
const options = useMemo(() => {
|
|
if (loadOptions) return infinite.data?.pages.flatMap((page) => page.items) ?? []
|
|
return query ? items.filter((item) => item.includes(query)) : items
|
|
}, [items, query, loadOptions, infinite.data])
|
|
|
|
const renderedOptions = emptyLabel ? [emptyLabel, ...options] : options
|
|
const selectedLabel = value || ''
|
|
|
|
const select = (item: string) => {
|
|
onChange(item === emptyLabel ? '' : item)
|
|
setOpen(false)
|
|
}
|
|
|
|
const onListScroll = () => {
|
|
const list = listRef.current
|
|
if (!list || !loadOptions || !infinite.hasNextPage || infinite.isFetchingNextPage) return
|
|
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 24) infinite.fetchNextPage()
|
|
}
|
|
|
|
return (
|
|
<div className={cn(className)}>
|
|
{label && <p className="mb-1.5 text-[12px] font-semibold text-grey-2">{label}</p>}
|
|
|
|
<Popover open={open} onOpenChange={setPopoverOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'flex h-11 w-full items-center gap-2 rounded-md border border-border-soft bg-input px-3.5 text-right text-[13.5px] outline-none transition-colors hover:bg-white/9',
|
|
open && 'border-red-mid/50',
|
|
)}
|
|
>
|
|
<span className={cn('min-w-0 flex-1 truncate', selectedLabel ? 'text-foreground' : 'text-grey-4')}>
|
|
{selectedLabel || placeholder}
|
|
</span>
|
|
<Ic name="chevDown" className="size-4 shrink-0 opacity-60" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
|
|
<PopoverContent
|
|
align="start"
|
|
sideOffset={6}
|
|
className={cn(
|
|
'z-[100] max-h-56 w-[var(--radix-popover-trigger-width)] overflow-y-hidden rounded-lg border border-border-soft bg-[#27282e] p-0 shadow-[0_16px_48px_rgba(0,0,0,.5)]',
|
|
contentClassName,
|
|
)}
|
|
>
|
|
{searchable && (
|
|
<div className="border-b border-border-soft p-2">
|
|
<input
|
|
ref={searchRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={searchPlaceholder}
|
|
className="w-full rounded-md border border-border-soft bg-white/5 px-3 py-1.5 text-[12.5px] text-grey-2 outline-none transition-colors placeholder:text-grey-4 focus:border-red-mid/50"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={listRef} onScroll={onListScroll} className="max-h-48 overflow-y-auto p-1.5">
|
|
{infinite.isLoading ? (
|
|
<p className="px-3 py-4 text-center text-[12px] text-grey-4">در حال بارگذاری…</p>
|
|
) : renderedOptions.length === 0 ? (
|
|
<p className="px-3 py-4 text-center text-[12px] text-grey-4">نتیجهای یافت نشد</p>
|
|
) : (
|
|
renderedOptions.map((item) => {
|
|
const active = item === selectedLabel || (item === emptyLabel && !selectedLabel)
|
|
return (
|
|
<button
|
|
key={item}
|
|
type="button"
|
|
onClick={() => select(item)}
|
|
className={cn(
|
|
'flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-[13px] transition-colors hover:bg-white/8',
|
|
active ? 'text-foreground' : 'text-grey-2',
|
|
)}
|
|
>
|
|
<span className="flex size-4 shrink-0 items-center justify-center text-red-mid">
|
|
{active && <Ic name="check" className="size-3.5" />}
|
|
</span>
|
|
{item}
|
|
</button>
|
|
)
|
|
})
|
|
)}
|
|
{infinite.isFetchingNextPage && (
|
|
<div className="flex items-center justify-center gap-2 px-3 py-2 text-[11.5px] text-grey-3">
|
|
<span className="size-3 animate-spin rounded-full border border-white/20 border-t-red-mid" />
|
|
بارگذاری موارد بیشتر…
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
)
|
|
}
|