ultisuite-client/components/gmail/contacts-page/contact-create-page.tsx
R3D347HR4Y 6ec95262af Add OnlyOffice integration and update project configurations
- Updated .env.example to include configuration for OnlyOffice Document Server.
- Modified the workspace configuration to remove the drive-suite path.
- Adjusted TypeScript environment imports for consistency.
- Enhanced Next.js configuration to disable canvas in Webpack.
- Updated package.json to include new dependencies for OnlyOffice and PDF.js.
- Added global styles for OnlyOffice theme integration in the CSS.
- Created new layout and page components for the Drive feature, including public sharing and editing functionalities.
- Updated metadata handling across various layouts to reflect the new app structure.
2026-06-07 15:49:21 +02:00

576 lines
25 KiB
TypeScript

"use client"
import {
useState,
useEffect,
useId,
forwardRef,
useCallback,
useRef,
type InputHTMLAttributes,
} from "react"
import { useForm, useFieldArray, Controller } from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import {
ArrowLeft,
Star,
User,
Building2,
Mail,
Phone,
MapPin,
Cake,
FileText,
Plus,
ChevronDown,
ChevronUp,
X,
Check,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { toast } from "sonner"
import { useCreateContact, useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
import { fullContactToApiContact } from "@/lib/api/adapters"
import { contactApiPath } from "@/lib/contacts/contact-api-path"
import { fullContactDisplayName } from "@/lib/contacts/types"
import { ContactAvatarPicker } from "@/components/gmail/contacts/contact-avatar-picker"
import type { FullContact } from "@/lib/contacts/types"
import { useNavStore } from "@/lib/stores/nav-store"
import { cn } from "@/lib/utils"
import {
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_ICON_BTN_CLASS,
CONTACTS_PAGE_SAVE_BTN_CLASS,
CONTACTS_MENU_SURFACE_CLASS,
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
CONTACTS_PANEL_CARD_CLASS,
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS,
CONTACTS_PANEL_ICON_BTN_CLASS,
CONTACTS_PANEL_LINK_TEXT_CLASS,
CONTACTS_PANEL_MUTED_ICON_CLASS,
CONTACTS_PANEL_POPOVER_ITEM_CLASS,
CONTACTS_PANEL_SELECT_TRIGGER_CLASS,
CONTACTS_PANEL_TAG_CLASS,
} from "@/lib/contacts-chrome-classes"
const FRENCH_MONTHS = [
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre",
] as const
const EMAIL_LABELS = ["Domicile", "Travail", "Autre"] as const
const PHONE_LABELS = ["Mobile", "Domicile", "Travail"] as const
const ADDRESS_LABELS = ["Domicile", "Travail", "Autre"] as const
const addressSchema = z.object({
street: z.string().optional().default(""),
city: z.string().optional().default(""),
region: z.string().optional().default(""),
postalCode: z.string().optional().default(""),
country: z.string().optional().default(""),
label: z.string().default("Domicile"),
})
const contactFormSchema = z.object({
namePrefix: z.string().optional().default(""),
firstName: z.string().optional().default(""),
middleName: z.string().optional().default(""),
lastName: z.string().optional().default(""),
nameSuffix: z.string().optional().default(""),
phoneticFirstName: z.string().optional().default(""),
phoneticLastName: z.string().optional().default(""),
company: z.string().optional().default(""),
department: z.string().optional().default(""),
jobTitle: z.string().optional().default(""),
emails: z.array(z.object({ value: z.string(), label: z.string() })),
phones: z.array(z.object({ value: z.string(), label: z.string() })),
addresses: z.array(addressSchema),
birthday: z.object({ day: z.any().optional(), month: z.any().optional(), year: z.any().optional() }).optional(),
notes: z.string().optional().default(""),
labels: z.array(z.string()).optional().default([]),
avatarUrl: z.string().optional(),
})
type ContactFormValues = z.infer<typeof contactFormSchema>
interface ContactCreatePageProps {
mode: "create" | "edit"
contactId?: string
onBack: () => void
onSaved: (id: string) => void
}
export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactCreatePageProps) {
const { contacts, bookId } = useContactsList()
const createContactMutation = useCreateContact()
const updateContactMutation = useUpdateContact()
const labelRows = useNavStore((s) => s.labelRows)
const availableLabels = labelRows.filter((r) => r.enabled !== false)
const [starred, setStarred] = useState(false)
const [nameExpanded, setNameExpanded] = useState(false)
const [companyExpanded, setCompanyExpanded] = useState(false)
const existingContact = mode === "edit" ? contacts.find((c) => c.id === contactId) : null
const {
register,
handleSubmit,
control,
watch,
reset,
setValue,
formState: { isDirty },
} = useForm<ContactFormValues>({
resolver: zodResolver(contactFormSchema),
defaultValues: {
namePrefix: "", firstName: "", middleName: "", lastName: "", nameSuffix: "",
phoneticFirstName: "", phoneticLastName: "",
company: "", department: "", jobTitle: "",
emails: [{ value: "", label: "Domicile" }],
phones: [{ value: "", label: "Mobile" }],
addresses: [],
birthday: { day: undefined, month: undefined, year: undefined },
notes: "", labels: [],
avatarUrl: undefined,
},
})
const { fields: emailFields, append: appendEmail, remove: removeEmail } = useFieldArray({ control, name: "emails" })
const { fields: phoneFields, append: appendPhone, remove: removePhone } = useFieldArray({ control, name: "phones" })
const { fields: addressFields, append: appendAddress, remove: removeAddress } = useFieldArray({ control, name: "addresses" })
useEffect(() => {
if (existingContact) {
const hasExtendedName = !!(existingContact.namePrefix || existingContact.middleName || existingContact.nameSuffix || existingContact.phoneticFirstName || existingContact.phoneticLastName)
if (hasExtendedName) setNameExpanded(true)
if (existingContact.department) setCompanyExpanded(true)
reset({
namePrefix: existingContact.namePrefix ?? "",
firstName: existingContact.firstName,
middleName: existingContact.middleName ?? "",
lastName: existingContact.lastName,
nameSuffix: existingContact.nameSuffix ?? "",
phoneticFirstName: existingContact.phoneticFirstName ?? "",
phoneticLastName: existingContact.phoneticLastName ?? "",
company: existingContact.company ?? "",
department: existingContact.department ?? "",
jobTitle: existingContact.jobTitle ?? "",
emails: existingContact.emails.length ? existingContact.emails : [{ value: "", label: "Domicile" }],
phones: existingContact.phones.length ? existingContact.phones : [{ value: "", label: "Mobile" }],
addresses: existingContact.addresses ?? [],
birthday: existingContact.birthday ?? { day: undefined, month: undefined, year: undefined },
notes: existingContact.notes ?? "",
labels: existingContact.labels ?? [],
avatarUrl: existingContact.avatarUrl,
})
}
}, [existingContact, reset])
const firstName = watch("firstName")
const lastName = watch("lastName")
const watchedEmails = watch("emails")
const avatarUrl = watch("avatarUrl")
const currentLabels = watch("labels") ?? []
const displayName = `${firstName ?? ""} ${lastName ?? ""}`.trim()
const canSave = isDirty || (mode === "create" && !!(firstName?.trim() || lastName?.trim() || watchedEmails?.some((e) => e.value?.trim())))
const toggleLabel = useCallback((labelId: string) => {
const next = currentLabels.includes(labelId) ? currentLabels.filter((l) => l !== labelId) : [...currentLabels, labelId]
setValue("labels", next, { shouldDirty: true })
}, [currentLabels, setValue])
function onSubmit(data: ContactFormValues) {
const payload = {
namePrefix: data.namePrefix || undefined,
firstName: data.firstName ?? "",
middleName: data.middleName || undefined,
lastName: data.lastName ?? "",
nameSuffix: data.nameSuffix || undefined,
phoneticFirstName: data.phoneticFirstName || undefined,
phoneticLastName: data.phoneticLastName || undefined,
company: data.company || undefined,
department: data.department || undefined,
jobTitle: data.jobTitle || undefined,
emails: data.emails.filter((e) => e.value),
phones: data.phones.filter((p) => p.value),
addresses: data.addresses.filter((a) => a.street || a.city || a.region || a.postalCode || a.country),
birthday: data.birthday?.day || data.birthday?.month || data.birthday?.year ? data.birthday : undefined,
notes: data.notes || undefined,
labels: data.labels?.length ? data.labels : undefined,
avatarUrl: data.avatarUrl || undefined,
}
if (mode === "create") {
const tempId = crypto.randomUUID()
const fullContact: FullContact = {
id: tempId,
...payload,
firstName: payload.firstName ?? "",
lastName: payload.lastName ?? "",
emails: payload.emails ?? [],
phones: payload.phones ?? [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
createContactMutation.mutate(
{ bookId, contact: fullContactToApiContact(fullContact) },
{
onSuccess: (created) => {
onSaved(created?.uid ?? tempId)
},
onError: (err) => {
const msg = err instanceof Error && err.message ? err.message : "Impossible d'enregistrer le contact"
toast.error(msg)
},
},
)
} else if (contactId && existingContact) {
const fullContact: FullContact = {
id: contactId,
path: existingContact.path,
etag: existingContact.etag,
...payload,
firstName: payload.firstName ?? "",
lastName: payload.lastName ?? "",
emails: payload.emails ?? [],
phones: payload.phones ?? [],
createdAt: existingContact.createdAt,
updatedAt: Date.now(),
}
if (!existingContact.etag) {
toast.error("Impossible d'enregistrer : version du contact inconnue. Rechargez la liste.")
return
}
updateContactMutation.mutate(
{
path: contactApiPath(fullContact),
etag: existingContact.etag,
contact: fullContactToApiContact(fullContact),
},
{
onSuccess: () => onSaved(contactId),
onError: (err) => {
const msg = err instanceof Error && err.message ? err.message : "Impossible d'enregistrer les modifications"
toast.error(msg)
},
},
)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="mx-auto max-w-2xl px-6 py-8">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<Button type="button" variant="ghost" size="icon" className={CONTACTS_PAGE_ICON_BTN_CLASS} onClick={onBack}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="icon" className="h-10 w-10 rounded-full" onClick={() => setStarred((s) => !s)}>
<Star className={cn("h-5 w-5", starred ? "fill-yellow-400 text-yellow-400" : CONTACTS_PANEL_MUTED_ICON_CLASS)} />
</Button>
<button
type="submit"
disabled={!canSave}
className={CONTACTS_PAGE_SAVE_BTN_CLASS}
>
Enregistrer
</button>
</div>
</div>
{/* Avatar */}
<div className="mb-6 flex flex-col items-center">
<ContactAvatarPicker
variant="page"
avatarUrl={avatarUrl}
displayName={displayName}
email={watchedEmails?.find((e) => e.value?.trim())?.value}
onChange={(next) => setValue("avatarUrl", next, { shouldDirty: true })}
/>
</div>
{/* Labels */}
<div className="mb-6 flex flex-wrap items-center justify-center gap-1.5">
{currentLabels.map((labelId) => {
const row = labelRows.find((r) => r.id === labelId)
return (
<span key={labelId} className={CONTACTS_PANEL_TAG_CLASS}>
{row && (
<span className={`inline-block h-2 w-2 rounded-full ${row.color}`} />
)}
{row?.label ?? labelId}
<button type="button" onClick={() => toggleLabel(labelId)} className="text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" />
</button>
</span>
)
})}
<Popover>
<PopoverTrigger asChild>
<button type="button" className={CONTACTS_PANEL_ADD_TAG_BTN_CLASS}>
<Plus className="h-3 w-3" /> Libellé
</button>
</PopoverTrigger>
<PopoverContent
data-contacts-menu-surface
className={cn("w-52 p-1", CONTACTS_MENU_SURFACE_CLASS)}
align="center"
>
<p className={cn("px-2 py-1.5 text-xs font-medium", CONTACTS_MUTED_TEXT)}>Libellés</p>
<div className="max-h-48 overflow-y-auto">
{availableLabels.map((row) => {
const active = currentLabels.includes(row.id)
return (
<button key={row.id} type="button" onClick={() => toggleLabel(row.id)} className={CONTACTS_PANEL_POPOVER_ITEM_CLASS}>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${row.color}`} />
<span className="flex-1 truncate">{row.label}</span>
{active && <Check className="h-3.5 w-3.5 text-primary" />}
</button>
)
})}
</div>
</PopoverContent>
</Popover>
</div>
{/* Name section */}
<FormSection icon={<User className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{nameExpanded && <FloatingInput label="Titre (M., Mme...)" {...register("namePrefix")} />}
<div className="flex items-center gap-1">
<div className="flex-1"><FloatingInput label="Prénom" {...register("firstName")} /></div>
<Button type="button" variant="ghost" size="icon" className={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)} onClick={() => setNameExpanded((e) => !e)}>
{nameExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</div>
{nameExpanded && <FloatingInput label="Deuxième prénom" {...register("middleName")} />}
<FloatingInput label="Nom" {...register("lastName")} />
{nameExpanded && (
<>
<FloatingInput label="Suffixe (Jr., Sr...)" {...register("nameSuffix")} />
<FloatingInput label="Prénom phonétique" {...register("phoneticFirstName")} />
<FloatingInput label="Nom phonétique" {...register("phoneticLastName")} />
</>
)}
</FormSection>
{/* Company section */}
<FormSection icon={<Building2 className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<div className="flex items-center gap-1">
<div className="flex-1"><FloatingInput label="Entreprise" {...register("company")} /></div>
<Button type="button" variant="ghost" size="icon" className={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)} onClick={() => setCompanyExpanded((e) => !e)}>
{companyExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</div>
{companyExpanded && <FloatingInput label="Service" {...register("department")} />}
<FloatingInput label="Fonction" {...register("jobTitle")} />
</FormSection>
{/* Email section */}
<FormSection icon={<Mail className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{emailFields.map((field, index) => (
<div key={field.id} className="space-y-2">
<div className="flex items-center gap-1">
<div className="flex-1"><FloatingInput label="E-mail" type="email" {...register(`emails.${index}.value`)} /></div>
{emailFields.length > 1 && (
<Button type="button" variant="ghost" size="icon" className={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)} onClick={() => removeEmail(index)}>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
<Controller control={control} name={`emails.${index}.label`} render={({ field: f }) => (
<CompactSelect value={f.value} onValueChange={f.onChange} options={EMAIL_LABELS.map((l) => ({ value: l, label: l }))} />
)} />
</div>
))}
<AddButton onClick={() => appendEmail({ value: "", label: "Domicile" })}>Ajouter une adresse e-mail</AddButton>
</FormSection>
{/* Phone section */}
<FormSection icon={<Phone className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{phoneFields.map((field, index) => (
<div key={field.id} className="space-y-2">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded text-sm">🇫🇷</span>
<div className="flex-1"><FloatingInput label="Téléphone" type="tel" {...register(`phones.${index}.value`)} /></div>
{phoneFields.length > 1 && (
<Button type="button" variant="ghost" size="icon" className={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)} onClick={() => removePhone(index)}>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
<Controller control={control} name={`phones.${index}.label`} render={({ field: f }) => (
<CompactSelect value={f.value} onValueChange={f.onChange} options={PHONE_LABELS.map((l) => ({ value: l, label: l }))} />
)} />
</div>
))}
<AddButton onClick={() => appendPhone({ value: "", label: "Mobile" })}>Ajouter un numéro de téléphone</AddButton>
</FormSection>
{/* Address section */}
<FormSection icon={<MapPin className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{addressFields.map((field, index) => (
<div key={field.id} className={CONTACTS_PANEL_CARD_CLASS}>
<div className="flex items-center justify-between">
<Controller control={control} name={`addresses.${index}.label`} render={({ field: f }) => (
<CompactSelect value={f.value} onValueChange={f.onChange} options={ADDRESS_LABELS.map((l) => ({ value: l, label: l }))} />
)} />
<Button type="button" variant="ghost" size="icon" className={cn("h-7 w-7 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)} onClick={() => removeAddress(index)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<FloatingInput label="Rue" {...register(`addresses.${index}.street`)} />
<div className="flex gap-2">
<div className="w-24"><FloatingInput label="Code postal" {...register(`addresses.${index}.postalCode`)} /></div>
<div className="flex-1"><FloatingInput label="Ville" {...register(`addresses.${index}.city`)} /></div>
</div>
<FloatingInput label="Région / Province" {...register(`addresses.${index}.region`)} />
<FloatingInput label="Pays" {...register(`addresses.${index}.country`)} />
</div>
))}
<AddButton onClick={() => appendAddress({ street: "", city: "", region: "", postalCode: "", country: "", label: "Domicile" })}>
Ajouter une adresse
</AddButton>
</FormSection>
{/* Birthday section */}
<FormSection icon={<Cake className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<div className="flex items-stretch gap-2">
<div className="w-[72px]"><FloatingInput label="Jour" type="number" min={1} max={31} {...register("birthday.day", { valueAsNumber: true })} /></div>
<div className="flex-1">
<Controller control={control} name="birthday.month" render={({ field: f }) => (
<CompactSelect value={f.value ? String(f.value) : ""} onValueChange={(v) => f.onChange(v ? Number(v) : undefined)} options={FRENCH_MONTHS.map((name, i) => ({ value: String(i + 1), label: name }))} placeholder="Mois" />
)} />
</div>
<div className="w-24"><FloatingInput label="Année" type="number" min={1900} max={2100} {...register("birthday.year", { valueAsNumber: true })} /></div>
</div>
</FormSection>
{/* Notes section */}
<FormSection icon={<FileText className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<FloatingTextarea label="Notes" {...register("notes")} />
</FormSection>
<div className="h-12" />
</form>
)
}
function FormSection({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className="flex gap-4 py-3">
<div className="flex w-6 shrink-0 pt-2">{icon}</div>
<div className="flex-1 space-y-2">{children}</div>
</div>
)
}
function AddButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<button type="button" onClick={onClick} className={cn("flex items-center gap-2 py-1", CONTACTS_PANEL_LINK_TEXT_CLASS, "hover:text-primary/80")}>
<Plus className="h-4 w-4" />{children}
</button>
)
}
interface FloatingInputProps extends InputHTMLAttributes<HTMLInputElement> { label: string }
const FloatingInput = forwardRef<HTMLInputElement, FloatingInputProps>(
function FloatingInput({ label, className, defaultValue, ...props }, ref) {
const id = useId()
const [focused, setFocused] = useState(false)
const [filled, setFilled] = useState(() => !!defaultValue)
const innerRef = useRef<HTMLInputElement | null>(null)
useEffect(() => { if (innerRef.current && innerRef.current.value) setFilled(true) })
const setRefs = useCallback((node: HTMLInputElement | null) => {
innerRef.current = node
if (typeof ref === "function") ref(node); else if (ref) ref.current = node
if (node && node.value) setFilled(true)
}, [ref])
const floated = focused || filled
return (
<div className="relative">
<input ref={setRefs} id={id} {...props} defaultValue={defaultValue}
className={cn(CONTACTS_PANEL_FLOATING_INPUT_CLASS, className)}
onFocus={(e) => { setFocused(true); props.onFocus?.(e) }}
onBlur={(e) => { setFocused(false); setFilled(!!e.target.value); props.onBlur?.(e) }}
onChange={(e) => { setFilled(!!e.target.value); props.onChange?.(e) }}
/>
<label htmlFor={id} className={cn(CONTACTS_PANEL_FLOATING_LABEL_CLASS, floated ? "top-0.5 px-0.5 text-[10px] leading-tight" : "top-[11px] text-sm", focused ? "text-primary" : "text-muted-foreground")}>
{label}
</label>
</div>
)
}
)
interface FloatingTextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { label: string }
const FloatingTextarea = forwardRef<HTMLTextAreaElement, FloatingTextareaProps>(
function FloatingTextarea({ label, className, ...props }, ref) {
const id = useId()
const [focused, setFocused] = useState(false)
const [filled, setFilled] = useState(false)
const innerRef = useRef<HTMLTextAreaElement | null>(null)
useEffect(() => { if (innerRef.current && innerRef.current.value) setFilled(true) })
const setRefs = useCallback((node: HTMLTextAreaElement | null) => {
innerRef.current = node
if (typeof ref === "function") ref(node); else if (ref) ref.current = node
if (node && node.value) setFilled(true)
}, [ref])
const floated = focused || filled
return (
<div className="relative">
<textarea ref={setRefs} id={id} rows={3} {...props}
className={cn(CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS, className)}
onFocus={(e) => { setFocused(true); props.onFocus?.(e) }}
onBlur={(e) => { setFocused(false); setFilled(!!e.target.value); props.onBlur?.(e) }}
onChange={(e) => { setFilled(!!e.target.value); props.onChange?.(e) }}
/>
<label htmlFor={id} className={cn(CONTACTS_PANEL_FLOATING_LABEL_CLASS, floated ? "top-1 px-0.5 text-[10px] leading-tight" : "top-2.5 text-sm", focused ? "text-primary" : "text-muted-foreground")}>
{label}
</label>
</div>
)
}
)
function CompactSelect({ value, onValueChange, options, placeholder }: { value: string; onValueChange: (v: string) => void; options: { value: string; label: string }[]; placeholder?: string }) {
return (
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger className={CONTACTS_PANEL_SELECT_TRIGGER_CLASS}>
<SelectValue placeholder={placeholder ?? "Choisir..."} />
</SelectTrigger>
<SelectContent data-contacts-menu-surface className={CONTACTS_MENU_SURFACE_CLASS}>
{options.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
</SelectContent>
</Select>
)
}