- 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
165 lines
5.7 KiB
TypeScript
Executable File
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 }
|