first commit
This commit is contained in:
164
apps/web/src/components/ui/multi-select.tsx
Normal file
164
apps/web/src/components/ui/multi-select.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface Option {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
options: Option[]
|
||||
selected: string[]
|
||||
onChange: (selected: string[]) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function MultiSelect({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
placeholder = "Select items...",
|
||||
className,
|
||||
disabled = false,
|
||||
}: MultiSelectProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
|
||||
const handleUnselect = (item: string) => {
|
||||
onChange(selected.filter((i) => i !== item))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.value === "") {
|
||||
if (e.key === "Backspace") {
|
||||
onChange(selected.slice(0, -1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectables = options.filter((option) => !selected.includes(option.value))
|
||||
|
||||
// Handle creating new option when user types something not in the list
|
||||
const handleSelect = (value: string) => {
|
||||
if (value === inputValue && !options.find(option => option.value === value)) {
|
||||
// Create new option
|
||||
onChange([...selected, value])
|
||||
} else {
|
||||
onChange([...selected, value])
|
||||
}
|
||||
setInputValue("")
|
||||
}
|
||||
|
||||
return (
|
||||
<Command
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn("overflow-visible bg-transparent", className)}
|
||||
>
|
||||
<div className="group rounded-md border border-input px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selected.map((item) => {
|
||||
const option = options.find((opt) => opt.value === item)
|
||||
return (
|
||||
<Badge key={item} variant="secondary">
|
||||
{option?.label || item}
|
||||
<button
|
||||
className="ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleUnselect(item)
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={() => handleUnselect(item)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
<CommandInput
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
onBlur={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="ml-2 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-2">
|
||||
<CommandList>
|
||||
{open && (inputValue.length > 0 || selectables.length > 0) ? (
|
||||
<div className="absolute top-0 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
|
||||
<CommandGroup className="h-full overflow-auto">
|
||||
{/* Show option to create new category if input doesn't match existing options */}
|
||||
{inputValue.length > 0 && !options.find(option =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()) ||
|
||||
option.value.toLowerCase().includes(inputValue.toLowerCase())
|
||||
) && (
|
||||
<CommandItem
|
||||
key={inputValue}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onSelect={() => {
|
||||
handleSelect(inputValue)
|
||||
setOpen(false)
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Create "{inputValue}"
|
||||
</CommandItem>
|
||||
)}
|
||||
|
||||
{/* Show existing options that match the search */}
|
||||
{selectables
|
||||
.filter(option =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()) ||
|
||||
option.value.toLowerCase().includes(inputValue.toLowerCase())
|
||||
)
|
||||
.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onSelect={() => {
|
||||
handleSelect(option.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
{selectables.length === 0 && inputValue.length === 0 && (
|
||||
<CommandEmpty>No more options available.</CommandEmpty>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</div>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</div>
|
||||
</Command>
|
||||
)
|
||||
}
|
||||
|
||||
export { MultiSelect }
|
||||
Reference in New Issue
Block a user