Rich Text Editor
v0.1.3DraftA 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 · depends on @behivetech/atoms.button, @behivetech/atoms.icon, @behivetech/atoms.modal, @behivetech/atoms.text-field, @behivetech/atoms.tooltip, @behivetech/forms.form-field
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);
rich-text-editor.behaviour.spec.tsx
1import { createRequire } from "node:module";2import { existsSync } from "node:fs";3import path from "node:path";4import { render, screen, waitFor, within } from "@testing-library/react";5import userEvent from "@testing-library/user-event";6import { renderToString } from "react-dom/server";7import { RichTextEditor } from "./index.js";8import { renderEditor, selectText, stubLayout } from "./spec-helpers.js";910beforeAll(stubLayout);1112/** Pastes HTML into the editing area the way a browser delivers it. */13const pasteHtml = (area: HTMLElement, html: string) => {14 // jsdom has no DataTransfer; ProseMirror only reads getData, types and files.15 const formats: Record<string, string> = {16 "text/html": html,17 "text/plain": "pasted",18 };19 const data = {20 types: Object.keys(formats),21 files: [],22 items: [],23 getData: (type: string) => formats[type] ?? "",24 };25 const event = new Event("paste", { bubbles: true, cancelable: true });26 Object.defineProperty(event, "clipboardData", { value: data });27 area.dispatchEvent(event);28};2930describe("RichTextEditor paste", () => {31 it("keeps only what the documented allowlist names", async () => {32 const { area, onChange } = await renderEditor({ value: "" });33 pasteHtml(34 area,35 [36 '<a href="https://ok.test" target="_blank" rel="x" class="c" title="t">ok</a>',37 '<a href=" https://pad.test ">pad</a>',38 '<a href="//evil.test">protocol</a>',39 '<a href="ftp://files.test">ftp</a>',40 '<a href="guide/relative">relative</a>',41 '<ol start="3" type="a" class="c"><li>three</li></ol>',42 '<img src="/a.png" alt="A" title="t" class="c" width="10" height="20">',43 ].join(""),44 );45 await waitFor(() => expect(onChange).toHaveBeenCalled());46 const html = onChange.mock.calls.at(-1)?.[0] as string;47 expect(html).toContain('<a href="https://ok.test">ok</a>');48 expect(html).toContain('<a href="https://pad.test">pad</a>');49 expect(html).not.toMatch(/target=|rel=|class=|title=/);50 expect(html).not.toContain("evil.test");51 expect(html).not.toContain("ftp:");52 expect(html).not.toContain("guide/relative");53 expect(html).toContain('width="10"');54 expect(html).toMatch(/<ol (start="3" type="a"|type="a" start="3")>/);55 });56});5758describe("RichTextEditor focus return", () => {59 const openDialog = async (target = "Plain") => {60 const { area, onChange } = await renderEditor();61 selectText(area, target);62 const linkButton = screen.getByRole("button", { name: "Link" });63 await userEvent.click(linkButton);64 const dialog = await screen.findByRole("dialog");6566 return { dialog, linkButton, onChange };67 };6869 it.each(["Cancel", "Remove link", "Save link"])(70 "puts focus back on the Link button after %s",71 async (name) => {72 // Remove link is only offered inside a link, so open the dialog on one.73 const { dialog, linkButton } = await openDialog(74 name === "Remove link" ? "Who to call" : "Plain",75 );76 if (name === "Save link") {77 await userEvent.type(78 within(dialog).getByLabelText(/Address/),79 "/guide/x",80 );81 }82 await userEvent.click(within(dialog).getByRole("button", { name }));83 await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());84 await waitFor(() => expect(document.activeElement).toBe(linkButton));85 },86 );8788 it("puts focus back on the Link button after Escape", async () => {89 const { linkButton } = await openDialog();90 await userEvent.keyboard("{Escape}");91 await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());92 await waitFor(() => expect(document.activeElement).toBe(linkButton));93 });9495 it("refocuses the Image button when the picker resolves empty", async () => {96 await renderEditor({ onInsertImage: () => Promise.resolve(undefined) });97 const image = screen.getByRole("button", { name: "Image" });98 await userEvent.click(image);99 await waitFor(() => expect(document.activeElement).toBe(image));100 });101});102103describe("RichTextEditor value sync", () => {104 it("replaces the content when value changes from outside", async () => {105 const onChange = vi.fn();106 const { rerender } = render(107 <RichTextEditor value="<p>One</p>" onChange={onChange} id="b" />,108 );109 await screen.findByText("One");110 rerender(<RichTextEditor value="<p>Two</p>" onChange={onChange} id="b" />);111 await screen.findByText("Two");112 expect(onChange).not.toHaveBeenCalled();113 });114115 it("leaves a value TipTap would serialise differently alone on mount", async () => {116 const { area, onChange } = await renderEditor({117 value: "<ul><li>text</li></ul>",118 });119 expect(onChange).not.toHaveBeenCalled();120 expect(area.querySelector("li")).toBeTruthy();121 });122});123124describe("RichTextEditor link state", () => {125 it("exposes the Link button as pressed inside a link", async () => {126 const { area } = await renderEditor();127 selectText(area, "Who to call");128 await waitFor(() =>129 expect(130 screen131 .getByRole("button", { name: "Link" })132 .getAttribute("aria-pressed"),133 ).toBe("true"),134 );135 });136});137138describe("published CommonJS entry", () => {139 const cjs = path.join(import.meta.dirname, "dist", "index.cjs");140 const run = existsSync(cjs) ? it : it.skip;141142 // `__toESM(require("@tiptap/starter-kit"))` made `.default` the whole module143 // and rendering threw in any CJS consumer; named imports fix it.144 run("renders on the server through require()", () => {145 const entry = createRequire(cjs)(cjs) as {146 RichTextEditor: typeof RichTextEditor;147 };148 const html = renderToString(149 <entry.RichTextEditor value="<p>Hi</p>" onChange={() => {}} />,150 );151 expect(html).toContain("Hi");152 });153});
rich-text-editor.spec.tsx
1import { render, screen, waitFor, within } from "@testing-library/react";2import { renderToString } from "react-dom/server";3import userEvent from "@testing-library/user-event";4import { RichText, RichTextEditor } from "./index.js";5import { DOC, renderEditor, selectText, stubLayout } from "./spec-helpers.js";6import { normalizeLinkHref } from "./link-href.js";78beforeAll(stubLayout);910describe("RichText", () => {11 it("renders the stored html", () => {12 render(<RichText html="<h2>Hot tub</h2><p>Keep the cover on.</p>" />);13 expect(14 screen.getByRole("heading", { level: 2, name: "Hot tub" }),15 ).toBeTruthy();16 expect(screen.getByText("Keep the cover on.")).toBeTruthy();17 });1819 it("merges an additional className", () => {20 const { container } = render(<RichText html="<p>x</p>" className="page" />);21 expect((container.firstChild as HTMLElement).className).toContain("page");22 });23});2425describe("RichTextEditor toolbar", () => {26 it("finds every button by its accessible name", async () => {27 await renderEditor({ onInsertImage: () => "/a.png" });28 const toolbar = screen.getByRole("toolbar", { name: "Formatting" });29 for (const name of [30 "Bold",31 "Italic",32 "H2 heading",33 "H3 heading",34 "Bulleted list",35 "Numbered list",36 "Quote",37 "Link",38 "Image",39 ]) {40 expect(within(toolbar).getByRole("button", { name })).toBeTruthy();41 }42 });4344 it("hides the Image button without onInsertImage", async () => {45 await renderEditor();46 expect(screen.queryByRole("button", { name: "Image" })).toBeNull();47 });4849 it("hides the groups that features leaves out", async () => {50 await renderEditor({ features: ["bold", "link"] });51 expect(screen.getByRole("button", { name: "Bold" })).toBeTruthy();52 expect(screen.queryByRole("button", { name: "Italic" })).toBeNull();53 expect(screen.queryByRole("button", { name: "H2 heading" })).toBeNull();54 });5556 it("renders the value read-only with a disabled toolbar before TipTap mounts", () => {57 const html = renderToString(58 <RichTextEditor value={DOC} onChange={() => {}} />,59 );60 expect(html).toContain("Before you get in");61 expect(html).toContain('aria-busy="true"');62 expect(html).toMatch(/<button[^>]*disabled/);63 });6465 it("reflects the active marks at the caret", async () => {66 const { area } = await renderEditor();67 selectText(area, "bold words");68 await waitFor(() =>69 expect(70 screen71 .getByRole("button", { name: "Bold" })72 .getAttribute("aria-pressed"),73 ).toBe("true"),74 );75 expect(76 screen77 .getByRole("button", { name: "Italic" })78 .getAttribute("aria-pressed"),79 ).toBe("false");80 });8182 it("calls onChange with html when a format is applied", async () => {83 const { area, onChange } = await renderEditor();84 selectText(area, "Plain");85 await userEvent.click(screen.getByRole("button", { name: "Italic" }));86 await waitFor(() => expect(onChange).toHaveBeenCalled());87 expect(onChange.mock.calls.at(-1)?.[0]).toContain("<em>Plain</em>");88 });8990 it("moves between buttons with the arrow keys", async () => {91 await renderEditor();92 const bold = screen.getByRole("button", { name: "Bold" });93 bold.focus();94 await userEvent.keyboard("{ArrowRight}");95 expect(document.activeElement).toBe(96 screen.getByRole("button", { name: "Italic" }),97 );98 await userEvent.keyboard("{End}");99 expect(document.activeElement).toBe(100 screen.getByRole("button", { name: "Link" }),101 );102 });103});104105describe("RichTextEditor value", () => {106 it("gives an empty string for an empty document", async () => {107 const { area, onChange } = await renderEditor({ value: "<p>x</p>" });108 selectText(area, "x");109 await userEvent.keyboard("{Backspace}");110 await waitFor(() => expect(onChange).toHaveBeenCalledWith(""));111 });112113 it("marks the editing area invalid", async () => {114 const { area } = await renderEditor({ invalid: true });115 expect(area.getAttribute("aria-invalid")).toBe("true");116 });117});118119describe("RichTextEditor image", () => {120 it("inserts the image a callback resolves with", async () => {121 const onInsertImage = vi.fn().mockResolvedValue({122 src: "/media/tub.jpg",123 alt: "Hot tub at dusk",124 width: 640,125 height: 360,126 });127 const { onChange } = await renderEditor({ onInsertImage });128 await userEvent.click(screen.getByRole("button", { name: "Image" }));129 await waitFor(() => expect(onChange).toHaveBeenCalled());130 expect(onInsertImage).toHaveBeenCalledTimes(1);131 const html = onChange.mock.calls.at(-1)?.[0] as string;132 expect(html).toContain('src="/media/tub.jpg"');133 expect(html).toContain('alt="Hot tub at dusk"');134 expect(html).toContain('width="640"');135 });136137 it("accepts a plain address string", async () => {138 const { onChange } = await renderEditor({139 onInsertImage: () => "/media/a.png",140 });141 await userEvent.click(screen.getByRole("button", { name: "Image" }));142 await waitFor(() => expect(onChange).toHaveBeenCalled());143 expect(onChange.mock.calls.at(-1)?.[0]).toContain('src="/media/a.png"');144 });145146 it("inserts nothing when the callback resolves empty", async () => {147 const { onChange } = await renderEditor({148 onInsertImage: () => Promise.resolve(undefined),149 });150 await userEvent.click(screen.getByRole("button", { name: "Image" }));151 await waitFor(() =>152 expect(153 (screen.getByRole("button", { name: "Image" }) as HTMLButtonElement)154 .disabled,155 ).toBe(false),156 );157 expect(onChange).not.toHaveBeenCalled();158 });159});160161describe("RichTextEditor link dialog", () => {162 it("prefills from a link and saves a new address", async () => {163 const { area, onChange } = await renderEditor();164 selectText(area, "Who to call");165 await userEvent.click(screen.getByRole("button", { name: "Link" }));166 const dialog = await screen.findByRole("dialog");167 const address = within(dialog).getByLabelText(/Address/);168 expect((address as HTMLInputElement).value).toBe("/guide/who-to-call");169 expect(170 within(dialog).getByRole("button", { name: "Remove link" }),171 ).toBeTruthy();172 await userEvent.clear(address);173 await userEvent.type(address, "mailto:alex@example.com");174 await userEvent.click(175 within(dialog).getByRole("button", { name: "Save link" }),176 );177 await waitFor(() => expect(onChange).toHaveBeenCalled());178 expect(onChange.mock.calls.at(-1)?.[0]).toContain(179 'href="mailto:alex@example.com"',180 );181 });182183 it("starts with focus in Address", async () => {184 const { area } = await renderEditor();185 selectText(area, "Plain");186 await userEvent.click(screen.getByRole("button", { name: "Link" }));187 const dialog = await screen.findByRole("dialog");188 await waitFor(() =>189 expect(document.activeElement).toBe(190 within(dialog).getByLabelText(/Address/),191 ),192 );193 });194195 it("keeps the dialog open with an error for a bad address", async () => {196 const { area, onChange } = await renderEditor();197 selectText(area, "Plain");198 await userEvent.click(screen.getByRole("button", { name: "Link" }));199 const dialog = await screen.findByRole("dialog");200 expect(201 within(dialog).queryByRole("button", { name: "Remove link" }),202 ).toBeNull();203 await userEvent.type(204 within(dialog).getByLabelText(/Address/),205 "javascript:alert(1)",206 );207 await userEvent.click(208 within(dialog).getByRole("button", { name: "Save link" }),209 );210 expect(within(dialog).getByText(/Enter a web address/)).toBeTruthy();211 expect(screen.getByRole("dialog")).toBeTruthy();212 expect(onChange).not.toHaveBeenCalled();213 });214215 it("removes the link", async () => {216 const { area, onChange } = await renderEditor();217 selectText(area, "Who to call");218 await userEvent.click(screen.getByRole("button", { name: "Link" }));219 const dialog = await screen.findByRole("dialog");220 await userEvent.click(221 within(dialog).getByRole("button", { name: "Remove link" }),222 );223 await waitFor(() => expect(onChange).toHaveBeenCalled());224 expect(onChange.mock.calls.at(-1)?.[0]).not.toContain("<a");225 });226227 it("closes on Cancel without changing the value", async () => {228 const { area, onChange } = await renderEditor();229 selectText(area, "Plain");230 await userEvent.click(screen.getByRole("button", { name: "Link" }));231 const dialog = await screen.findByRole("dialog");232 await userEvent.click(233 within(dialog).getByRole("button", { name: "Cancel" }),234 );235 await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());236 expect(onChange).not.toHaveBeenCalled();237 });238});239240describe("normalizeLinkHref", () => {241 it.each([242 ["https://example.com/a", "https://example.com/a"],243 [" http://example.com ", "http://example.com"],244 ["mailto:alex@example.com", "mailto:alex@example.com"],245 ["tel:+15555550100", "tel:+15555550100"],246 ["/guide/hot-tub", "/guide/hot-tub"],247 ])("accepts %s", (input, expected) => {248 expect(normalizeLinkHref(input)).toBe(expected);249 });250251 it.each([252 "javascript:alert(1)",253 "data:text/html,x",254 "//evil.com",255 "hot tub",256 "",257 ])("rejects %s", (input) => {258 expect(normalizeLinkHref(input)).toBeNull();259 });260});