ultisuite-client/components/gmail/email-label-picker-block.tsx
2026-05-16 20:30:50 +02:00

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>
</>
)
}