← All stories

COMPONENT · ui-rich-text-editor

ui-rich-text-editor

Component Tier 1 (primitive) Used by email designs (EF-051), Canvas blocks (EF-027), guest messaging (EF-053), custom-question help text (EF-040)

A rich-text editor with a closed schema (no inline styles, no arbitrary HTML). Paste-from-Word strips junk, merge tokens are first-class atoms (not editable text), undo/redo is correct across paste and token-insert, and the output is always JSON not HTML.

Component contract

A schema-bounded rich-text editor. Output is structured JSON (Lexical / ProseMirror style), never HTML. Closed set of allowed marks and blocks; merge tokens are atomic nodes.

  • value: EditorState — JSON representation. Controlled.
  • onChange: (next) => void — debounced 200ms.
  • schema: SchemaDef — allowed blocks (paragraph, heading[1-3], list, blockquote), allowed marks (bold, italic, underline, link), allowed inline atoms (merge-token, hard-break).
  • tokens?: TokenDef[] — registered merge tokens; insertable via toolbar or keyboard shortcut. Each token has id, label, fallback (for missing data), and category.
  • onInsertToken?: (tokenId) => void — fires when token inserted; useful for analytics.
  • placeholder?: string
  • maxLength?: number — character cap, counted as plain-text length.
  • readOnly?: boolean
  • error?: string — surfaces inline error.

Interaction surface

  1. Toolbar exposes only the schema's allowed marks/blocks.

    If the schema doesn't include underline, the underline button is not rendered. The user can never produce a node the schema doesn't allow.

  2. Merge tokens render as atomic chips inside the text flow.

    A token like {guest.firstName} renders as a non-editable chip with the token's label ("Guest first name"). Cursor navigates around it as one unit. Backspace deletes the whole token.

  3. Paste sanitizes to the schema.

    Pasting from Word, Google Docs, or any rich source: HTML is parsed, mapped to allowed schema nodes, unmapped nodes drop their formatting (text content preserved). Inline styles, scripts, classes, and arbitrary attributes are stripped.

  4. Undo/redo across all operations.

    Type, paste, format, insert-token, delete-token are all in the undo stack. Each operation is one undo step (paste of 5 paragraphs is one undo, not 5).

  5. Character count visible when maxLength set.

    "X / Y characters" shown below the editor. Color shifts to warning at 90%, danger at 100%. At 100%, additional input is rejected (silent — no error toast for every keystroke; just doesn't insert).

  6. Link insertion is structured.

    Selecting text and clicking link opens a small popover with URL field. URL is validated (http/https/mailto only). Invalid URLs show inline validation. After insert, link renders with underline + accent color.

Failure modes

Pasted Word docs include style="font-family: Calibri" inline styles

Trigger: paste-from-Word path doesn't strip inline styles, the saved JSON includes alien formatting that breaks email rendering downstream.

Paste handler runs the HTML through a strict allow-list parser. Inline styles are dropped; only schema-allowed marks survive. Harness: paste a known Word HTML string with font-family + line-height inline styles, assert the saved EditorState has no inline-style entries.

Token rendered as plain text "{guest.firstName}"

Trigger: token was inserted as a string, not as an atomic node; user can edit the braces, breaking token resolution.

Tokens are inline nodes in the schema. They render as chips and are uneditable. Cursor + arrow keys treat them as one unit. Harness: insert a token, click in the middle of it (e.g., between letters of the label), assert cursor lands at the boundary either left or right of the token, never inside.

Backspace inside a token partially deletes the chip

Trigger: token chip allows partial deletion, leaving "{guest.firstNam" garbage.

Backspace at the right boundary of a token deletes the entire chip in one operation. Harness: insert token, place cursor at right boundary, Backspace once, assert token entirely removed.

Undo merges multiple operations

Trigger: paste 5 paragraphs, then insert a token, then bold a word. One undo reverts all three.

Undo stack groups by operation, not by render frame. Harness: paste, insert token, bold, then Cmd+Z three times. After 1st undo: bold removed. After 2nd: token removed. After 3rd: paste reverted.

maxLength counts HTML markup not visible chars

Trigger: maxLength=100, user types 80 plain chars but counter shows 250 because it's counting JSON node markup.

Character count is plain-text length (after rendering tokens to their fallback). Harness: maxLength=100, type "Hello world" + insert token "{guest.firstName}" with fallback "guest", assert counter shows "Hello world guest".length = 17.

Link popover allows javascript: URLs

Trigger: user pastes "javascript:alert(1)" into the link URL field; the link is created.

URL validation allow-list: http, https, mailto, tel. Anything else is rejected with inline error. Harness: type "javascript:alert(1)", assert error visible, link NOT created.

Toolbar button shows allowed mark in disallowed schema

Trigger: schema excludes underline, but the underline toolbar button renders (greyed out or live).

Toolbar is generated from the schema. If schema.marks doesn't include underline, the button is not rendered at all. Harness: schema={marks: ["bold", "italic"]}, assert no underline button in toolbar.

Mobile keyboard covers the editor on focus

Trigger: on iOS Safari, focusing the editor causes the keyboard to slide up over the editor surface itself.

On focus, scroll the editor into view (above the keyboard line). Harness: emulate mobile viewport, focus editor, assert editor's bounding box is above the bottom 40% of viewport.

Schema-disallowed paste content silently lost

Trigger: paste a table; the schema doesn't allow tables; the table is dropped without notice; user thinks it was kept.

Paste handler emits a non-blocking toast: "Some formatting was removed (tables not supported)". User sees what was lost. Harness: paste HTML containing a table, assert toast visible AND the table's text content is preserved as paragraphs.

Accessibility

  • Editor surface has role="textbox", aria-multiline="true", aria-label from a parent label or label prop.
  • Toolbar has role="toolbar" + aria-label="Formatting".
  • Each toolbar button has aria-pressed reflecting active state for the current selection.
  • Tokens have role="img" with descriptive aria-label ("Merge token: Guest first name").
  • Character count region has aria-live="polite" only when approaching limit (≥90%).
  • Link popover follows ui-modal-style focus trap on its own scope.
  • Color contrast: editor text ≥ 4.5:1, placeholder ≥ 4.5:1, character count ≥ 4.5:1 in every state.
  • axe-clean at severity ≥ serious.

Stable test attributes

data-testWherePurpose
ui-rich-text-editorOuter wrapperComponent identity
ui-rich-text-editor-toolbarToolbar regionrole=toolbar
ui-rich-text-editor-toolbar-buttonEach toolbar buttondata-action attr (bold, italic, link, token, …)
ui-rich-text-editor-surfaceEditable regionrole=textbox
ui-rich-text-editor-tokenEach merge-token chipdata-token-id attr
ui-rich-text-editor-link-popoverLink insert popoverVisible when inserting/editing link
ui-rich-text-editor-link-errorLink URL validation errorVisible on invalid URL
ui-rich-text-editor-character-countCharacter countVisible when maxLength set
ui-rich-text-editor-paste-toastPaste-stripped toastVisible after paste with removed formatting
ui-rich-text-editor-errorInline error regionVisible when error prop set

Agent test plan

Standalone probes against /admin-test/ui-rich-text-editor-fixture with variants: empty, with-content, with-tokens, with-maxLength, mobile-viewport, schema-restrictive (only paragraph + bold), schema-permissive.

Probe list
- toolbar-only-allowed-marks: schema=[bold, italic], assert no underline button present
- type-emits-onChange-debounced: type "abc" rapidly, onChange called once after 200ms
- paste-strips-inline-styles: paste Word HTML, output JSON has no inline styles
- paste-preserves-text: paste rich content, plain-text content preserved
- paste-toast-on-stripped-formatting: paste a table, ui-rich-text-editor-paste-toast visible
- token-renders-as-chip: insert token, ui-rich-text-editor-token visible with token-id attr
- token-cursor-skips-interior: place cursor mid-token via click, cursor lands at boundary
- token-backspace-deletes-whole: cursor at right of token, Backspace, token gone
- undo-paste-as-one-step: paste 5 paragraphs, Cmd+Z, all 5 reverted in one step
- undo-token-as-one-step: insert token, Cmd+Z, token gone
- undo-stack-three-deep: paste, token, bold; three undos restore each in reverse
- maxLength-plain-text-count: type "abc", insert token with fallback "guest", count="abc guest"
- maxLength-blocks-input: maxLength=10, type 11th character, no insertion
- link-validates-allowlist: type "javascript:alert(1)" in link URL, ui-rich-text-editor-link-error visible
- link-allows-https: type "https://example.com", link created
- link-allows-mailto: type "mailto:[email protected]", link created
- mobile-keyboard-scroll-into-view: mobile viewport, focus editor, editor bbox above keyboard line
- aria-pressed-reflects-selection: select bold text, bold button has aria-pressed=true
- aria-label-tokens: token has aria-label="Merge token: [label]"
- aria-live-character-count-near-limit: maxLength=100, type to 90 chars, count region has aria-live=polite
- color-contrast-editor-text: ≥ 4.5:1
- color-contrast-placeholder: ≥ 4.5:1
- axe-clean-serious: no serious violations