Files
shop-admin/src/components/MultiSelectSearchable.tsx
T
hamid zarghami e8c657f573 announcement
2025-10-12 09:46:03 +03:30

204 lines
8.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.
import React, { useState, useMemo } from 'react'
import { Check, ChevronsUpDown, X, Search } from 'lucide-react'
import { cn } from '../lib/utils'
import { Button } from './ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from './ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from './ui/popover'
export type MultiSelectOption = {
value: string
label: string
}
type MultiSelectSearchableProps = {
options: MultiSelectOption[]
value: string[]
onChange: (value: string[]) => void
onSearch?: (searchTerm: string) => void
onOpenChange?: (open: boolean) => void
placeholder?: string
label?: string
error_text?: string
disabled?: boolean
loading?: boolean
}
const MultiSelectSearchable: React.FC<MultiSelectSearchableProps> = ({
options,
value,
onChange,
onSearch,
onOpenChange,
placeholder = "انتخاب کنید...",
label,
error_text,
disabled = false,
loading = false,
}) => {
const [open, setOpen] = useState(false)
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen)
onOpenChange?.(newOpen)
}
const selectedLabels = useMemo(() => {
return value
.map(v => options.find(option => option.value === v)?.label)
.filter(Boolean)
}, [value, options])
const handleSelect = (currentValue: string) => {
const newValue = value.includes(currentValue)
? value.filter(v => v !== currentValue)
: [...value, currentValue]
onChange(newValue)
}
const handleRemove = (itemValue: string) => {
onChange(value.filter(v => v !== itemValue))
}
return (
<div className="w-full">
{label && (
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 block mb-1">
{label}
</label>
)}
<Popover
open={open}
onOpenChange={handleOpenChange}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"w-full justify-between min-h-10 h-auto",
!value.length && "text-muted-foreground",
error_text && "border-red-500"
)}
disabled={disabled || loading}
>
<div className="flex flex-wrap gap-1 flex-1 text-left min-h-[20px]">
{selectedLabels.length > 0 ? (
selectedLabels.map((label, index) => (
<span
key={index}
className="inline-flex items-center gap-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-xs max-w-[200px]"
title={label}
>
<span className="truncate">{label}</span>
<X
color='red'
className="h-3 w-3 cursor-pointer hover:text-destructive shrink-0"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleRemove(value[index])
}}
/>
</span>
))
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
</div>
<ChevronsUpDown color='gray' className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] max-w-[400px] p-0 z-[70]"
align="start"
onKeyDown={(e) => {
// Prevent event bubbling that might cause navigation
e.stopPropagation()
}}
>
<Command
shouldFilter={false}
onKeyDown={(e) => {
// Prevent any navigation or form submission from command
e.stopPropagation()
}}
>
<div className="flex h-9 items-center gap-2 border-b px-3">
<Search color='gray' className="size-4 shrink-0 opacity-50" />
<input
type="text"
placeholder="جستجو..."
className="placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
onChange={(e) => {
e.preventDefault()
onSearch?.(e.target.value)
}}
onKeyDown={(e) => {
// Prevent form submission or other default behaviors
if (e.key === 'Enter') {
e.preventDefault()
e.stopPropagation()
}
}}
/>
</div>
<CommandList className="max-h-[200px]">
{loading && options.length === 0 && (
<div className="flex items-center justify-center py-6">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900"></div>
<span className="ml-2 text-sm text-muted-foreground">در حال بارگذاری...</span>
</div>
)}
<CommandEmpty>موردی یافت نشد.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = value.includes(option.value)
return (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => {
handleSelect(option.value)
}}
className="flex items-center"
>
<Check
color='gray'
className={cn(
"mr-2 h-4 w-4 shrink-0",
isSelected ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate flex-1" title={option.label}>
{option.label}
</span>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{error_text && (
<div className="text-red-500 text-xs mt-1">{error_text}</div>
)}
</div>
)
}
export default MultiSelectSearchable