A TipTap rich text editor with a toolbar (bold, italic, headings 2 and 3, bulleted and numbered lists, quote, link, image), plus `RichText`, the renderer for the HTML it stores, with print-friendly prose styles.
registry/behivetech/molecules/rich-text-editor at v0.1.3, snapshotted 2026-11-09
link-href.ts
1/** Matches an absolute web address, an email or a phone link. */2const ABSOLUTE_HREF = /^(https?:\/\/\S+|mailto:\S+|tel:\S+)$/i;34/** Matches a path on the current site: a single leading slash, never `//host`. */5const SITE_PATH_HREF = /^\/(?!\/)\S*$/;67const HREF_PATTERNS = [ABSOLUTE_HREF, SITE_PATH_HREF];89/**10 * Trims a link address and checks it against what the editor may produce:11 * `https://`, `http://`, `mailto:`, `tel:` or a site path starting with `/`.12 * Anything else (`javascript:`, `data:`, a bare word) is rejected so the13 * stored HTML never carries a scheme the consuming app has to filter out.14 *15 * @param raw - The address as typed.16 * @returns The trimmed address, or `null` when it is not allowed.17 */18export const normalizeLinkHref = (raw: string): string | null => {19 const href = raw.trim();20 const isAllowed = HREF_PATTERNS.some((pattern) => pattern.test(href));2122 return isAllowed ? href : null;23};
rich-text-editor-extensions.ts
1import type { Extensions } from "@tiptap/react";2import { StarterKit } from "@tiptap/starter-kit";3import { Image } from "@tiptap/extension-image";4import { Link } from "@tiptap/extension-link";5import { normalizeLinkHref } from "./link-href.js";6import type { RichTextEditorFeature } from "./rich-text-editor-items.js";78/**9 * Only the attributes the README allowlist names are kept, so pasted HTML10 * cannot smuggle `target`, `rel`, `class` or `title` past a sanitiser that11 * trusts "the editor can only produce" them.12 */13const SafeLink = Link.extend({14 addAttributes: () => ({15 href: {16 default: null,17 // Store the address as the dialog would: trimmed.18 parseHTML: (element) => element.getAttribute("href")?.trim() ?? null,19 },20 }),21});2223const SafeImage = Image.extend({24 addAttributes: () => ({25 src: { default: null },26 alt: { default: null },27 width: { default: null },28 height: { default: null },29 }),30});3132const HEADING_LEVELS: (2 | 3)[] = [2, 3];3334/**35 * Builds the TipTap extensions for a feature set. The set is also the output36 * allowlist: nothing outside the toolbar's formats (underline, strike, code,37 * code blocks, rules, h1 and h4 to h6) can be produced, even by a keyboard38 * shortcut or a paste, so the consuming app's sanitiser only has to allow39 * what is listed on `RichText`.40 *41 * @param features - The features switched on.42 */43export const buildExtensions = (44 features: RichTextEditorFeature[],45): Extensions => {46 const isOn = (feature: RichTextEditorFeature) => features.includes(feature);47 const extensions: Extensions = [48 StarterKit.configure({49 bold: isOn("bold") ? undefined : false,50 italic: isOn("italic") ? undefined : false,51 heading: isOn("heading") ? { levels: HEADING_LEVELS } : false,52 bulletList: isOn("bulletList") ? undefined : false,53 orderedList: isOn("orderedList") ? undefined : false,54 blockquote: isOn("blockquote") ? undefined : false,55 code: false,56 codeBlock: false,57 horizontalRule: false,58 strike: false,59 underline: false,60 // StarterKit bundles Link; the package adds its own below so the61 // options (and the dependency) are explicit.62 link: false,63 }),64 ];6566 if (isOn("link")) {67 extensions.push(68 SafeLink.configure({69 openOnClick: false,70 autolink: false,71 // The dialog's rule applies to pasted and parsed links too.72 isAllowedUri: (url) => normalizeLinkHref(url) !== null,73 shouldAutoLink: (url) => normalizeLinkHref(url) !== null,74 // Site paths stay in the tab, and the stored anchor carries only href.75 HTMLAttributes: { target: null, rel: null },76 }),77 );78 }7980 if (isOn("image")) {81 extensions.push(SafeImage.configure({ inline: false }));82 }8384 return extensions;85};
rich-text-editor-items.ts
1import type { Editor } from "@tiptap/react";2import {3 BoldIcon,4 ImageIcon,5 ItalicIcon,6 LinkIcon,7 ListIcon,8 ListOrderedIcon,9 QuoteIcon,10 type IconComponent,11} from "@behivetech/atoms.icon";12import type { RichTextEditorLabels } from "./rich-text-editor-labels.js";1314/** A group of controls the `features` prop can switch on or off. */15export type RichTextEditorFeature =16 | "bold"17 | "italic"18 | "heading"19 | "bulletList"20 | "orderedList"21 | "blockquote"22 | "link"23 | "image";2425export const ALL_FEATURES: RichTextEditorFeature[] = [26 "bold",27 "italic",28 "heading",29 "bulletList",30 "orderedList",31 "blockquote",32 "link",33 "image",34];3536export type ToolbarItemId =37 | "bold"38 | "italic"39 | "heading2"40 | "heading3"41 | "bulletList"42 | "orderedList"43 | "blockquote"44 | "link"45 | "image";4647export type ToolbarGroupId = "marks" | "headings" | "blocks" | "insert";4849/** What pressing a toolbar button does: toggle a format, or open something. */50export type ToolbarAction = "toggle" | "link" | "image";5152export interface ToolbarItem {53 id: ToolbarItemId;54 group: ToolbarGroupId;55 feature: RichTextEditorFeature;56 action: ToolbarAction;57 /** Key into the labels, which is also the accessible name */58 label: keyof RichTextEditorLabels;59 /** Glyph for icon buttons */60 Icon?: IconComponent;61 /** Key of the visible text for text buttons (H2, H3) */62 textLabel?: keyof RichTextEditorLabels;63}6465/** Toolbar order. Groups wrap as whole groups on narrow screens. */66export const TOOLBAR_ITEMS: ToolbarItem[] = [67 {68 id: "bold",69 group: "marks",70 feature: "bold",71 action: "toggle",72 label: "bold",73 Icon: BoldIcon,74 },75 {76 id: "italic",77 group: "marks",78 feature: "italic",79 action: "toggle",80 label: "italic",81 Icon: ItalicIcon,82 },83 {84 id: "heading2",85 group: "headings",86 feature: "heading",87 action: "toggle",88 label: "heading2",89 textLabel: "heading2Short",90 },91 {92 id: "heading3",93 group: "headings",94 feature: "heading",95 action: "toggle",96 label: "heading3",97 textLabel: "heading3Short",98 },99 {100 id: "bulletList",101 group: "blocks",102 feature: "bulletList",103 action: "toggle",104 label: "bulletList",105 Icon: ListIcon,106 },107 {108 id: "orderedList",109 group: "blocks",110 feature: "orderedList",111 action: "toggle",112 label: "orderedList",113 Icon: ListOrderedIcon,114 },115 {116 id: "blockquote",117 group: "blocks",118 feature: "blockquote",119 action: "toggle",120 label: "blockquote",121 Icon: QuoteIcon,122 },123 {124 id: "link",125 group: "insert",126 feature: "link",127 action: "link",128 label: "link",129 Icon: LinkIcon,130 },131 {132 id: "image",133 group: "insert",134 feature: "image",135 action: "image",136 label: "image",137 Icon: ImageIcon,138 },139];140141const HEADING_LEVEL_2 = 2;142const HEADING_LEVEL_3 = 3;143144/** Runs the toggle for an item against the editor, focusing it first. */145export const TOGGLE_COMMANDS: Partial<146 Record<ToolbarItemId, (editor: Editor) => void>147> = {148 bold: (editor) => editor.chain().focus().toggleBold().run(),149 italic: (editor) => editor.chain().focus().toggleItalic().run(),150 heading2: (editor) =>151 editor.chain().focus().toggleHeading({ level: HEADING_LEVEL_2 }).run(),152 heading3: (editor) =>153 editor.chain().focus().toggleHeading({ level: HEADING_LEVEL_3 }).run(),154 bulletList: (editor) => editor.chain().focus().toggleBulletList().run(),155 orderedList: (editor) => editor.chain().focus().toggleOrderedList().run(),156 blockquote: (editor) => editor.chain().focus().toggleBlockquote().run(),157};158159/** Reads whether the caret sits inside the format an item toggles. */160export const IS_ACTIVE: Record<ToolbarItemId, (editor: Editor) => boolean> = {161 bold: (editor) => editor.isActive("bold"),162 italic: (editor) => editor.isActive("italic"),163 heading2: (editor) => editor.isActive("heading", { level: HEADING_LEVEL_2 }),164 heading3: (editor) => editor.isActive("heading", { level: HEADING_LEVEL_3 }),165 bulletList: (editor) => editor.isActive("bulletList"),166 orderedList: (editor) => editor.isActive("orderedList"),167 blockquote: (editor) => editor.isActive("blockquote"),168 link: (editor) => editor.isActive("link"),169 image: () => false,170};
rich-text-editor-labels.ts
1/**2 * Every string the editor shows or announces. Registry packages ship no i18n3 * layer, so the app passes translated text through the `labels` prop and the4 * English defaults here fill the gaps.5 */6export interface RichTextEditorLabels {7 /** Accessible name of the editing area when the app passes no `aria-label` */8 editor: string;9 /** Accessible name of the toolbar */10 toolbar: string;11 bold: string;12 italic: string;13 /** Visible text of the H2 button */14 heading2Short: string;15 /** Accessible name of the H2 button; starts with the visible text (WCAG 2.5.3) */16 heading2: string;17 /** Visible text of the H3 button */18 heading3Short: string;19 /** Accessible name of the H3 button; starts with the visible text (WCAG 2.5.3) */20 heading3: string;21 bulletList: string;22 orderedList: string;23 blockquote: string;24 link: string;25 image: string;26 /** Title of the link dialog */27 linkTitle: string;28 linkText: string;29 linkAddress: string;30 linkAddressHint: string;31 linkAddressError: string;32 linkRemove: string;33 linkCancel: string;34 linkSave: string;35}3637/** English text for every label; the `labels` prop overrides single keys. */38export const DEFAULT_LABELS: RichTextEditorLabels = {39 editor: "Rich text editor",40 toolbar: "Formatting",41 bold: "Bold",42 italic: "Italic",43 heading2Short: "H2",44 heading2: "H2 heading",45 heading3Short: "H3",46 heading3: "H3 heading",47 bulletList: "Bulleted list",48 orderedList: "Numbered list",49 blockquote: "Quote",50 link: "Link",51 image: "Image",52 linkTitle: "Link",53 linkText: "Text",54 linkAddress: "Address",55 linkAddressHint:56 "A web page (https://…), an email (mailto:…) or a page on this site (/guide/…).",57 linkAddressError:58 "Enter a web address, a mailto: or tel: link, or a path that starts with /.",59 linkRemove: "Remove link",60 linkCancel: "Cancel",61 linkSave: "Save link",62};
rich-text-editor-link-dialog.tsx
1import { type FormEvent, useId, useState } from "react";2import { getClassName } from "@bht-component-registry/class-names";3import { Button } from "@behivetech/atoms.button";4import { Modal } from "@behivetech/atoms.modal";5import { TextField } from "@behivetech/atoms.text-field";6import { FormField } from "@behivetech/forms.form-field";7import { normalizeLinkHref } from "./link-href.js";8import type { RichTextEditorLabels } from "./rich-text-editor-labels.js";9import styles from "./rich-text-editor-link-dialog.module.scss";1011export interface LinkDialogValue {12 /** Visible text of the link; empty means "use the address" */13 text: string;14 /** Validated, trimmed address */15 href: string;16}1718export interface RichTextEditorLinkDialogProps {19 /** Text prefilled from the selection */20 initialText: string;21 /** Address prefilled when the caret is already in a link */22 initialHref: string;23 /** Shows "Remove link", only when the caret is in a link */24 canRemove: boolean;25 labels: RichTextEditorLabels;26 /** Called with the validated values when the form is submitted */27 onSave: (value: LinkDialogValue) => void;28 /** Called when the user removes the link */29 onRemove: () => void;30 /** Called on Cancel, Escape, backdrop click or the close button */31 onClose: () => void;32}3334/**35 * The small dialog the toolbar's Link button opens, on `Modal`. Enter submits,36 * Escape cancels, and focus starts in Address. An address that is not an37 * allowed link shows an inline error and the dialog stays open.38 */39export const RichTextEditorLinkDialog = ({40 initialText,41 initialHref,42 canRemove,43 labels,44 onSave,45 onRemove,46 onClose,47}: RichTextEditorLinkDialogProps) => {48 const [rootClass, getChildClass] = getClassName({49 rootClass: "BHT__RichTextEditorLinkDialog",50 styles,51 });52 const fieldId = useId();53 const hintId = `${fieldId}-hint`;54 const errorId = `${fieldId}-error`;55 const [text, setText] = useState(initialText);56 const [href, setHref] = useState(initialHref);57 const [hasError, setHasError] = useState(false);5859 const handleSubmit = (event: FormEvent<HTMLFormElement>) => {60 event.preventDefault();61 const normalized = normalizeLinkHref(href);6263 setHasError(normalized === null);64 if (normalized !== null) {65 onSave({ text: text.trim(), href: normalized });66 }67 };6869 return (70 // Modal's content portals to body, so the ghost-label colour is set through its className.71 <Modal72 className={rootClass}73 title={labels.linkTitle}74 headingLevel={2}75 padding="md"76 onClose={onClose}77 >78 <form79 className={getChildClass("form")}80 onSubmit={handleSubmit}81 noValidate82 >83 <FormField label={labels.linkText} htmlFor={`${fieldId}-text`}>84 <TextField85 id={`${fieldId}-text`}86 value={text}87 onChange={(event) => setText(event.target.value)}88 />89 </FormField>90 <FormField91 label={labels.linkAddress}92 htmlFor={`${fieldId}-address`}93 hint={<span id={hintId}>{labels.linkAddressHint}</span>}94 error={95 hasError ? (96 <span id={errorId}>{labels.linkAddressError}</span>97 ) : undefined98 }99 required100 >101 <TextField102 name="address"103 required104 aria-describedby={hasError ? errorId : hintId}105 // Focus starts in the one required field. Radix leaves focus alone when it is already inside the dialog.106 autoFocus107 id={`${fieldId}-address`}108 type="url"109 inputMode="url"110 value={href}111 invalid={hasError}112 onChange={(event) => {113 setHref(event.target.value);114 setHasError(false);115 }}116 />117 </FormField>118 <div className={getChildClass("actions")}>119 {canRemove && (120 <Button type="button" variant="ghost" onClick={onRemove}>121 {labels.linkRemove}122 </Button>123 )}124 <div className={getChildClass("main-actions")}>125 <Button type="button" variant="ghost" onClick={onClose}>126 {labels.linkCancel}127 </Button>128 <Button type="submit" variant="primary">129 {labels.linkSave}130 </Button>131 </div>132 </div>133 </form>134 </Modal>135 );136};
rich-text-editor-toolbar.tsx
1import {2 type KeyboardEvent,3 useCallback,4 useMemo,5 useRef,6 useState,7} from "react";8import { getClassName } from "@bht-component-registry/class-names";9import { Button } from "@behivetech/atoms.button";10import { Tooltip } from "@behivetech/atoms.tooltip";11import {12 type ToolbarGroupId,13 type ToolbarItem,14 type ToolbarItemId,15} from "./rich-text-editor-items.js";16import type { RichTextEditorLabels } from "./rich-text-editor-labels.js";17import styles from "./rich-text-editor-toolbar.module.scss";1819const GROUP_ORDER: ToolbarGroupId[] = ["marks", "headings", "blocks", "insert"];2021/** Where each navigation key lands, given the current index and the count. */22const KEY_TARGETS: Record<string, (index: number, count: number) => number> = {23 ArrowRight: (index, count) => (index + 1) % count,24 ArrowLeft: (index, count) => (index - 1 + count) % count,25 Home: () => 0,26 End: (_index, count) => count - 1,27};2829export interface RichTextEditorToolbarProps {30 /** Id of the editing area the toolbar controls */31 controls?: string;32 /** Buttons to show, already filtered by `features` */33 items: ToolbarItem[];34 /** Which items are pressed right now */35 pressed: Partial<Record<ToolbarItemId, boolean>>;36 /** Item whose action is in flight, shown as a loading button */37 busyId?: ToolbarItemId;38 /** Disables every button, used before the editor mounts */39 disabled: boolean;40 labels: RichTextEditorLabels;41 /** Called with the item the user pressed */42 action: (item: ToolbarItem, button: HTMLButtonElement) => void;43 className?: string;44}4546/**47 * The formatting toolbar: four groups of icon buttons that wrap as whole48 * groups. It is a single tab stop (roving tabindex); the arrow keys, Home and49 * End move between the buttons.50 */51export const RichTextEditorToolbar = ({52 controls,53 items,54 pressed,55 busyId,56 disabled,57 labels,58 action,59 className,60}: RichTextEditorToolbarProps) => {61 const [rootClass, getChildClass] = getClassName({62 className,63 rootClass: "BHT__RichTextEditorToolbar",64 styles,65 });66 const toolbarRef = useRef<HTMLDivElement>(null);67 const [activeIndex, setActiveIndex] = useState(0);6869 // `items` can shrink under a stored index; keep one button in the tab order.70 const tabStop = Math.min(activeIndex, items.length - 1);7172 const groups = useMemo(73 () =>74 GROUP_ORDER.map((group) => ({75 group,76 items: items.filter((item) => item.group === group),77 })).filter(({ items: groupItems }) => groupItems.length > 0),78 [items],79 );8081 const moveFocus = useCallback((index: number) => {82 const buttons = toolbarRef.current?.querySelectorAll("button");83 const target = buttons?.[index];84 if (target) {85 setActiveIndex(index);86 target.focus();87 }88 }, []);8990 const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {91 const getTarget = KEY_TARGETS[event.key];9293 if (getTarget) {94 event.preventDefault();95 moveFocus(getTarget(activeIndex, items.length));96 }97 };9899 return (100 <div101 ref={toolbarRef}102 role="toolbar"103 aria-label={labels.toolbar}104 aria-controls={controls}105 className={rootClass}106 onKeyDown={handleKeyDown}107 >108 {groups.map(({ group, items: groupItems }) => (109 <div key={group} className={getChildClass("group")}>110 {groupItems.map((item) => {111 const index = items.indexOf(item);112 const isPressed = pressed[item.id] === true;113 const label = labels[item.label];114 const { Icon } = item;115116 return (117 <Tooltip key={item.id} label={label}>118 <Button119 type="button"120 iconOnly121 size="md"122 variant={isPressed ? "primary" : "ghost"}123 aria-label={label}124 aria-pressed={item.action === "image" ? undefined : isPressed}125 aria-haspopup={item.action === "link" ? "dialog" : undefined}126 disabled={disabled}127 loading={busyId === item.id}128 tabIndex={index === tabStop ? 0 : -1}129 onFocus={() => setActiveIndex(index)}130 onClick={(event) => action(item, event.currentTarget)}131 >132 {Icon ? (133 <Icon size="sm" />134 ) : item.textLabel ? (135 labels[item.textLabel]136 ) : null}137 </Button>138 </Tooltip>139 );140 })}141 </div>142 ))}143 </div>144 );145};
rich-text-editor.tsx
1import { useEffect, useMemo, useRef } from "react";2import { EditorContent, useEditor, useEditorState } from "@tiptap/react";3import { getClassName } from "@bht-component-registry/class-names";4import { RichText, getRichTextClassName } from "./rich-text.js";5import { RichTextEditorLinkDialog } from "./rich-text-editor-link-dialog.js";6import { buildExtensions } from "./rich-text-editor-extensions.js";7import {8 ALL_FEATURES,9 IS_ACTIVE,10 TOGGLE_COMMANDS,11 TOOLBAR_ITEMS,12 type RichTextEditorFeature,13 type ToolbarItem,14 type ToolbarItemId,15} from "./rich-text-editor-items.js";16import {17 DEFAULT_LABELS,18 type RichTextEditorLabels,19} from "./rich-text-editor-labels.js";20import { RichTextEditorToolbar } from "./rich-text-editor-toolbar.js";21import {22 useRichTextEditorActions,23 type InsertImageResult,24} from "./use-rich-text-editor-actions.js";25import styles from "./rich-text-editor.module.scss";2627export type { RichTextEditorFeature } from "./rich-text-editor-items.js";28export type { RichTextEditorLabels } from "./rich-text-editor-labels.js";29export type {30 InsertImageResult,31 RichTextImage,32} from "./use-rich-text-editor-actions.js";3334export interface RichTextEditorProps {35 /** Id of the editing area. A `FormField` label cannot name a contenteditable, so also pass `aria-labelledby` */36 id?: string;37 /** Additional class names to merge with the component root element */38 className?: string;39 /** Current value as HTML. Sanitise it on save: the editor only produces what `RichText` lists, but a stored value can be anything */40 value: string;41 /** Called with the updated HTML on every edit; an empty document gives an empty string */42 onChange: (html: string) => void;43 /**44 * Shows the Image button and runs when it is pressed. Resolve with45 * `{ src, alt, width?, height? }`, or a plain address string (alt is then46 * empty), to insert the image at the caret; resolve with nothing, or47 * reject, to insert nothing. The button shows a loading state meanwhile.48 */49 onInsertImage?: () => InsertImageResult | Promise<InsertImageResult>;50 /** Which formats to offer. Defaults to all of them; the Image button also needs `onInsertImage` */51 features?: RichTextEditorFeature[];52 /** Marks the editor as invalid: adds the `--invalid` modifier and sets `aria-invalid`. `FormField` carries the message */53 invalid?: boolean;54 /** Text overrides for the toolbar and the link dialog, for translation. Missing keys fall back to English */55 labels?: Partial<RichTextEditorLabels>;56 /** Accessible name of the editing area; defaults to `labels.editor` */57 "aria-label"?: string;58 /** Id of the element that names the editing area, for example the `FormField` label */59 "aria-labelledby"?: string;60 /** Id of the element that describes the editing area, for example the `FormField` hint */61 "aria-describedby"?: string;62}6364interface ContentAttributesInput {65 id?: string;66 className: string;67 name: string;68 labelledBy?: string;69 describedBy?: string;70 invalid: boolean;71}7273/** The attributes of the contenteditable element: a labelled, multiline textbox. */74const getContentAttributes = ({75 id,76 className,77 name,78 labelledBy,79 describedBy,80 invalid,81}: ContentAttributesInput): Record<string, string> => ({82 ...(id ? { id } : {}),83 class: className,84 role: "textbox",85 "aria-multiline": "true",86 ...(labelledBy ? { "aria-labelledby": labelledBy } : { "aria-label": name }),87 ...(describedBy ? { "aria-describedby": describedBy } : {}),88 ...(invalid ? { "aria-invalid": "true" } : {}),89});9091/**92 * A TipTap-backed WYSIWYG editor: a toolbar (bold, italic, headings 2 and 3,93 * bulleted and numbered lists, quote, link, image) over a content area that94 * wears the `RichText` prose styles. Value in and out is HTML.95 *96 * Wrap it in `FormField`. Before TipTap mounts it shows the value read-only at97 * the same size with the toolbar disabled, so the page does not jump.98 */99export const RichTextEditor = ({100 id,101 className,102 value,103 onChange,104 onInsertImage,105 features = ALL_FEATURES,106 invalid = false,107 labels,108 "aria-label": ariaLabel,109 "aria-labelledby": ariaLabelledBy,110 "aria-describedby": ariaDescribedBy,111}: RichTextEditorProps) => {112 const [rootClass, getChildClass] = getClassName({113 className,114 rootClass: "BHT__RichTextEditor",115 modifiers: { invalid },116 styles,117 });118 const text = { ...DEFAULT_LABELS, ...labels };119 // `features` is usually an inline array, so its contents are the identity:120 // a new array with the same entries must not rebuild the editor.121 const featureKey = features.join();122 const extensions = useMemo(123 () => buildExtensions(featureKey.split(",") as RichTextEditorFeature[]),124 [featureKey],125 );126 const contentClass = getRichTextClassName(getChildClass("content"));127128 const attributes = getContentAttributes({129 id,130 className: contentClass,131 name: ariaLabel ?? text.editor,132 labelledBy: ariaLabelledBy,133 describedBy: ariaDescribedBy,134 invalid,135 });136 const attributesKey = JSON.stringify(attributes);137138 // The last HTML the editor and the app agreed on. Content is replaced only139 // when `value` moves away from it, so a stored value TipTap would serialise140 // differently does not reset the caret on mount or on an echoed onChange.141 const lastValue = useRef(value);142143 const editor = useEditor(144 {145 extensions,146 content: value,147 immediatelyRender: false,148 editorProps: { attributes },149 onUpdate: ({ editor: updated }) => {150 const html = updated.isEmpty ? "" : updated.getHTML();151 lastValue.current = html;152 onChange(html);153 },154 },155 [extensions],156 );157158 // Keep the content area's attributes current without remounting the editor.159 useEffect(() => {160 editor?.setOptions({161 editorProps: { attributes: JSON.parse(attributesKey) },162 });163 }, [editor, attributesKey]);164165 // Follow a `value` that changed from outside, such as a form reset.166 useEffect(() => {167 if (editor && value !== lastValue.current) {168 lastValue.current = value;169 editor.commands.setContent(value, { emitUpdate: false });170 }171 }, [value, editor]);172173 const pressed = useEditorState({174 editor,175 selector: ({ editor: current }) =>176 Object.fromEntries(177 Object.entries(IS_ACTIVE).map(([itemId, isActive]) => [178 itemId,179 current ? isActive(current) : false,180 ]),181 ) as Partial<Record<ToolbarItemId, boolean>>,182 });183184 const {185 linkDraft,186 isInsertingImage,187 openLink,188 saveLink,189 removeLink,190 closeLink,191 insertImage,192 } = useRichTextEditorActions(editor, onInsertImage);193194 const hasImagePicker = onInsertImage !== undefined;195 const items = useMemo(() => {196 const enabled = featureKey.split(",");197198 return TOOLBAR_ITEMS.filter(199 (item) =>200 enabled.includes(item.feature) &&201 (item.id !== "image" || hasImagePicker),202 );203 }, [featureKey, hasImagePicker]);204205 const handleAction = (item: ToolbarItem, button: HTMLButtonElement) => {206 const actions: Record<ToolbarItem["action"], () => void> = {207 toggle: () => editor && TOGGLE_COMMANDS[item.id]?.(editor),208 link: () => openLink(button),209 image: () => void insertImage(button),210 };211212 actions[item.action]();213 };214215 return (216 <div className={rootClass}>217 <RichTextEditorToolbar218 controls={id}219 items={items}220 pressed={pressed ?? {}}221 busyId={isInsertingImage ? "image" : undefined}222 disabled={!editor}223 labels={text}224 action={handleAction}225 />226 {editor ? (227 <EditorContent editor={editor} />228 ) : (229 <RichText230 html={value}231 className={getChildClass("content")}232 aria-busy="true"233 />234 )}235 {linkDraft && (236 <RichTextEditorLinkDialog237 initialText={linkDraft.text}238 initialHref={linkDraft.href}239 canRemove={linkDraft.canRemove}240 labels={text}241 onSave={saveLink}242 onRemove={removeLink}243 onClose={closeLink}244 />245 )}246 </div>247 );248};
rich-text-entry.ts
1export { RichText } from "./rich-text.js";2export type { RichTextProps } from "./rich-text.js";
rich-text.tsx
1import { type HTMLAttributes } from "react";2import { getClassName } from "@bht-component-registry/class-names";3import styles from "./rich-text.module.scss";45export interface RichTextProps extends Omit<6 HTMLAttributes<HTMLDivElement>,7 "children" | "dangerouslySetInnerHTML"8> {9 /** Additional class names to merge with the component root element */10 className?: string;11 /** Stored HTML to render. It must already be sanitised: `RichText` does not filter it */12 html: string;13}1415/**16 * Builds the root class of the prose styles. The editor puts it on its17 * contenteditable area so an admin edits exactly what a reader sees.18 *19 * @param className - Extra classes to merge with the prose root.20 */21export const getRichTextClassName = (className?: string): string => {22 const [rootClass] = getClassName({23 className,24 rootClass: "BHT__RichText",25 styles,26 });2728 return rootClass;29};3031/**32 * Renders the HTML the rich text editor stores, with the prose styles for33 * headings, lists, quotes, links and images, plus print-friendly rules.34 *35 * The component sets no width, so the page decides the measure. The HTML is36 * inserted as-is: sanitise it on save with an allowlist of what the editor can37 * produce (`p`, `h2`, `h3`, `strong`, `em`, `ul`, `ol`, `li`, `blockquote`,38 * `a[href]`, `img[src alt width height]`, `br`).39 */40export const RichText = ({ className, html, ...rest }: RichTextProps) => (41 <div42 {...rest}43 className={getRichTextClassName(className)}44 // The caller owns sanitising; see the JSDoc above.45 dangerouslySetInnerHTML={{ __html: html }}46 />47);
spec-helpers.tsx
1import { vi, type Mock } from "vitest";2import { act, render, screen } from "@testing-library/react";3import { RichTextEditor } from "./index.js";45// jsdom does no layout, and ProseMirror measures the caret to scroll it into6// view after a command. Empty rect lists are enough: nothing here asserts scroll.7export const stubLayout = () => {8 const emptyRects = () => [] as unknown as DOMRectList;9 const emptyRect = () => new DOMRect();10 Range.prototype.getClientRects = emptyRects;11 Range.prototype.getBoundingClientRect = emptyRect;12 Element.prototype.getClientRects = emptyRects;13};1415export const DOC =16 '<h2>Before you get in</h2><p>Plain <strong>bold words</strong> and <a href="/guide/who-to-call">Who to call</a>.</p>';1718/** Puts the DOM selection across a text node, as a user dragging would. */19export const selectText = (root: HTMLElement, text: string) => {20 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);21 let node = walker.nextNode();22 while (node && !node.textContent?.includes(text)) {23 node = walker.nextNode();24 }25 if (!node) {26 throw new Error(`No text node contains "${text}"`);27 }28 const start = node.textContent?.indexOf(text) ?? 0;29 // ProseMirror only reads the DOM selection while its view has focus.30 act(() => root.focus());31 const selection = window.getSelection();32 selection?.setBaseAndExtent(node, start, node, start + text.length);33 act(() => {34 document.dispatchEvent(new Event("selectionchange"));35 });36};3738export const renderEditor = async (39 props: Partial<React.ComponentProps<typeof RichTextEditor>> = {},40): Promise<{ area: HTMLElement; onChange: Mock<(html: string) => void> }> => {41 const onChange = vi.fn();42 render(43 <RichTextEditor value={DOC} onChange={onChange} id="body" {...props} />,44 );45 const area = await screen.findByRole("textbox");4647 return { area, onChange };48};
use-rich-text-editor-actions.ts
1import { useCallback, useEffect, useRef, useState } from "react";2import type { Editor } from "@tiptap/react";34/** What `onInsertImage` resolves to: a full description, or just the address. */5export interface RichTextImage {6 /** Image address */7 src: string;8 /** Alternative text; WCAG 1.1.1 needs it for any image that carries meaning */9 alt: string;10 /** Stored width in pixels, which stops layout shift */11 width?: number;12 /** Stored height in pixels */13 height?: number;14}1516/** What `onInsertImage` may return: an image, a bare address, or nothing to cancel. */17export type InsertImageResult = RichTextImage | string | null | undefined;1819export interface LinkDraft {20 text: string;21 href: string;22 canRemove: boolean;23}2425const SELECTED_TEXT_SEPARATOR = " ";2627const toImage = (result: InsertImageResult): RichTextImage | undefined => {28 const image = typeof result === "string" ? { src: result, alt: "" } : result;2930 return image?.src ? image : undefined;31};3233/** Selects the image just inserted, which sits right before the caret. */34const selectImageBeforeCaret = (editor: Editor) => {35 const { $from, from } = editor.state.selection;36 const before = $from.nodeBefore;3738 if (before?.type.name === "image") {39 editor.commands.setNodeSelection(from - before.nodeSize);40 }41};4243/**44 * Owns the two toolbar actions that do more than toggle a format: the link45 * dialog and the image callback.46 *47 * @param editor - The mounted editor, or null before TipTap mounts.48 * @param onInsertImage - The app's picker, if the image button is shown.49 */50export const useRichTextEditorActions = (51 editor: Editor | null,52 onInsertImage?: () => InsertImageResult | Promise<InsertImageResult>,53) => {54 const [linkDraft, setLinkDraft] = useState<LinkDraft | null>(null);55 const [isInsertingImage, setIsInsertingImage] = useState(false);56 const openerRef = useRef<HTMLButtonElement | null>(null);57 const wasLinkOpen = useRef(false);5859 // Radix returns focus only to a Dialog.Trigger, and `Modal` has none, so60 // focus is put back on the toolbar button once the dialog has unmounted.61 useEffect(() => {62 const isOpen = linkDraft !== null;63 const justClosed = wasLinkOpen.current && !isOpen;64 wasLinkOpen.current = isOpen;65 if (justClosed) {66 const frame = requestAnimationFrame(() => openerRef.current?.focus());6768 return () => cancelAnimationFrame(frame);69 }7071 return undefined;72 }, [linkDraft]);7374 const openLink = useCallback(75 (opener?: HTMLButtonElement) => {76 openerRef.current = opener ?? null;77 if (!editor) {78 return;79 }80 const canRemove = editor.isActive("link");81 if (canRemove) {82 editor.chain().extendMarkRange("link").run();83 }84 const { from, to } = editor.state.selection;85 setLinkDraft({86 text: editor.state.doc.textBetween(from, to, SELECTED_TEXT_SEPARATOR),87 href: (editor.getAttributes("link").href as string | undefined) ?? "",88 canRemove,89 });90 },91 [editor],92 );9394 const saveLink = useCallback(95 ({ text, href }: { text: string; href: string }) => {96 if (editor && linkDraft) {97 const label = text || linkDraft.text || href;98 const replacesText = label !== linkDraft.text;99 const chain = editor.chain();100101 if (replacesText) {102 chain.insertContent({103 type: "text",104 text: label,105 marks: [{ type: "link", attrs: { href } }],106 });107 } else {108 chain.setLink({ href });109 }110 chain.run();111 }112 setLinkDraft(null);113 },114 [editor, linkDraft],115 );116117 const removeLink = useCallback(() => {118 editor?.chain().extendMarkRange("link").unsetLink().run();119 setLinkDraft(null);120 }, [editor]);121122 const closeLink = useCallback(() => setLinkDraft(null), []);123124 const insertImage = useCallback(125 async (opener?: HTMLButtonElement) => {126 if (!editor || !onInsertImage) {127 return;128 }129 openerRef.current = opener ?? null;130 setIsInsertingImage(true);131 try {132 const image = toImage(await onInsertImage());133 if (image) {134 editor.chain().focus().setImage(image).run();135 selectImageBeforeCaret(editor);136 } else {137 // Nothing inserted: the pressed (now disabled) button had focus.138 requestAnimationFrame(() => openerRef.current?.focus());139 }140 } catch {141 // The app's picker reports its own failures; a rejection means "nothing inserted".142 requestAnimationFrame(() => openerRef.current?.focus());143 } finally {144 setIsInsertingImage(false);145 }146 },147 [editor, onInsertImage],148 );149150 return {151 linkDraft,152 isInsertingImage,153 openLink,154 saveLink,155 removeLink,156 closeLink,157 insertImage,158 };159};
rich-text-editor-link-dialog.module.scss
1// Content of the link dialog; the dialog itself is Modal.23.BHT__RichTextEditorLinkDialog {4 // Modal's panel is `surface-container-high`, where Button's default ghost5 // label (`primary`) is 4.02:1 in the-behive's Dark tokens. `secondary` is6 // 12.87:1 Dark and 5.40:1 Light there. Set through Modal's className because7 // its content portals to body, out of reach of a wrapper.8 --button-label-color: var(--md-sys-color-secondary);910 &__form {11 display: flex;12 flex-direction: column;13 gap: var(--spacing-4, 1rem);14 }1516 &__actions {17 display: flex;18 flex-wrap: wrap;19 align-items: center;20 justify-content: space-between;21 gap: var(--spacing-2, 0.5rem);22 }2324 &__main-actions {25 display: flex;26 gap: var(--spacing-2, 0.5rem);27 margin-inline-start: auto;28 }29}
rich-text-editor-toolbar.module.scss
1// The formatting toolbar of the rich text editor. Groups wrap as whole groups,2// so the toolbar never scrolls sideways (scrolled-off buttons would be hidden).34.BHT__RichTextEditorToolbar {5 position: sticky;6 inset-block-start: 0;7 z-index: 1;8 display: flex;9 flex-wrap: wrap;10 gap: var(--spacing-1, 0.25rem) var(--spacing-4, 1rem);11 padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);12 background: var(--md-sys-color-surface);13 border-block-end: 1px solid var(--md-sys-color-outline-variant);1415 &__group {16 display: flex;17 gap: var(--spacing-1, 0.25rem);18 }19}
rich-text-editor.module.scss
1// The rich text editor's frame: an outlined box like Textarea, with the2// toolbar on top and the content area below. The content area also wears3// RichText's prose root class, so the text looks as it will for readers.4//5// Edge: 1px outline, 2px primary while focus is inside, 1px error when6// invalid (2px error while focused). The thicker edge is an inset shadow, so7// nothing shifts.89.BHT__RichTextEditor {10 display: block;11 border: 1px solid var(--md-sys-color-outline);12 border-radius: var(--md-sys-shape-corner-extra-small, 4px);13 background: transparent;1415 &:focus-within {16 border-color: var(--md-sys-color-primary);17 box-shadow: inset 0 0 0 1px var(--md-sys-color-primary);18 }1920 &--invalid {21 border-color: var(--md-sys-color-error);2223 &:focus-within {24 border-color: var(--md-sys-color-error);25 box-shadow: inset 0 0 0 1px var(--md-sys-color-error);26 }27 }2829 &__content {30 min-block-size: 10rem;31 padding: var(--spacing-4, 1rem);3233 // The ProseMirror element is the focus target; the frame shows focus.34 &:focus {35 outline: none;36 }3738 // ProseMirror marks the node selected after an image insert.39 :global(.ProseMirror-selectednode) {40 outline: 2px solid var(--md-sys-color-primary);41 outline-offset: 2px;42 }43 }44}
rich-text.module.scss
1// Prose styles for stored rich text. The editor's content area wears the same2// root class, so the admin edits what readers get. The HTML carries no classes3// (it is sanitised to bare elements), so the styles sit on element selectors4// under this block, never on another component's classes.5//6// The component sets no width: the page decides the measure.78.BHT__RichText {9 display: flow-root;10 color: var(--md-sys-color-on-surface);11 font-family: var(--md-sys-typescale-body-large-font, var(--font-body));12 font-size: var(--md-sys-typescale-body-large-size, 1rem);13 font-weight: var(--md-sys-typescale-body-large-weight, 400);14 line-height: var(--md-sys-typescale-body-large-line-height, 1.5rem);15 letter-spacing: var(--md-sys-typescale-body-large-tracking, 0.0313em);16 overflow-wrap: break-word;1718 // spacing-4 between blocks; the first block starts flush.19 > * {20 margin-block: 0 var(--spacing-4, 1rem);21 }2223 > :last-child {24 margin-block-end: 0;25 }2627 h2 {28 margin-block-start: var(--spacing-8, 2rem);29 font-family: var(30 --md-sys-typescale-headline-small-font,31 var(--font-display)32 );33 font-size: var(--md-sys-typescale-headline-small-size, 1.5rem);34 font-weight: var(--md-sys-typescale-headline-small-weight, 700);35 line-height: var(--md-sys-typescale-headline-small-line-height, 2rem);36 letter-spacing: var(--md-sys-typescale-headline-small-tracking, 0);37 }3839 h3 {40 margin-block-start: var(--spacing-4, 1rem);41 font-family: var(--md-sys-typescale-title-large-font, var(--font-body));42 font-size: var(--md-sys-typescale-title-large-size, 1.375rem);43 font-weight: var(--md-sys-typescale-title-large-weight, 400);44 line-height: var(--md-sys-typescale-title-large-line-height, 1.75rem);45 letter-spacing: var(--md-sys-typescale-title-large-tracking, 0);46 }4748 > :first-child {49 margin-block-start: 0;50 }5152 strong {53 font-weight: 700;54 }5556 em {57 font-style: italic;58 }5960 ul,61 ol {62 padding-inline-start: var(--spacing-6, 1.5rem);63 }6465 ul {66 list-style: disc;67 }6869 ol {70 list-style: decimal;71 }7273 li > p {74 margin: 0;75 }7677 a {78 color: var(--md-sys-color-secondary);79 text-decoration: underline;80 text-underline-offset: 0.15em;81 }8283 blockquote {84 border-inline-start: 4px solid var(--md-sys-color-tertiary);85 padding-inline-start: var(--spacing-4, 1rem);86 color: var(--md-sys-color-on-surface-variant);8788 > p {89 margin: 0;90 }91 }9293 img {94 display: block;95 max-inline-size: 100%;96 block-size: auto;97 border-radius: var(--md-sys-shape-corner-small, 8px);98 }99100 // Print: system colors so a Dark page still prints dark-on-white, links101 // followed by their address, and nothing split where it hurts reading.102 @media print {103 // CanvasText follows the used colour scheme; pin it so a dark page still104 // prints dark on white whatever the engine does with backgrounds.105 color-scheme: light;106 color: CanvasText;107 // Canvas is white under color-scheme: light, so the prose box prints108 // dark on white even when the page behind it is dark and backgrounds print.109 background: Canvas;110 orphans: 3;111 widows: 3;112113 h2,114 h3 {115 color: CanvasText;116 break-after: avoid;117 }118119 li,120 blockquote,121 img {122 break-inside: avoid;123 }124125 a {126 color: CanvasText;127 }128129 a::after {130 content: " (" attr(href) ")";131 font-size: 0.875em;132 overflow-wrap: anywhere;133 }134135 blockquote {136 border-inline-start-color: CanvasText;137 color: CanvasText;138 }139140 img {141 max-block-size: 8cm;142 // The width and height attributes pin both sizes while the cap squashes143 // the height; auto on both lets the cap carry through the aspect ratio.144 inline-size: auto;145 block-size: auto;146 max-inline-size: 100%;147 }148 }149}
index.ts
1export { RichTextEditor } from "./rich-text-editor.js";2export type {3 InsertImageResult,4 RichTextEditorFeature,5 RichTextEditorLabels,6 RichTextEditorProps,7 RichTextImage,8} from "./rich-text-editor.js";9export { RichText } from "./rich-text.js";10export type { RichTextProps } from "./rich-text.js";
rich-text-editor.example.tsx
1import { useState } from "react";2import { FormField } from "@behivetech/forms.form-field";3import { RichText, RichTextEditor } from "./index.js";45const SAMPLE_HTML =6 '<h2>Before you get in</h2><p>The hot tub is on the back deck, past the <strong>string lights</strong>. It stays at 102°F all year.</p><ul><li>Shower first.</li><li>Keep the cover on whenever nobody is in.</li></ul><h3>Turning on the jets</h3><ol><li>Lift the cover from the house side.</li><li>Press <strong>Jets</strong> once for low, twice for high.</li></ol><blockquote><p>Please be out of the tub by 10 pm.</p></blockquote><p>Something not working? See <a href="/guide/who-to-call">Who to call</a>. <em>Never drain the tub yourself.</em></p>';78/** A controlled editor in a `FormField`. The preview below it is `RichText` on the same HTML. */9export const BasicRichTextEditor = () => {10 const [html, setHtml] = useState(SAMPLE_HTML);1112 return (13 <>14 <FormField15 label="About the house"16 htmlFor="about"17 hint="Shown on the home page."18 >19 <RichTextEditor20 id="about"21 value={html}22 onChange={setHtml}23 aria-label="About the house"24 />25 </FormField>26 <RichText html={html} />27 </>28 );29};3031/** With `onInsertImage`, the toolbar shows an Image button. The picker here resolves with a stand-in image. */32export const WithImageRichTextEditor = () => {33 const [html, setHtml] = useState("<p>Add a photo with the image button.</p>");3435 return (36 <RichTextEditor37 value={html}38 onChange={setHtml}39 aria-label="Page body"40 onInsertImage={async () => ({41 src: "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='640' height='360'><rect width='640' height='360' fill='%23bc13fe'/></svg>",42 alt: "A purple placeholder",43 width: 640,44 height: 360,45 })}46 />47 );48};4950/** `features` limits the toolbar to the formats a field needs, here a short bio. */51export const BioRichTextEditor = () => {52 const [html, setHtml] = useState("<p>Painter and potter.</p>");5354 return (55 <RichTextEditor56 value={html}57 onChange={setHtml}58 aria-label="Artist bio"59 features={["bold", "italic", "link"]}60 />61 );62};6364/** Invalid state for a required field that is empty; `FormField` shows the message. */65export const InvalidRichTextEditor = () => (66 <FormField label="Body" htmlFor="body" error="Add some text." required>67 <RichTextEditor68 id="body"69 value=""70 onChange={() => {}}71 aria-label="Body"72 invalid73 />74 </FormField>75);