MarkdownField
betav0.1.0Markdown text field for forms whose value is markdown displayed RENDERED elsewhere (descriptions, reports, notes, comments) — plain multi-line text that is never rendered is a Textarea, one line is an Input, and code, JSON or queries are never edited here (RULE-01). A native <textarea> shows the raw markdown; a formatting toolbar (heading, bold, italic, strikethrough, link, quote, code, image, list, ordered list, task list) inserts markdown syntax at the selection and returns focus to the textarea; Ctrl/Cmd+B, I and K (link) do the same from the keyboard. The field surface is Textarea's own, shared rather than copied. A Write/Preview switch renders when `renderPreview` is passed: the library ships NO markdown renderer, so pass the renderer the product displays this content with, and the preview applies the Figma preview styling to its output (RULE-02). `onImageUpload` enables paste, drop and an add-images button; without it none of them exists and the image button inserts an image template (RULE-05). The Ask AI button and menu render only when BOTH `aiActions` and `onAiAction` are passed — the component stops at 'action chosen', the AI call and its result dialog stay product code (RULE-06). `characterLimit` is a SOFT limit: a counter, a warning from `limitWarningAt` (default 90 % of the limit), an error past it — it never truncates, so the form must block submission (RULE-07). Native `maxLength` keeps its hard meaning. The toolbar collapses its content and list groups behind a 'More' button when the row is too narrow (measured); the field width comes from its container, not below 275px (RULE-08); as a flex or grid item it narrows to its column (min-w-0), so two fields can sit side by side. `toolbar={false}` is for compact repeated inputs such as inline replies (RULE-09). Every rendered string comes from ONE `labels` object with English defaults — a deliberate divergence from the family's one-prop-per-string convention (Combobox `emptyMessage`), arbitrated in MarkdownField.rfc.md §9 Q13 because about twenty top-level props would bury the field API; a localised product passes every key translated. Two strings are deliberately outside it: the image rejection message, built from the rejected files by the `imageRejectionMessage` callback (English default), and the Ask AI item labels, which arrive translated in `aiActions` — a localised product passes both (RULE-10). In Preview the label, helper, counter and error stay as in Write; only the editable surface is replaced (RULE-11). Replaces OpenCTI's react-mde and OpenAEV's @uiw/react-md-editor wrappers; no success state (the green field is the drop target).
Variants
Title sample
Text sample lorem ipsum dolor sit amet, consectetur adipiscing elit.
Quote example lorem ipsum dolor sit amet, consectetur adipiscing elit.
import { MarkdownField } from "@filigran/design-system";- List item example 1
- List item example 2
- List item example 1
- List item example 2
Examples
<MarkdownField label="Description" value={description} onValueChange={setDescription} />// Preview with the renderer the product displays this content with (RULE-02)
<MarkdownField
label="Description"
required
value={description}
onValueChange={setDescription}
renderPreview={(markdown) => <MarkdownDisplay content={markdown} />}
/>// Images: only where the product can store them, and a rejection is never silent (RULE-05)
<MarkdownField
label="Report"
value={report}
onValueChange={setReport}
onImageUpload={(file) => uploadImage(file)}
onImageReject={(rejections) => setImageError(rejections[0].reason)}
/>// Ask AI: both props, only when the feature is available to this user (RULE-06)
<MarkdownField
label="Description"
value={description}
onValueChange={setDescription}
aiActions={[
{ id: "fix-spelling", label: t("Fix spelling & grammar") },
{ id: "summarize", label: t("Summarize") },
]}
onAiAction={(actionId, value) => openAiDialog(actionId, value)}
/>// Soft character limit — the form validation blocks submission past it (RULE-07)
<MarkdownField label="Summary" characterLimit={2000} value={summary} onValueChange={setSummary} />// Formik: error only once touched or submitted (RULE-04); field-level blur
<MarkdownField
label="Description"
name="description"
value={formik.values.description}
onValueChange={(value) => formik.setFieldValue("description", value)}
onBlur={() => formik.setFieldTouched("description", true)}
error={formik.touched.description ? formik.errors.description : undefined}
/>// Localised strings (RULE-10)
<MarkdownField
label={t("Description")}
value={description}
onValueChange={setDescription}
labels={{ bold: t("Bold"), italic: t("Italic"), write: t("Write"), preview: t("Preview") }}
/><MarkdownField aria-label="Reply" toolbar={false} value={reply} onValueChange={setReply} /><MarkdownField label="Locked" value={text} disabled />Props
| Prop | Type |
|---|---|
value | string — Controlled markdown source. A change the parent does not echo back stays the base an upload result is applied to until the props bring a new value |
defaultValue | string — Uncontrolled initial markdown source |
onValueChange | (value: string) => void — Fires with the full source on typing AND on every toolbar, shortcut, image and upload insertion (a native onChange cannot report a programmatic insertion, RFC §9 Q12). There is no onChange |
onFocus | (event: FocusEvent<HTMLElement>) => void — FIELD-LEVEL: fires when focus enters the field (textarea, toolbar, buttons, its floating panels) from outside it |
onBlur | (event: FocusEvent<HTMLElement>) => void — FIELD-LEVEL: fires when focus leaves the whole field; moving from the textarea to a toolbar button or into the 'More' panel or the Ask AI menu is not a blur (submit-on-blur adapters, RFC §9 Q11) |
label | string — Visible label. Omit → aria-label or aria-labelledby required (RULE-03) |
required | boolean — Sets aria-required; renders the `*` marker only with a `label` (default: false). The native `required` constraint applies in Write only: in Preview the textarea is hidden and could not show the browser's validation message, so the host's form validation must cover Preview |
infoTooltip | ReactNode — Info trigger next to the label (caller-supplied); toned icon/info, icon/error or icon/disabled through currentColor |
placeholder | string — Placeholder of the Write field |
helperText | ReactNode — Helper text below the field; replaced by an error or limit message |
error | string — Error message, string only: its presence IS the error state (aria-invalid, error border, label and `*` error-toned). The counter stays helper-toned unless the count is past `characterLimit` (GAP-M8) |
disabled | boolean — Disabled field: textarea disabled, toolbar aria-disabled (kept in the roving order), AI button, toggle and add-images disabled (default: false) |
rows | number — Visible height of the Write field in lines (default: 3, the value the node draws) |
characterLimit | number — Soft limit: renders the "N/limit" counter, the warning from `limitWarningAt` and the exceeded error message. Never truncates; `maxLength` stays the native hard limit |
limitWarningAt | number — Count from which the warning tone and message show (default: Math.floor(0.9 * characterLimit)) |
mode | "write" | "preview" — Controlled mode. Ignored without `renderPreview`. Pass `onModeChange` with it: a controlled mode without `onModeChange` renders NO toggle (it could not act, L103) and warns in development; the field stays in the given mode |
defaultMode | "write" | "preview" — Uncontrolled initial mode (default: "write") |
onModeChange | (mode: "write" | "preview") => void — Fires when the toggle changes the mode |
renderPreview | (markdown: string) => ReactNode — Preview renderer. Pass the one the product displays this content with, returning its block elements (react-markdown's output) — the preview styles headings, paragraphs, links, quotes, code, lists and images. Without it the Write/Preview toggle does not render (RULE-02) |
toolbar | boolean — Show the formatting toolbar (default: true). Shortcuts work either way |
onImageUpload | (file: File) => Promise<string> — Uploads one image and resolves with its URL. An 'uploading' placeholder is inserted at the caret and replaced by  on resolve (the file name escaped so a bracket in it cannot break the markdown), removed on reject (onImageReject reason "upload"), a synchronous throw included. Enables paste, drop and the add-images button (RULE-05). Cmd/Ctrl+Z undoes toolbar and shortcut insertions and the placeholder insertion, not the replacement of a placeholder by its uploaded image |
acceptImageTypes | string — Accepted image types, MIME or extension list (default: "image/png,image/jpeg,image/gif,image/webp") |
maxImageSize | number — Max bytes per image (default: 5 MB) |
onImageReject | (rejections: { file: File; reason: "accept" | "maxSize" | "upload" | "maxLength" }[]) => void — Fires for every refused or failed image ("maxLength": the image's text would take the field past the native maxLength, so it was not added); wire it whenever onImageUpload is passed. It fires once per failure, as it happens, with that failure only. The field also shows the message (error tone, aria-invalid) and announces it in its live region; that built-in error clears on the next edit of the text or the next accepted image |
imageRejectionMessage | (rejections) => string — Builds the error message from EVERY rejection the message line currently shows: the files refused at pick time and each upload of the batch that failed since, until the next edit (default: English builder, naming every file); translate it in a localised product: it is the one rendered message `labels` does not carry, because it is built from the rejected files (RULE-10) |
aiActions | { id: string; label: string; disabled?: boolean }[] — Ask AI menu items, product-supplied and already translated. No default list |
onAiAction | (actionId: string, value: string) => void — Fires with the chosen action and the current source. The AI button renders only when this AND a non-empty `aiActions` are passed. That gate decides what is shown, it is not an authorization boundary: the handler receives the field's full value, so the host authorizes the action — server side included — before anything is sent to the AI (RULE-06) |
labels | Partial<MarkdownFieldLabels> — Every rendered string, merged over English defaults: heading, bold, italic, strikethrough, link, quote, code, image, list, listOrdered, listCheck, askAi, write, preview, viewMode, more, toolbar, addImages, dropImages, uploading, limitWarning, limitExceeded, urlPlaceholder |
maxLength | number — Native hard limit, passed to the textarea unchanged. A toolbar or shortcut insertion that would take the text past it is a no-op, like native typing at the limit: skipped whole, never truncated into broken syntax, no onValueChange. An image whose placeholder, or whose final markdown, would not fit is not added (its placeholder is removed) and is reported through onImageReject with reason "maxLength" |
className | string — Additional CSS classes on the outer wrapper |
Accessibility
Native <textarea> (ref target) with a real <label for>; without a label, aria-label or aria-labelledby is required (dev console.warn, never a throw). The error state sets aria-invalid; aria-describedby is merged with the consumer's and points only at rendered, non-empty nodes — the message line (helper, error, limit or drop message) and the counter. The formatting toolbar is a WAI-ARIA toolbar: role=toolbar, named (labels.toolbar), aria-controls the textarea, ONE tab stop with roving focus (Left/Right wrap, Home/End); Enter/Space insert the syntax and return focus to the textarea with the caret placed. Ctrl/Cmd+B, I and K insert bold, italic and a link from the textarea; the three buttons carry aria-keyshortcuts. In Preview and Disabled the toolbar stays visible and aria-disabled — inert, painted --icon-disabled, still in the roving order so it stays discoverable (RFC §9 Q14). Overflow: the content and list groups move behind an IconButton (aria-haspopup, aria-expanded, aria-controls via Radix Popover) into a portalled panel holding a second named toolbar; Escape closes it and returns focus to the button, choosing a tool closes it and returns focus to the textarea. The panel carries z-[var(--fds-z-overlay,50)] and paints the layer-1 surface (layer-1 + bg-elevation-default); the Ask AI Menu is given layer-1 the same way (GAP-M15). Ask AI is IconButton ia/tertiary as MenuTrigger, named labels.askAi; its glyph is decorative. The AI glyph's gradient (Icon gradient="ia") is decorative, never the sole carrier of meaning, and its stops are not verified against arbitrary backgrounds; they were measured anyway on the page: --color-filigran-ia-primary 6.13:1 dark / 6.52:1 light, --color-filigran-ia-secondary 14.14:1 dark / 11.41:1 light. Write/Preview is the library ButtonGroup (role=radiogroup, named labels.viewMode, selection follows focus, aria-checked on the chosen view), composed unchanged with usecase isolated. wcagStatus is "fail" because of it, and truthfully so: the selected segment (--bg-input-default plus a 10% brand wash) stands against an unselected segment that is transparent over the page: as rendered 1.49:1 dark (#183251 on #070d18) / 1.37:1 light (#cdd0e1 on #f2f2f3), against the 3:1 WCAG 1.4.11 asks for a state distinction (re-derived with scripts/lib/theme.ts). The declared pair "toggle selected vs unselected segment" is ButtonGroup's proxy pair (the same tokens ButtonGroup.meta.ts declares), because the pair format cannot express a wash over the input fill against the page; check:contrast computes it at 1.23:1 dark / 1.22:1 light. Both readings are below 3:1. The state is exposed to assistive technology through aria-checked; the shortfall is visual only. Accepted by the designer (@fimow, 2026-10-08) in a time-boxed scripts/wcag-allowlist.json entry aligned with ButtonGroup's (expires 2026-11-30); resolution follows ButtonGroup's V2. The preview is a named region (field name + labels.preview), focusable (tabIndex=0, standard focus ring) so a keyboard user can scroll it, not a live region. In Preview the textarea is hidden, so the region carries the field's aria-describedby (helper or error, and counter); it carries no aria-invalid, which role=region does not support — the error text conveys the state. Image paste/drop/pick: a rejected or failed image is never silent — onImageReject fires and the message renders in the error tone; the drop target is not colour-only (the message line reads labels.dropImages); while an upload runs the textarea is aria-busy and a role=status line reads labels.uploading. The hidden file input is tabIndex=-1, aria-hidden and never `required`. Measured ratios (pnpm check:contrast, theme.css): label 8.99:1 dark / 7.89:1 light; required mark 17.38 / 15.72; helper and counter 8.99 / 7.89; error text 5.18 / 5.92; limit warning 11.96 / 10.80; drop message 14.32 / 8.91; drop border 5.23 / 4.42; placeholder 7.38 / 7.00; value 14.28 / 13.96; hover border 14.28 / 13.96; error border 4.26 / 5.25; focus ring 10.30 / 11.22; toolbar glyph 10.30 / 11.22, on its hover wash 8.80 / 9.21; toggle and More borders 3.78 / 3.60; overflow panel glyph 9.47 / 12.55 and border 3.71 / 3.73 on layer 1; Ask AI menu item on layer 1 12.68 / 15.72; add-images label 10.30 / 11.22; preview text 17.38 / 15.72; preview code text 14.28 / 13.96; quote bar 10.30 / 11.22; disabled text 6.60 / 5.34, disabled glyph 6.60 / 5.34, disabled border 3.25 / 3.72 (disabled classes are exempt under WCAG 1.4.3 Note 1 and declared anyway because they pass). Placeholder and value follow the surface shared with Textarea and the nodes' bindings: placeholder --text-input-placeholder (secondary grey), value --text-input-value (primary), aligned by the 2026-10-14 Figma export (PR #253), which closes the RFC §9 Q15 follow-up.