"use client"; /** * MentionInput — text input with the TASK-0094 R3 `@`-mention popup. * * Type `@` (at the start of the text or after whitespace) and the mention * popup opens: a grouped, fuzzy-ranked list of the workspace's * People / Teams / Pages / Databases (max 20 per group). Picking one inserts * the CANONICAL token `@:` — the frozen shared `parseMentions` * contract the server parses into `mentionIds`/`pageMentionIds` + the R4 * notification fan-out. (The Phase-3 version inserted a rendered `@Name`, * which `parseMentions` never matched — it never actually notified; R3 fixes * that by inserting the token the server understands. Recorded conflict: the * R3 spec line "the body text keeps the rendered @Name" vs the frozen shared * `parseMentions` contract — resolved in favor of the contract, the * authoritative mention parser, per AGENTS rule 29.) * * No third-party editor: a contentEditable
+ the grouped MentionPopup. * Keyboard is the combobox pattern (arrows/Enter/Tab/Esc); a11y is * `role=listbox`/`role=option` (ux §12 "mention popup = listbox"). * * Backward compatible: when `workspaceId` is NOT passed (or empty), the popup * shows only the People group from `members` — the old members-only behavior. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { useMentionSources } from "@/hooks/use-mention-sources"; import { buildMentionGroups, mentionActive, type MentionItem, type MentionMember, type MentionSource } from "@/components/mentions/mention-model"; import { MentionPopup } from "@/components/mentions/MentionPopup"; export interface MentionInputProps { value: string; onChange: (value: string) => void; members: MentionMember[]; placeholder?: string; className?: string; autoFocus?: boolean; onSubmit?: () => void; onEscape?: () => void; /** When set, enables the full R3 grouped popup (People/Teams/Pages/Databases). */ workspaceId?: string; /** The current user's id — excluded from the People group (no self-mention). */ currentUserId?: string | null; /** data-testid for the editable region (E2E). */ testId?: string; } export function MentionInput({ value, onChange, members, placeholder, className, autoFocus, onSubmit, onEscape, workspaceId, currentUserId, testId }: MentionInputProps) { const ref = useRef(null); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [highlight, setHighlight] = useState(0); const [anchor, setAnchor] = useState<{ start: number; end: number } | null>(null); const ws = workspaceId ?? ""; const { source: wsSource, isLoading } = useMentionSources(ws); // The grouped R3 popup (People/Teams/Pages/Databases) when a workspace is // given; otherwise the members-only People group (the pre-R3 behavior). const source = useMemo( () => ws ? wsSource : { people: members, teams: [], pages: [], databases: [] }, [ws, wsSource, members] ); // Keep the DOM in sync when `value` changes externally (reset, mention // pick). We don't track the caret across external updates — acceptable for // a comment composer. useEffect(() => { if (ref.current && ref.current.textContent !== value) { ref.current.textContent = value; } }, [value]); const groups = useMemo( () => buildMentionGroups(source, { query, currentUserId, existingText: value }), [source, query, currentUserId, value] ); const flatItems = useMemo(() => groups.flatMap((g) => g.items), [groups]); const updateFromDOM = useCallback(() => { const text = ref.current?.textContent ?? ""; onChange(text); // Detect an open mention: a word-boundary `@` with the query typed after // it (the R3 trigger). No `@` → close. const caret = window.getSelection(); const selStart = caret?.anchorOffset ?? text.length; const active = mentionActive(text.slice(0, selStart)); if (active) { setAnchor({ start: active.start, end: active.end }); setQuery(active.query); setOpen(true); setHighlight(0); } else { setOpen(false); setAnchor(null); } }, [onChange]); const pick = useCallback( (item: MentionItem) => { const el = ref.current; if (!el || !anchor) return; const full = el.textContent ?? ""; const inserted = `${item.token} `; // trailing space ends the token const next = `${full.slice(0, anchor.start)}${inserted}${full.slice(anchor.end)}`; el.textContent = next; // Caret right after the inserted token + space. const caretPos = anchor.start + inserted.length; const sel = window.getSelection(); if (sel && el.firstChild) { const range = document.createRange(); const node = el.firstChild as Text; const at = Math.min(caretPos, node.textContent?.length ?? caretPos); range.setStart(node, at); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } onChange(next); setOpen(false); setAnchor(null); el.focus(); }, [anchor, onChange] ); const onKeyDown = (e: React.KeyboardEvent) => { if (open && flatItems.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); setHighlight((h) => (h + 1) % flatItems.length); return; } if (e.key === "ArrowUp") { e.preventDefault(); setHighlight((h) => (h - 1 + flatItems.length) % flatItems.length); return; } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const item = flatItems[highlight]; if (item) pick(item); return; } if (e.key === "Escape") { setOpen(false); return; } } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSubmit?.(); return; } if (e.key === "Escape") { onEscape?.(); } }; return (
setTimeout(() => setOpen(false), 150)} /> {open && ( )}
); }