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

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