Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
173 lines
5.6 KiB
TypeScript
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>
|
|
)
|
|
}
|