import { X } from 'lucide-react' import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' import { searchContacts } from '#server/fns' import { addToken, moveHighlight, removeTokenAt, tokensToValue, valueToTokens } from '../lib/contact-token.js' import { cn } from '../lib/utils.js' import { Input } from './ui/input.js' /** * Recipient/guest field with contact autocomplete. Committed recipients render * as removable chips; the trailing input is where you type or pick the next * one. Keeps the comma-separated `value: string` contract so compose drafts and * calendar guests can share it unchanged. */ export type RecipientInputHandle = { getCurrentValue: () => string } type RecipientInputProps = { value: string onChange: (next: string) => void onEdit?: () => void placeholder?: string className?: string id?: string label?: string disabled?: boolean invalid?: boolean describedBy?: string } export const RecipientInput = forwardRef(function RecipientInput( { value, onChange, onEdit, placeholder, className, id = 'recipient-input', label = 'Recipients', disabled = false, invalid = false, describedBy, }, ref, ) { const tokens = valueToTokens(value) const [draft, setDraft] = useState('') const [suggestions, setSuggestions] = useState<{ email: string; name?: string }[]>([]) const [open, setOpen] = useState(false) const [highlight, setHighlight] = useState(0) const timer = useRef | null>(null) const currentValueRef = useRef(value) // Contact lookups can finish out of order. Keep a monotonically increasing // request id so a slow response for an earlier draft cannot replace the // suggestions for what the user is currently typing. const searchRequestId = useRef(0) const query = draft.trim() currentValueRef.current = query ? tokensToValue(addToken(tokens, query)) : value useImperativeHandle(ref, () => ({ getCurrentValue: () => currentValueRef.current }), []) function setDraftValue(next: string) { currentValueRef.current = next.trim() ? tokensToValue(addToken(tokens, next)) : value setDraft(next) onEdit?.() } useEffect(() => { const requestId = ++searchRequestId.current if (timer.current) clearTimeout(timer.current) if (disabled || query.length < 2) { setSuggestions([]) setOpen(false) return } timer.current = setTimeout(async () => { try { const found = await searchContacts({ data: { q: query } }) if (requestId !== searchRequestId.current) return setSuggestions(found) setOpen(found.length > 0) setHighlight(0) } catch { if (requestId !== searchRequestId.current) return setSuggestions([]) // autocomplete is best-effort } }, 250) return () => { /* v8 ignore else -- @preserve this cleanup exists only after the effect schedules a timer */ if (timer.current) clearTimeout(timer.current) } }, [disabled, query]) function commit(raw: string) { const next = tokensToValue(addToken(tokens, raw)) currentValueRef.current = next onChange(next) onEdit?.() setDraft('') setSuggestions([]) setOpen(false) } function removeAt(index: number) { const next = tokensToValue(removeTokenAt(tokens, index)) currentValueRef.current = next onChange(next) onEdit?.() } function onInputChange(event: React.ChangeEvent) { const next = event.target.value // A comma (typed or pasted) commits everything before the last one. if (next.includes(',')) { const cut = next.lastIndexOf(',') let nextTokens = tokens for (const part of next.slice(0, cut).split(',')) nextTokens = addToken(nextTokens, part) const committed = tokensToValue(nextTokens) const remainder = next.slice(cut + 1).trimStart() currentValueRef.current = remainder ? tokensToValue(addToken(nextTokens, remainder)) : committed onChange(committed) onEdit?.() setDraft(remainder) return } setDraftValue(next) } function onKeyDown(event: React.KeyboardEvent) { if (open && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { event.preventDefault() setHighlight(moveHighlight(highlight, event.key === 'ArrowDown' ? 1 : -1, suggestions.length)) return } if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey) { const picked = open ? suggestions[highlight] : undefined if (picked || draft.trim()) { event.preventDefault() commit(picked ? picked.email : draft) } return } if (event.key === 'Escape' && open) { event.preventDefault() setOpen(false) return } if (event.key === 'Backspace' && draft === '' && tokens.length > 0) { removeAt(tokens.length - 1) } } return ( // The width class lives on the root so the absolutely-positioned suggestion // list (w-full) spans the whole field instead of just the typed content.
{tokens.map((token, index) => ( {token} ))} { // Blur runs before an external button's click. Commit now so Close // snapshots the visible recipient instead of an older controlled value. if (draft.trim()) commit(draft) setOpen(false) }} placeholder={tokens.length ? '' : (placeholder ?? 'To (comma-separated)')} className="min-h-0 w-auto min-w-[8rem] flex-1 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0" type="email" inputMode="email" autoComplete="off" autoCapitalize="none" enterKeyHint="next" disabled={disabled} aria-invalid={invalid || undefined} aria-describedby={describedBy} />
{open ? (
    {suggestions.map((suggestion, index) => (
  • ))}
) : null}
) })