ultisuite-client/components/agenda/agenda-guest-picker.tsx
R3D347HR4Y 3bbf3691b0
Some checks failed
E2E / Playwright e2e (push) Has been cancelled
bordel c'est beau
2026-06-11 10:10:39 +02:00

182 lines
6.3 KiB
TypeScript

"use client"
import { useMemo, useRef, useState } from "react"
import { X } from "lucide-react"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import { Input } from "@/components/ui/input"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { fullContactDisplayName } from "@/lib/contacts/types"
import type { AgendaEventAttendee } from "@/lib/agenda/agenda-types"
import { cn } from "@/lib/utils"
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
interface Suggestion {
email: string
name: string
avatarUrl?: string
}
/** Champ « Ajouter des invités » avec autocomplétion sur le carnet d'adresses. */
export function AgendaGuestPicker({
attendees,
onChange,
organizerEmail,
}: {
attendees: AgendaEventAttendee[]
onChange: (attendees: AgendaEventAttendee[]) => void
organizerEmail?: string
}) {
const [query, setQuery] = useState("")
const [focused, setFocused] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const blurTimer = useRef<number | null>(null)
const { contacts } = useContactsList()
const suggestions: Suggestion[] = useMemo(() => {
const q = query.trim().toLowerCase()
if (q.length < 1) return []
const taken = new Set(attendees.map((a) => a.email.toLowerCase()))
if (organizerEmail) taken.add(organizerEmail.toLowerCase())
const out: Suggestion[] = []
for (const contact of contacts) {
const name = fullContactDisplayName(contact)
for (const { value: email } of contact.emails) {
if (!email || taken.has(email.toLowerCase())) continue
if (
name.toLowerCase().includes(q) ||
email.toLowerCase().includes(q)
) {
out.push({ email, name: name || email, avatarUrl: contact.avatarUrl })
}
}
if (out.length >= 6) break
}
return out
}, [contacts, query, attendees, organizerEmail])
const addAttendee = (s: Suggestion) => {
onChange([...attendees, { email: s.email, name: s.name, status: "NEEDS-ACTION" }])
setQuery("")
setActiveIndex(0)
}
const tryAddRaw = () => {
const email = query.trim().replace(/[,;]$/, "")
if (!EMAIL_RE.test(email)) return false
if (attendees.some((a) => a.email.toLowerCase() === email.toLowerCase())) {
setQuery("")
return true
}
addAttendee({ email, name: email })
return true
}
const showSuggestions = focused && (suggestions.length > 0 || EMAIL_RE.test(query.trim()))
return (
<div className="flex flex-col gap-1.5">
<div className="relative">
<Input
value={query}
placeholder="Ajouter des invités"
onChange={(e) => {
setQuery(e.target.value)
setActiveIndex(0)
}}
onFocus={() => {
if (blurTimer.current) window.clearTimeout(blurTimer.current)
setFocused(true)
}}
onBlur={() => {
blurTimer.current = window.setTimeout(() => setFocused(false), 150)
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault()
setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1))
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActiveIndex((i) => Math.max(i - 1, 0))
} else if (e.key === "Enter") {
e.preventDefault()
if (suggestions[activeIndex]) addAttendee(suggestions[activeIndex])
else tryAddRaw()
} else if ((e.key === "," || e.key === ";") && query.trim()) {
e.preventDefault()
tryAddRaw()
}
}}
/>
{showSuggestions && (
<div className="absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-lg border border-border/60 bg-popover py-1 shadow-lg">
{suggestions.map((s, i) => (
<button
key={s.email}
type="button"
className={cn(
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left",
i === activeIndex ? "bg-mail-nav-hover" : "hover:bg-mail-nav-hover",
)}
onMouseDown={(e) => {
e.preventDefault()
addAttendee(s)
}}
>
<ContactAvatar name={s.name} email={s.email} avatarUrl={s.avatarUrl} size="xs" />
<span className="min-w-0">
<span className="block truncate text-sm">{s.name}</span>
<span className="block truncate text-xs text-muted-foreground">
{s.email}
</span>
</span>
</button>
))}
{suggestions.length === 0 && EMAIL_RE.test(query.trim()) && (
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-mail-nav-hover"
onMouseDown={(e) => {
e.preventDefault()
tryAddRaw()
}}
>
Ajouter « {query.trim()} »
</button>
)}
</div>
)}
</div>
{attendees.length > 0 && (
<div className="flex flex-col gap-1">
{attendees.map((a) => (
<div
key={a.email}
className="group flex items-center gap-2.5 rounded-lg px-1.5 py-1 hover:bg-mail-nav-hover"
>
<ContactAvatar name={a.name || a.email} email={a.email} size="xs" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{a.name || a.email}</span>
{a.name && a.name !== a.email && (
<span className="block truncate text-xs text-muted-foreground">
{a.email}
</span>
)}
</span>
<button
type="button"
aria-label={`Retirer ${a.email}`}
className="rounded-full p-1 text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-black/5 dark:hover:bg-white/10"
onClick={() => onChange(attendees.filter((x) => x.email !== a.email))}
>
<X className="size-4" />
</button>
</div>
))}
</div>
)}
</div>
)
}