This commit is contained in:
2026-04-07 06:32:40 +03:00
parent 24a44481a0
commit 11db1e6941
101 changed files with 54585 additions and 39160 deletions
@@ -0,0 +1,337 @@
"use client"
import { useState } from "react"
import {
useFormContext,
useController,
type FieldValues,
type FieldPath,
} from "react-hook-form"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Check, PlusIcon, X } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { LABEL_ROUTES } from "@garage/api"
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/components/ui/popover"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Field,
FieldLabel,
FieldError,
FieldDescription,
} from "@/shared/components/ui/field"
import { cn } from "@/shared/lib/utils"
// ── Types ──
export type LabelItem = {
id: number
title: string
color_code: string
}
type RhfLabelPickerFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
// ── Helpers ──
function extractLabels(response: unknown): LabelItem[] {
if (Array.isArray(response)) return response
const obj = response as any
if (Array.isArray(obj?.data?.data)) return obj.data.data
if (Array.isArray(obj?.data)) return obj.data
return []
}
// ── Component ──
export function RhfLabelPickerField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label,
description,
required,
disabled,
placeholder = "Select labels...",
}: RhfLabelPickerFieldProps<TValues, TName>) {
const api = useAuthApi()
const queryClient = useQueryClient()
const { control } = useFormContext<TValues>()
const { field, fieldState: { error } } = useController({ name, control, disabled })
const [open, setOpen] = useState(false)
const [search, setSearch] = useState("")
const [creating, setCreating] = useState(false)
const [newTitle, setNewTitle] = useState("")
const [newColor, setNewColor] = useState("#6366f1")
const [isSubmitting, setIsSubmitting] = useState(false)
const { data: allLabels = [] } = useQuery<LabelItem[]>({
queryKey: [LABEL_ROUTES.INDEX],
queryFn: async () => {
const res = await api.labels.list()
return extractLabels(res)
},
staleTime: 5 * 60 * 1000,
})
const selected: LabelItem[] = field.value ?? []
const filtered = search
? allLabels.filter((l) =>
l.title.toLowerCase().includes(search.toLowerCase()),
)
: allLabels
function toggle(lbl: LabelItem) {
const isSelected = selected.some((s) => s.id === lbl.id)
if (isSelected) {
field.onChange(selected.filter((s) => s.id !== lbl.id))
} else {
field.onChange([...selected, lbl])
}
}
function remove(id: number, e: React.MouseEvent) {
e.stopPropagation()
field.onChange(selected.filter((s) => s.id !== id))
}
async function handleCreate() {
if (!newTitle.trim()) return
setIsSubmitting(true)
try {
const res = await api.labels.create({
title: newTitle.trim(),
color_code: newColor,
}) as any
const created = res?.data ?? res
queryClient.invalidateQueries({ queryKey: [LABEL_ROUTES.INDEX] })
if (created?.id) {
field.onChange([
...selected,
{
id: created.id,
title: created.title ?? newTitle.trim(),
color_code: created.color_code ?? newColor,
},
])
}
setNewTitle("")
setNewColor("#6366f1")
setCreating(false)
} catch {
// silent – toast handled upstream if desired
} finally {
setIsSubmitting(false)
}
}
return (
<Field data-invalid={!!error || undefined}>
{label && (
<FieldLabel>
{label}
{required && (
<span className="text-destructive ms-0.5">*</span>
)}
</FieldLabel>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div
role="combobox"
aria-expanded={open}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setOpen((v) => !v)
}
}}
className={cn(
"flex min-h-9 w-full cursor-pointer flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1.5 text-sm transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
disabled && "cursor-not-allowed opacity-50",
error && "border-destructive",
)}
>
{selected.length === 0 && (
<span className="text-muted-foreground">
{placeholder}
</span>
)}
{selected.map((s) => (
<span
key={s.id}
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: s.color_code + "28",
borderColor: s.color_code + "80",
color: s.color_code,
}}
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: s.color_code }}
/>
{s.title}
<button
type="button"
className="ml-0.5 rounded-full opacity-70 hover:opacity-100"
onClick={(e) => remove(s.id, e)}
tabIndex={-1}
aria-label={`Remove ${s.title}`}
>
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
</div>
</PopoverTrigger>
<PopoverContent
className="flex w-64 flex-col overflow-hidden p-0"
align="start"
style={{
maxHeight:
"var(--radix-popover-content-available-height, 320px)",
}}
>
{/* Search */}
<div className="shrink-0 p-2">
<Input
placeholder="Search labels..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8"
/>
</div>
{/* List */}
<ScrollArea className="min-h-0 flex-1">
<div className="px-1 pb-1">
{filtered.length === 0 && (
<p className="py-4 text-center text-xs text-muted-foreground">
No labels found
</p>
)}
{filtered.map((lbl) => {
const isSelected = selected.some(
(s) => s.id === lbl.id,
)
return (
<button
key={lbl.id}
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => toggle(lbl)}
>
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{
backgroundColor: lbl.color_code,
}}
/>
<span className="flex-1 text-start">
{lbl.title}
</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</button>
)
})}
</div>
</ScrollArea>
{/* Footer: create */}
<div className="shrink-0 border-t p-2">
{creating ? (
<div className="flex flex-col gap-2">
<Input
placeholder="Label name"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
className="h-8"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleCreate()
}
if (e.key === "Escape") setCreating(false)
}}
autoFocus
/>
<div className="flex items-center gap-2">
<input
type="color"
value={newColor}
onChange={(e) =>
setNewColor(e.target.value)
}
className="h-8 w-8 cursor-pointer rounded border p-0.5"
title="Pick a color"
/>
<span className="flex-1 text-xs text-muted-foreground">
Color
</span>
<Button
type="button"
size="sm"
className="h-7 text-xs"
onClick={handleCreate}
disabled={
isSubmitting || !newTitle.trim()
}
>
{isSubmitting ? "..." : "Create"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={() => setCreating(false)}
>
Cancel
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-full justify-start gap-1.5 text-xs"
onClick={() => setCreating(true)}
>
<PlusIcon className="h-3.5 w-3.5" />
Create label
</Button>
)}
</div>
</PopoverContent>
</Popover>
{description && (
<FieldDescription>{description}</FieldDescription>
)}
{error && <FieldError>{error.message}</FieldError>}
</Field>
)
}