176 lines
5.2 KiB
TypeScript
176 lines
5.2 KiB
TypeScript
"use client"
|
|
|
|
import { useLayoutEffect, useRef, type ComponentType, type ReactNode } from "react"
|
|
import { Check, Minus, Plus } from "lucide-react"
|
|
import { Icon } from "@iconify/react"
|
|
import { Input } from "@/components/ui/input"
|
|
import { cn } from "@/lib/utils"
|
|
import type { LabelPickerVisual } from "@/lib/label-picker-visual"
|
|
|
|
export type CatalogLabelPresence = "none" | "some" | "all"
|
|
|
|
export type LabelPickerItemComponent = ComponentType<{
|
|
children: ReactNode
|
|
onSelect?: (event: Event) => void
|
|
className?: string
|
|
}>
|
|
|
|
export function LabelPickerLeadingVisual({
|
|
visual,
|
|
}: {
|
|
visual: LabelPickerVisual
|
|
}) {
|
|
if (visual.kind === "iconify") {
|
|
return (
|
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
|
|
<Icon
|
|
icon={visual.icon}
|
|
className="size-[18px] shrink-0 text-[#5f6368]"
|
|
aria-hidden
|
|
/>
|
|
</span>
|
|
)
|
|
}
|
|
return (
|
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
|
|
<span
|
|
className={cn("block h-3 w-3 rounded-sm", visual.colorClass)}
|
|
aria-hidden
|
|
/>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function LabelPickerCheckboxVisual({
|
|
checked,
|
|
}: {
|
|
checked: boolean | "indeterminate"
|
|
}) {
|
|
return (
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"pointer-events-none inline-flex size-4 shrink-0 items-center justify-center rounded-[2.5px] border-[1.5px] border-[#c2c2c2] bg-transparent",
|
|
checked === true && "border-[#0b57d0] bg-[#0b57d0] text-white",
|
|
checked === "indeterminate" && "border-[#0b57d0] bg-[#0b57d0] text-white"
|
|
)}
|
|
>
|
|
{checked === true ? (
|
|
<Check className="size-3 stroke-[2.5] text-white" />
|
|
) : checked === "indeterminate" ? (
|
|
<Minus className="size-3 stroke-[2.5] text-white" />
|
|
) : null}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
export function EmailLabelPickerBlock({
|
|
query,
|
|
onQueryChange,
|
|
catalogLabels,
|
|
resolveLabelVisual,
|
|
Item,
|
|
getLabelPresence,
|
|
onToggleCatalogLabel,
|
|
onCreateLabel,
|
|
listClassName,
|
|
searchAutoFocus = true,
|
|
}: {
|
|
query: string
|
|
onQueryChange: (v: string) => void
|
|
catalogLabels: string[]
|
|
resolveLabelVisual: (label: string) => LabelPickerVisual
|
|
Item: LabelPickerItemComponent
|
|
getLabelPresence: (label: string) => CatalogLabelPresence
|
|
onToggleCatalogLabel: (label: string) => void
|
|
onCreateLabel: (label: string) => void
|
|
listClassName?: string
|
|
/** Focus search field when the picker mounts (submenu / sheet open). */
|
|
searchAutoFocus?: boolean
|
|
}) {
|
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
useLayoutEffect(() => {
|
|
if (!searchAutoFocus) return
|
|
let inner = 0
|
|
const outer = requestAnimationFrame(() => {
|
|
inner = requestAnimationFrame(() => {
|
|
searchInputRef.current?.focus({ preventScroll: true })
|
|
})
|
|
})
|
|
return () => {
|
|
cancelAnimationFrame(outer)
|
|
if (inner) cancelAnimationFrame(inner)
|
|
}
|
|
}, [searchAutoFocus])
|
|
const q = query.trim().toLowerCase()
|
|
const filtered = catalogLabels.filter(
|
|
(l) => q.length === 0 || l.toLowerCase().includes(q)
|
|
)
|
|
const trimmed = query.trim()
|
|
const hasExact = catalogLabels.some(
|
|
(l) => l.toLowerCase() === trimmed.toLowerCase()
|
|
)
|
|
const canCreate = trimmed.length > 0 && !hasExact
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className="shrink-0 border-b border-[#eceff1] p-2"
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
<Input
|
|
ref={searchInputRef}
|
|
value={query}
|
|
onChange={(e) => onQueryChange(e.target.value)}
|
|
placeholder="Rechercher ou créer un libellé…"
|
|
aria-label="Rechercher ou créer un libellé"
|
|
className="h-8 border-[#dadce0] text-sm shadow-none"
|
|
autoComplete="off"
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
/>
|
|
</div>
|
|
<div className={cn("min-h-0 overflow-y-auto py-1", listClassName ?? "max-h-52")}>
|
|
{canCreate ? (
|
|
<Item
|
|
onSelect={(e) => {
|
|
e.preventDefault()
|
|
onCreateLabel(trimmed)
|
|
}}
|
|
>
|
|
<Plus className="size-[18px] shrink-0 text-[#0b57d0]" strokeWidth={1.5} />
|
|
<span className="min-w-0 flex-1 text-[#0b57d0]">
|
|
Créer le libellé « {trimmed} »
|
|
</span>
|
|
</Item>
|
|
) : null}
|
|
{filtered.map((label) => {
|
|
const presence = getLabelPresence(label)
|
|
const boxChecked: boolean | "indeterminate" =
|
|
presence === "all" ? true : presence === "some" ? "indeterminate" : false
|
|
return (
|
|
<Item
|
|
key={label}
|
|
onSelect={(e) => {
|
|
e.preventDefault()
|
|
onToggleCatalogLabel(label)
|
|
}}
|
|
>
|
|
<LabelPickerCheckboxVisual checked={boxChecked} />
|
|
<LabelPickerLeadingVisual visual={resolveLabelVisual(label)} />
|
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
</Item>
|
|
)
|
|
})}
|
|
{filtered.length === 0 && !canCreate ? (
|
|
<div className="px-3 py-2 text-sm text-[#5f6368]">
|
|
Aucun libellé correspondant
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|