144 lines
4.2 KiB
TypeScript
144 lines
4.2 KiB
TypeScript
import * as React from "react"
|
|
import { IconX } from "@tabler/icons-react"
|
|
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Input } from "@/components/ui/input"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
type MultiValueInputProps = {
|
|
values: string[]
|
|
onChange: (values: string[]) => void
|
|
placeholder?: string
|
|
disabled?: boolean
|
|
maxItems?: number
|
|
addOnBlur?: boolean
|
|
className?: string
|
|
inputClassName?: string
|
|
validate?: (value: string) => string | null
|
|
format?: (value: string) => string
|
|
emptyState?: React.ReactNode
|
|
}
|
|
|
|
export function MultiValueInput({
|
|
values,
|
|
onChange,
|
|
placeholder,
|
|
disabled,
|
|
maxItems,
|
|
addOnBlur,
|
|
className,
|
|
inputClassName,
|
|
validate,
|
|
format,
|
|
emptyState,
|
|
}: MultiValueInputProps) {
|
|
const [pending, setPending] = React.useState("")
|
|
const [error, setError] = React.useState<string | null>(null)
|
|
const inputRef = React.useRef<HTMLInputElement | null>(null)
|
|
|
|
const remainingSlots = typeof maxItems === "number" ? Math.max(maxItems - values.length, 0) : undefined
|
|
const canAdd = remainingSlots === undefined || remainingSlots > 0
|
|
|
|
const addValue = React.useCallback(
|
|
(raw: string) => {
|
|
const trimmed = raw.trim()
|
|
if (!trimmed) return
|
|
const formatted = format ? format(trimmed) : trimmed
|
|
if (values.includes(formatted)) {
|
|
setPending("")
|
|
setError(null)
|
|
return
|
|
}
|
|
if (validate) {
|
|
const validation = validate(formatted)
|
|
if (validation) {
|
|
setError(validation)
|
|
return
|
|
}
|
|
}
|
|
setError(null)
|
|
onChange([...values, formatted])
|
|
setPending("")
|
|
},
|
|
[format, onChange, validate, values]
|
|
)
|
|
|
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (event.key === "Enter" || event.key === "," || event.key === "Tab") {
|
|
if (!canAdd) return
|
|
event.preventDefault()
|
|
addValue(pending)
|
|
}
|
|
if (event.key === "Backspace" && pending.length === 0 && values.length > 0) {
|
|
onChange(values.slice(0, -1))
|
|
setError(null)
|
|
}
|
|
}
|
|
|
|
const handleBlur = () => {
|
|
if (addOnBlur && pending.trim()) {
|
|
addValue(pending)
|
|
}
|
|
}
|
|
|
|
const removeValue = (value: string) => {
|
|
onChange(values.filter((item) => item !== value))
|
|
setError(null)
|
|
inputRef.current?.focus()
|
|
}
|
|
|
|
return (
|
|
<div className={cn("space-y-2", className)}>
|
|
<div
|
|
className={cn(
|
|
"flex flex-wrap gap-2 rounded-md border border-input bg-background px-3 py-2 focus-within:ring-2 focus-within:ring-ring",
|
|
disabled && "opacity-60"
|
|
)}
|
|
>
|
|
{values.map((value) => (
|
|
<Badge
|
|
key={value}
|
|
variant="secondary"
|
|
className="flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium"
|
|
>
|
|
<span>{value}</span>
|
|
<button
|
|
type="button"
|
|
className="flex h-4 w-4 items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80"
|
|
onClick={() => removeValue(value)}
|
|
aria-label={`Remover ${value}`}
|
|
disabled={disabled}
|
|
>
|
|
<IconX className="h-3 w-3" strokeWidth={2} />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
{canAdd ? (
|
|
<Input
|
|
ref={inputRef}
|
|
value={pending}
|
|
onChange={(event) => {
|
|
setPending(event.target.value)
|
|
setError(null)
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
onBlur={handleBlur}
|
|
placeholder={values.length === 0 ? placeholder : undefined}
|
|
disabled={disabled}
|
|
className={cn(
|
|
"m-0 h-auto min-w-[8rem] flex-1 border-0 bg-transparent px-0 py-1 text-sm shadow-none focus-visible:ring-0",
|
|
inputClassName
|
|
)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
{error ? <p className="text-xs font-medium text-destructive">{error}</p> : null}
|
|
{!values.length && emptyState ? <div className="text-xs text-muted-foreground">{emptyState}</div> : null}
|
|
{typeof remainingSlots === "number" ? (
|
|
<div className="text-right text-[11px] text-muted-foreground">
|
|
{remainingSlots} item{remainingSlots === 1 ? "" : "s"} restantes
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|