ultisuite-client/components/agenda/agenda-settings-chip-picker.tsx
R3D347HR4Y ad1370ea7e
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: enhance configuration and add new demo layouts
- Introduced turbopack alias for canvas in next.config.mjs.
- Updated package.json scripts for development and branding tasks.
- Added new dependencies for Tiptap extensions.
- Implemented new demo layouts for agenda, contacts, drive, and mail applications.
- Enhanced globals.css for improved theming and splash screen animations.
- Added OAuth callback handling for drive mounts.
- Updated layout components to integrate new demo shells and improve structure.
2026-06-12 19:10:24 +02:00

173 lines
5.6 KiB
TypeScript

"use client"
import { useMemo, useRef, useState } from "react"
import { X } from "lucide-react"
import { Input } from "@/components/ui/input"
import type { AgendaInvitationExclusion } from "@/lib/agenda/agenda-settings-types"
import { exclusionKey } from "@/lib/agenda/agenda-settings-types"
import { useAgendaInvitationSuggestions } from "@/lib/agenda/use-agenda-invitation-suggestions"
import { cn } from "@/lib/utils"
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export function AgendaSettingsChipPicker({
items,
onChange,
placeholder,
allowedTypes,
emptyHint,
}: {
items: AgendaInvitationExclusion[]
onChange: (items: AgendaInvitationExclusion[]) => void
placeholder: string
allowedTypes?: AgendaInvitationExclusion["type"][]
emptyHint?: string
}) {
const [query, setQuery] = useState("")
const [focused, setFocused] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const blurTimer = useRef<number | null>(null)
const taken = useMemo(() => new Set(items.map(exclusionKey)), [items])
const suggestions = useAgendaInvitationSuggestions(query, taken, {
types: allowedTypes,
})
const addItem = (item: AgendaInvitationExclusion) => {
if (taken.has(exclusionKey(item))) return
onChange([...items, item])
setQuery("")
setActiveIndex(0)
}
const removeItem = (id: string) => {
onChange(items.filter((item) => exclusionKey(item) !== id))
}
const tryAddEmail = () => {
const email = query.trim().replace(/[,;]$/, "")
if (!EMAIL_RE.test(email)) return false
addItem({ type: "email", value: email, label: email })
return true
}
const showSuggestions =
focused && (suggestions.length > 0 || EMAIL_RE.test(query.trim()))
const grouped = useMemo(() => {
const map = new Map<string, typeof suggestions>()
for (const s of suggestions) {
const list = map.get(s.group) ?? []
list.push(s)
map.set(s.group, list)
}
return [...map.entries()]
}, [suggestions])
let flatIndex = -1
return (
<div className="flex flex-col gap-1.5">
<div
className={cn(
"min-h-9 rounded-md border border-input bg-background px-2 py-1.5",
"focus-within:ring-2 focus-within:ring-ring/40",
)}
>
{items.length > 0 ? (
<div className="mb-1 flex flex-wrap gap-1">
{items.map((item) => (
<span
key={exclusionKey(item)}
className="inline-flex max-w-full items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] text-foreground"
>
<span className="truncate">{item.label}</span>
<button
type="button"
className="shrink-0 rounded-full p-0.5 hover:bg-background/80"
aria-label={`Retirer ${item.label}`}
onClick={() => removeItem(exclusionKey(item))}
>
<X className="size-3" />
</button>
</span>
))}
</div>
) : null}
<Input
value={query}
placeholder={items.length === 0 ? placeholder : "Ajouter…"}
className="h-7 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0"
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), 120)
}}
onKeyDown={(e) => {
if (e.key === "Backspace" && !query && items.length > 0) {
removeItem(exclusionKey(items[items.length - 1]!))
return
}
if (e.key === "Enter" || e.key === ",") {
e.preventDefault()
if (suggestions[activeIndex]) addItem(suggestions[activeIndex]!)
else tryAddEmail()
return
}
if (!showSuggestions || suggestions.length === 0) return
if (e.key === "ArrowDown") {
e.preventDefault()
setActiveIndex((i) => (i + 1) % suggestions.length)
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length)
}
}}
/>
</div>
{showSuggestions ? (
<ul className="max-h-44 overflow-y-auto rounded-md border border-border bg-popover py-1 shadow-md">
{grouped.map(([group, groupItems]) => (
<li key={group}>
<p className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{group}
</p>
{groupItems.map((s) => {
flatIndex += 1
const idx = flatIndex
return (
<button
key={s.id}
type="button"
className={cn(
"block w-full px-2 py-1.5 text-left text-xs hover:bg-muted",
idx === activeIndex && "bg-muted",
)}
onMouseDown={(e) => {
e.preventDefault()
addItem(s)
}}
>
{s.label}
</button>
)
})}
</li>
))}
</ul>
) : null}
{emptyHint && items.length === 0 ? (
<p className="text-[11px] text-muted-foreground">{emptyHint}</p>
) : null}
</div>
)
}