Rich-text editor integration
How @danielivanovz/mention integrates with Lexical, TipTap, Slate, and other rich-text frameworks via the EditorAdapter contract.
@danielivanovz/mention ships two host adapters in the box: textarea (via <Mention.Input>) and plain-text contenteditable (via <Mention.Editable>). Anything beyond that — Lexical, TipTap, Slate, ProseMirror, CodeMirror, your own custom editor — integrates via the same five-method EditorAdapter contract.
This page frames where the library's responsibility ends and the host editor's begins.
The boundary
The library owns:
- The combobox-as-substring contract —
role="combobox",aria-controls,aria-activedescendant,aria-expanded,aria-haspopup,aria-autocomplete. DOM focus stays in the host editor. No second tab stop. No focus shift on highlight. - The state machine — open/close/highlight/commit reducer; trigger detection; IME composition guard; pointer-driven highlight.
- Caret-anchored positioning — Floating UI virtual element bound to whatever
getCaretRect()reports. - Chip selection — two-step backspace state machine; aria-live announcement.
The framework editor owns:
- Editor a11y — text-region role, line/paragraph semantics, find-in-page, undo stack integration, paste sanitization. The mention library does not extend the framework's a11y surface.
- Selection model — what counts as a "caret offset" inside a node tree (Lexical's
LexicalNode-keyed selection, ProseMirror's 1-indexed text offsets, Slate'sPath + Offset). - Document structure — block-level layout, embedded media, code blocks. Mentions are inline insertions; the host editor's normalization rules govern what's valid where.
The EditorAdapter interface is the contract that maps from the host's selection model to the library's flat character-offset model. Three methods are required on every adapter (getValue, getCaretOffset, applyInsert); two more (applyChipInsert, getChipBeforeCaret) are needed for shape: "node" channels.
Writing a custom adapter
import type { EditorAdapter, MentionInsertResult } from "@danielivanovz/mention";
export function createMyEditorAdapter(editor: MyEditor): EditorAdapter {
return {
element: editor.rootElement,
getValue: () => editor.getPlainText(),
getCaretOffset: () => editor.getSelection().anchorOffset,
getCaretRect: () => editor.getCaretBoundingRect(),
applyInsert: (result: MentionInsertResult) => {
editor.replaceAll(result.value);
editor.setCaret(result.caret);
},
focus: () => editor.focus(),
};
}Then wire it up:
const adapterRef = useRef<EditorAdapter | null>(null);
useEffect(() => {
adapterRef.current = createMyEditorAdapter(myEditor);
}, [myEditor]);For shape: "node" channels, also implement applyChipInsert (insert the supplied placeholder element at the trigger window) and getChipBeforeCaret (return the chip element directly preceding the caret, or null).
Per-framework recipes
- Lexical — subscribe to
editor.registerUpdateListener; insert viaeditor.update(() => …); chips asDecoratorNode. - TipTap — ProseMirror coordinate mapping; insert via
editor.commands.insertContentAt; chips as ProseMirror node spec withatom: true. - Slate —
Editor.string(editor, [])for value;Range.start(selection)for anchor;Transforms.insertText/Transforms.insertNodesfor commit.
Known limitations
| Framework | Limitation |
|---|---|
| Lexical | Chip atomicity requires DecoratorNode (not TextNode). The substring path works with TextNode directly. |
| TipTap | ProseMirror positions are 1-indexed text offsets; the adapter must convert to/from the library's 0-indexed character model. |
| Slate | Transforms runs inside Editor.withoutNormalizing for commit atomicity — otherwise normalization can split nodes mid-operation and invalidate the caret offset. |
| All three | Undo/redo is the framework's responsibility. The library doesn't push commits onto a host undo stack — consumers wire that themselves if needed. |
What's NOT in the library
The library deliberately ships only the textarea + plain-text contenteditable adapters. Framework adapters live as recipes (copy-paste examples) rather than first-class exports because:
- Bundling framework deps inside
@danielivanovz/mentionwould breach the wedge (the library's value proposition is "modern bundle size, no editor framework required"). - Framework adapter shape evolves with the framework's own API — keeping them as recipes means consumers can pin the version that matches their host.
- Each framework has community-maintained mention plugins of its own (Lexical's
LexicalTypeaheadMenuPlugin, TipTap's mention extension). The wedge for@danielivanovz/mentionis the headless cross-framework primitive — recipes show how to opt into it when the framework's built-in plugin doesn't fit.