@danielivanovz/mention
Integrations

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 contractrole="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's Path + 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 via editor.update(() => …); chips as DecoratorNode.
  • TipTap — ProseMirror coordinate mapping; insert via editor.commands.insertContentAt; chips as ProseMirror node spec with atom: true.
  • SlateEditor.string(editor, []) for value; Range.start(selection) for anchor; Transforms.insertText / Transforms.insertNodes for commit.

Known limitations

FrameworkLimitation
LexicalChip atomicity requires DecoratorNode (not TextNode). The substring path works with TextNode directly.
TipTapProseMirror positions are 1-indexed text offsets; the adapter must convert to/from the library's 0-indexed character model.
SlateTransforms runs inside Editor.withoutNormalizing for commit atomicity — otherwise normalization can split nodes mid-operation and invalidate the caret offset.
All threeUndo/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:

  1. Bundling framework deps inside @danielivanovz/mention would breach the wedge (the library's value proposition is "modern bundle size, no editor framework required").
  2. Framework adapter shape evolves with the framework's own API — keeping them as recipes means consumers can pin the version that matches their host.
  3. Each framework has community-maintained mention plugins of its own (Lexical's LexicalTypeaheadMenuPlugin, TipTap's mention extension). The wedge for @danielivanovz/mention is the headless cross-framework primitive — recipes show how to opt into it when the framework's built-in plugin doesn't fit.

On this page