Files
tabungin/apps/web/src/components/ui/multi-select.tsx
Dwindi Ramadhana 6a6e74562c checkpoint: goals feature, wallet balance, and goals/wallet detail UI
- Add goals feature (models, migrations, API, web pages)
- Add reserved/centralized wallet balance service
- Add wallet detail page and overview components
- Add new UI components (progress, multi-select, FAB)
- Remove stray empty -H/-d files from working tree
2026-06-17 20:40:00 +07:00

165 lines
5.7 KiB
TypeScript
Executable File

"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 min-h-[44px] md:min-h-[36px] text-base md: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 min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm"
>
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 min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm"
>
{option.label}
</CommandItem>
))}
{selectables.length === 0 && inputValue.length === 0 && (
<CommandEmpty>No more options available.</CommandEmpty>
)}
</CommandGroup>
</div>
) : null}
</CommandList>
</div>
</Command>
)
}
export { MultiSelect }