Skip to main content

RichTextField

betav0.1.0

HTML rich text field for content displayed as HTML, exported to PDF or sent as email (reports, templates, email bodies, remediation texts); markdown content is a MarkdownField and plain text — AI prompts included — is a Textarea (RULE-02). Import it from the dedicated subpath `@filigran/design-system/rich-text-field`, NOT from the main entry: it carries the TipTap 3 engine, and the subpath keeps that weight out of products that never edit rich text (RFC §9 Q5). The value is HTML; an empty field yields '' (RULE-01). A toolbar (text style, font family, font size, bold, italic, underline, strikethrough, subscript, superscript, text and background colour, highlight, alignment, lists and to do lists, link, image, table, quote, code, code block, page break, horizontal rule, undo, redo, Source HTML, Ask AI, fullscreen) applies formatting to the selection; its density is never a prop: it follows the field's own width — md while the md toolbar fits whole, else sm (alignment, lists and insert move into a More menu) while the sm toolbar fits whole, else xs (also text style, font family, font size and script), and below the xs toolbar's own width it wraps onto two lines: undo, redo, Source HTML, Ask AI and fullscreen above, on the right, the formatting tools below, on the left — so size the field through its container.Template variables (`$var`, `${…}`, `<#list>`) stay plain text: no autolink, no smart typography (RULE-03). Full-page editors (templates) pass `fullscreen={false}` and usually no visible label, then `aria-label` (RULE-04). Email bodies pass `pageBreak={false} taskList={false}` (RULE-05). Ask AI renders only with BOTH `aiActions` and `onAiAction`; the AI call and its dialog stay product code, and `generating` makes the field read-only and busy while the AI writes (RULE-06). Read-only mapping (selecting text to map entities) is `readOnly` with `onTextSelection`, never `disabled` (RULE-07). Images are embedded as base64 unless `onImageUpload` is passed; a refused image is never silent (`onImageReject` and an error message). Source HTML shows the raw HTML in a textarea; HTML the schema cannot keep is removed and the field says so. Products drive streaming and typing animations through the ref handle (`setContent`, `scrollToBottom`, `focus`), and reach the content DOM through `[data-rich-text-content]`, never through the engine's class names. Every rendered string comes from ONE `labels` object with English defaults (MarkdownField convention). Replaces @filigran/rich-text-editor (OpenCTI, OpenAEV).

Variants

Lorem ipsum dolor sit amet
This field is required

Examples

import { RichTextField } from "@filigran/design-system/rich-text-field";

<RichTextField label="Content" value={content} onValueChange={setContent} />
// Formik through the product's adapter: error once touched, field-level blur
<RichTextField
  label="Description"
  name="description"
  value={formik.values.description}
  onValueChange={(html) => formik.setFieldValue("description", html)}
  onBlur={() => formik.setFieldTouched("description", true)}
  error={formik.touched.description ? formik.errors.description : undefined}
  height={200}
/>
// Email body (RULE-05): no page break, no to do list
<RichTextField label="Email body" value={body} onValueChange={setBody} pageBreak={false} taskList={false} />
// Full-page template editor (RULE-04)
<RichTextField aria-label="Template content" value={template} onValueChange={setTemplate} fullscreen={false} height="calc(100vh - 280px)" />
// Ask AI (RULE-06): both props; the product keeps its AI dialog
<RichTextField
  label="Content"
  value={content}
  onValueChange={setContent}
  aiActions={[
    { id: "fix-spelling", label: t("Fix spelling & grammar") },
    { id: "summarize", label: t("Summarize") },
  ]}
  onAiAction={(actionId, html) => openAiDialog(actionId, html)}
  aiMinLength={10}
/>
// Streaming into the field (replaces onReady(adapter))
const editorRef = React.useRef<RichTextFieldHandle>(null);
<RichTextField ref={editorRef} label="Answer" value={answer} onValueChange={setAnswer} generating={isStreaming} />
// on each chunk:
editorRef.current?.setContent(partialHtml);
editorRef.current?.scrollToBottom();
// Read-only mapping (RULE-07)
<RichTextField label="Content" value={content} readOnly onTextSelection={(text) => openMapping(text)} />
// Unsaved changes in the label row
<RichTextField label="Content" value={content} onValueChange={setContent} unsavedChanges={content !== lastSaved} />
// Images uploaded instead of embedded
<RichTextField
  label="Report"
  value={report}
  onValueChange={setReport}
  onImageUpload={(file) => uploadImage(file)}
  onImageReject={(rejections) => logRejection(rejections)}
/>

Props

PropType
valuestring — Controlled HTML. '' when empty, never "<p></p>" (RULE-01). A value that is not the field's own last emission replaces the content without firing onValueChange
defaultValuestring — Uncontrolled initial HTML
onValueChange(html: string) => void — Fires with the full HTML on every edit: typing, toolbar, paste, image, table, Source HTML. There is no onChange
onFocus(event: FocusEvent<HTMLElement>) => void — FIELD-LEVEL: fires when focus enters the field (editing area, toolbar, its menus and panels, the fullscreen dialog) from outside it
onBlur(event: FocusEvent<HTMLElement>) => void — FIELD-LEVEL: fires when focus leaves the field and all its surfaces; moving into a menu or panel is not a blur (save-on-blur adapters)
labelstring — Visible label. Omit → aria-label or aria-labelledby required (RULE-04)
requiredboolean — aria-required and the `*` marker with a label (default: false)
infoTooltipReactNode — Info trigger next to the label, toned through currentColor
placeholderstring — Placeholder of the empty field (visual, plus aria-placeholder)
helperTextReactNode — Helper text below the field; replaced by an error or a field message
errorstring — Error message, string only: its presence IS the error state (aria-invalid, error border, label and `*` error-toned)
disabledboolean — Disabled field: not editable, outlined, the whole toolbar aria-disabled (More included, kept in the roving order)
readOnlyboolean — Read-only and selectable, marks kept: the mapping mode (RULE-07). The toolbar is inert
onTextSelection(text: string) => void — Fires while readOnly with the selected text, for selections longer than 2 characters (OpenCTI mapping)
unsavedChangesboolean — Shows labels.unsavedChanges ("You have unsaved changes") right-aligned in the label row, in a status region
heightnumber | string — Editing-area height: px, or any CSS length ("calc(100vh - 280px)"). Default: 3 lines (88px, as drawn). The user can still resize it vertically
pageBreakboolean — Page break tool, serialised as <div class="page-break"> (default: true). Email bodies: false (RULE-05)
sourceHtmlboolean — Source HTML button and mode (default: true)
fullscreenboolean — Fullscreen button: a modal Dialog titled by the label (default: true). Full-page editors: false (RULE-04)
taskListboolean — To do lists in the Lists menu (default: true). Email bodies: false (RULE-05)
aiActions{ id: string; label: string; disabled?: boolean }[] — Ask AI menu items, product-supplied and translated. No default list
onAiAction(actionId: string, html: string) => void — Fires with the chosen action and the current HTML. The button renders only with this AND a non-empty aiActions; that gate decides what is shown, the host authorizes the action (RULE-06)
aiMinLengthnumber — Ask AI is inert below this many characters, its tooltip saying why (labels.askAiTooShort) (default: 0)
generatingboolean — The AI is writing: editing area read-only and aria-busy, toolbar inert, labels.generating announced
onImageUpload(file: File) => Promise<string> — Uploads one image and resolves with its URL. Without it images are embedded as base64 data URLs and the image panel says so
acceptImageTypesstring — Accepted image types (default: "image/png,image/jpeg,image/gif,image/webp,image/svg+xml")
maxImageSizenumber — Max bytes per image (default: 5 MB)
onImageReject(rejections: { file: File; reason: "accept" | "maxSize" | "upload" }[]) => void — Fires for every refused or failed image; the field also shows the message in the error tone until the next edit
labelsPartial<RichTextFieldLabels> — Every rendered string (tooltips, accessible names, menus, panels, messages), merged over English defaults
classNamestring — Additional CSS classes on the outer wrapper
idstring — Id of the editing area (the label's target)
namestring — Form name: a hidden input carries the HTML
refRichTextFieldHandle — { setContent(html), scrollToBottom(), focus() }. setContent does not fire onValueChange

Accessibility

2.1 AAWCAG pass

The editing area is a TipTap contenteditable with role=textbox, aria-multiline, named by the visible <label> (aria-labelledby) or aria-label / aria-labelledby, aria-describedby the message line (helper, error or field message) merged with the consumer's, aria-required, aria-invalid, aria-readonly (readOnly, generating), aria-disabled, aria-busy while generating, aria-placeholder; the visual placeholder is a CSS copy only. The toolbar is a WAI-ARIA toolbar (role=toolbar, named "{label} formatting", aria-controls the editing area) with ONE tab stop and roving focus (Left/Right wrap, Home/End), shared with MarkdownField (src/lib/toolbar.ts, RFC §9 Q17). Toggles carry aria-pressed; dropdown triggers are Menu triggers (aria-haspopup=menu, aria-expanded); panel triggers carry aria-haspopup=dialog and aria-expanded. While disabled every tool is aria-disabled, fullscreen included; read-only or generating, every tool but fullscreen. Inert tools are painted --icon-disabled and kept in the roving order; when the toolbar wraps, the roving order stays the one-line reading order (formatting tools first, then the group shown above them on the right); in Source HTML the formatting tools are, undo, redo, Source HTML, Ask AI and fullscreen stay live. Menus are the library Menu (current value marked selected: check icon + aria-current); More nests submenus. Panels are one non-modal Radix Popover (role=dialog, named Add link / Edit link / Add image / Image options / Table / the colour name), portalled with z-[var(--fds-z-overlay,50)] on layer 1, holding the library Paper, Input, Button, Tabs and Checkbox; the colour panels are the ColorPicker's own panel body (area, hue slider, format and HEX inputs — shared, not copied) plus a Reset color IconButton (Figma hasReset); focus moves into the panel, Escape or Cancel closes it and returns focus to the control that opened it. Link URLs accept http, https and mailto only (javascript: and data: refused with a field error). Empty alt text is kept as alt="" (decorative). The table size picker is one button: arrows change the size, Enter inserts; the size (rows x columns) is a polite live text. Inside a table the table button opens the table operations menu, so they are keyboard-reachable; a right click or the context menu key inside a table opens the same menu at the pointer or the caret (outside a table the browser's menu is left alone). Hovering an image tints it (aria-hidden, decorative) and shows an IconButton named Image options (aria-haspopup=dialog) that selects the image and opens its options panel — a pointer shortcut: selecting the image and pressing the toolbar's Image button reaches the same panel from the keyboard. The image-hover pair is computed with the tint over the page surface, not over the image, whose pixels no gate can know. Font family items are named in their own face (the text stays the family's name). Source HTML is indented one block per line; the whitespace it adds between blocks is not content and is not reported as removed HTML. Fullscreen is the library Dialog over the whole viewport (modal, focus trapped, Escape closes, focus returns to the expand button), titled by the label, on bg-gradient-default — the title is declared against both stops. Status lines (unsaved changes, generating) are role=status regions mounted before their text. USER-CHOSEN COLOURS ARE OUTSIDE THE CONTRAST GATE: text, background and highlight colours picked in the ColorPicker are content, not tokens, and no pair here declares them compliant (RFC §5.3, §9 Q12). Table cell borders, the table grid's unselected cells and the drag cut-out are decorative (the size is conveyed by text). Tab inside the editing area keeps TipTap's list behaviour (indent within a list item) and otherwise leaves the field. Ratios are computed by pnpm check:contrast from theme.css.