Behive Tech Component Registry

Rich Text Editor

v0.1.3Draft

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.

pnpm add @behivetech/molecules.rich-text-editor

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;
3
4/** Matches a path on the current site: a single leading slash, never `//host`. */
5const SITE_PATH_HREF = /^\/(?!\/)\S*$/;
6
7const HREF_PATTERNS = [ABSOLUTE_HREF, SITE_PATH_HREF];
8
9/**
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 the
13 * 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));
21
22 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";
7
8/**
9 * Only the attributes the README allowlist names are kept, so pasted HTML
10 * cannot smuggle `target`, `rel`, `class` or `title` past a sanitiser that
11 * 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});
22
23const SafeImage = Image.extend({
24 addAttributes: () => ({
25 src: { default: null },
26 alt: { default: null },
27 width: { default: null },
28 height: { default: null },
29 }),
30});
31
32const HEADING_LEVELS: (2 | 3)[] = [2, 3];
33
34/**
35 * Builds the TipTap extensions for a feature set. The set is also the output
36 * 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 keyboard
38 * shortcut or a paste, so the consuming app's sanitiser only has to allow
39 * 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 the
61 // options (and the dependency) are explicit.
62 link: false,
63 }),
64 ];
65
66 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 }
79
80 if (isOn("image")) {
81 extensions.push(SafeImage.configure({ inline: false }));
82 }
83
84 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";
13
14/** 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";
24
25export const ALL_FEATURES: RichTextEditorFeature[] = [
26 "bold",
27 "italic",
28 "heading",
29 "bulletList",
30 "orderedList",
31 "blockquote",
32 "link",
33 "image",
34];
35
36export type ToolbarItemId =
37 | "bold"
38 | "italic"
39 | "heading2"
40 | "heading3"
41 | "bulletList"
42 | "orderedList"
43 | "blockquote"
44 | "link"
45 | "image";
46
47export type ToolbarGroupId = "marks" | "headings" | "blocks" | "insert";
48
49/** What pressing a toolbar button does: toggle a format, or open something. */
50export type ToolbarAction = "toggle" | "link" | "image";
51
52export 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}
64
65/** 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];
140
141const HEADING_LEVEL_2 = 2;
142const HEADING_LEVEL_3 = 3;
143
144/** 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};
158
159/** 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 i18n
3 * layer, so the app passes translated text through the `labels` prop and the
4 * 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}
36
37/** 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";
10
11export interface LinkDialogValue {
12 /** Visible text of the link; empty means "use the address" */
13 text: string;
14 /** Validated, trimmed address */
15 href: string;
16}
17
18export 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}
33
34/**
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 an
37 * 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);
58
59 const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
60 event.preventDefault();
61 const normalized = normalizeLinkHref(href);
62
63 setHasError(normalized === null);
64 if (normalized !== null) {
65 onSave({ text: text.trim(), href: normalized });
66 }
67 };
68
69 return (
70 // Modal's content portals to body, so the ghost-label colour is set through its className.
71 <Modal
72 className={rootClass}
73 title={labels.linkTitle}
74 headingLevel={2}
75 padding="md"
76 onClose={onClose}
77 >
78 <form
79 className={getChildClass("form")}
80 onSubmit={handleSubmit}
81 noValidate
82 >
83 <FormField label={labels.linkText} htmlFor={`${fieldId}-text`}>
84 <TextField
85 id={`${fieldId}-text`}
86 value={text}
87 onChange={(event) => setText(event.target.value)}
88 />
89 </FormField>
90 <FormField
91 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 ) : undefined
98 }
99 required
100 >
101 <TextField
102 name="address"
103 required
104 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 autoFocus
107 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";
18
19const GROUP_ORDER: ToolbarGroupId[] = ["marks", "headings", "blocks", "insert"];
20
21/** 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};
28
29export 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}
45
46/**
47 * The formatting toolbar: four groups of icon buttons that wrap as whole
48 * groups. It is a single tab stop (roving tabindex); the arrow keys, Home and
49 * 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);
68
69 // `items` can shrink under a stored index; keep one button in the tab order.
70 const tabStop = Math.min(activeIndex, items.length - 1);
71
72 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 );
80
81 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 }, []);
89
90 const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
91 const getTarget = KEY_TARGETS[event.key];
92
93 if (getTarget) {
94 event.preventDefault();
95 moveFocus(getTarget(activeIndex, items.length));
96 }
97 };
98
99 return (
100 <div
101 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;
115
116 return (
117 <Tooltip key={item.id} label={label}>
118 <Button
119 type="button"
120 iconOnly
121 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";
26
27export 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";
33
34export 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 with
45 * `{ src, alt, width?, height? }`, or a plain address string (alt is then
46 * empty), to insert the image at the caret; resolve with nothing, or
47 * 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}
63
64interface ContentAttributesInput {
65 id?: string;
66 className: string;
67 name: string;
68 labelledBy?: string;
69 describedBy?: string;
70 invalid: boolean;
71}
72
73/** 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});
90
91/**
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 that
94 * 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 at
97 * 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"));
127
128 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);
137
138 // The last HTML the editor and the app agreed on. Content is replaced only
139 // when `value` moves away from it, so a stored value TipTap would serialise
140 // differently does not reset the caret on mount or on an echoed onChange.
141 const lastValue = useRef(value);
142
143 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 );
157
158 // 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]);
164
165 // 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]);
172
173 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 });
183
184 const {
185 linkDraft,
186 isInsertingImage,
187 openLink,
188 saveLink,
189 removeLink,
190 closeLink,
191 insertImage,
192 } = useRichTextEditorActions(editor, onInsertImage);
193
194 const hasImagePicker = onInsertImage !== undefined;
195 const items = useMemo(() => {
196 const enabled = featureKey.split(",");
197
198 return TOOLBAR_ITEMS.filter(
199 (item) =>
200 enabled.includes(item.feature) &&
201 (item.id !== "image" || hasImagePicker),
202 );
203 }, [featureKey, hasImagePicker]);
204
205 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 };
211
212 actions[item.action]();
213 };
214
215 return (
216 <div className={rootClass}>
217 <RichTextEditorToolbar
218 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 <RichText
230 html={value}
231 className={getChildClass("content")}
232 aria-busy="true"
233 />
234 )}
235 {linkDraft && (
236 <RichTextEditorLinkDialog
237 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";
4
5export 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}
14
15/**
16 * Builds the root class of the prose styles. The editor puts it on its
17 * 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 });
27
28 return rootClass;
29};
30
31/**
32 * Renders the HTML the rich text editor stores, with the prose styles for
33 * 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 is
36 * inserted as-is: sanitise it on save with an allowlist of what the editor can
37 * 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 <div
42 {...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";
4
5// jsdom does no layout, and ProseMirror measures the caret to scroll it into
6// 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};
14
15export 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>';
17
18/** 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};
37
38export 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");
46
47 return { area, onChange };
48};
use-rich-text-editor-actions.ts
1import { useCallback, useEffect, useRef, useState } from "react";
2import type { Editor } from "@tiptap/react";
3
4/** 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}
15
16/** What `onInsertImage` may return: an image, a bare address, or nothing to cancel. */
17export type InsertImageResult = RichTextImage | string | null | undefined;
18
19export interface LinkDraft {
20 text: string;
21 href: string;
22 canRemove: boolean;
23}
24
25const SELECTED_TEXT_SEPARATOR = " ";
26
27const toImage = (result: InsertImageResult): RichTextImage | undefined => {
28 const image = typeof result === "string" ? { src: result, alt: "" } : result;
29
30 return image?.src ? image : undefined;
31};
32
33/** 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;
37
38 if (before?.type.name === "image") {
39 editor.commands.setNodeSelection(from - before.nodeSize);
40 }
41};
42
43/**
44 * Owns the two toolbar actions that do more than toggle a format: the link
45 * 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);
58
59 // Radix returns focus only to a Dialog.Trigger, and `Modal` has none, so
60 // 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());
67
68 return () => cancelAnimationFrame(frame);
69 }
70
71 return undefined;
72 }, [linkDraft]);
73
74 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 );
93
94 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();
100
101 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 );
116
117 const removeLink = useCallback(() => {
118 editor?.chain().extendMarkRange("link").unsetLink().run();
119 setLinkDraft(null);
120 }, [editor]);
121
122 const closeLink = useCallback(() => setLinkDraft(null), []);
123
124 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 );
149
150 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.
2
3.BHT__RichTextEditorLinkDialog {
4 // Modal's panel is `surface-container-high`, where Button's default ghost
5 // label (`primary`) is 4.02:1 in the-behive's Dark tokens. `secondary` is
6 // 12.87:1 Dark and 5.40:1 Light there. Set through Modal's className because
7 // its content portals to body, out of reach of a wrapper.
8 --button-label-color: var(--md-sys-color-secondary);
9
10 &__form {
11 display: flex;
12 flex-direction: column;
13 gap: var(--spacing-4, 1rem);
14 }
15
16 &__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 }
23
24 &__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).
3
4.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);
14
15 &__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 the
2// toolbar on top and the content area below. The content area also wears
3// 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 when
6// invalid (2px error while focused). The thicker edge is an inset shadow, so
7// nothing shifts.
8
9.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;
14
15 &:focus-within {
16 border-color: var(--md-sys-color-primary);
17 box-shadow: inset 0 0 0 1px var(--md-sys-color-primary);
18 }
19
20 &--invalid {
21 border-color: var(--md-sys-color-error);
22
23 &: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 }
28
29 &__content {
30 min-block-size: 10rem;
31 padding: var(--spacing-4, 1rem);
32
33 // The ProseMirror element is the focus target; the frame shows focus.
34 &:focus {
35 outline: none;
36 }
37
38 // 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 same
2// root class, so the admin edits what readers get. The HTML carries no classes
3// (it is sanitised to bare elements), so the styles sit on element selectors
4// under this block, never on another component's classes.
5//
6// The component sets no width: the page decides the measure.
7
8.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;
17
18 // spacing-4 between blocks; the first block starts flush.
19 > * {
20 margin-block: 0 var(--spacing-4, 1rem);
21 }
22
23 > :last-child {
24 margin-block-end: 0;
25 }
26
27 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 }
38
39 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 }
47
48 > :first-child {
49 margin-block-start: 0;
50 }
51
52 strong {
53 font-weight: 700;
54 }
55
56 em {
57 font-style: italic;
58 }
59
60 ul,
61 ol {
62 padding-inline-start: var(--spacing-6, 1.5rem);
63 }
64
65 ul {
66 list-style: disc;
67 }
68
69 ol {
70 list-style: decimal;
71 }
72
73 li > p {
74 margin: 0;
75 }
76
77 a {
78 color: var(--md-sys-color-secondary);
79 text-decoration: underline;
80 text-underline-offset: 0.15em;
81 }
82
83 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);
87
88 > p {
89 margin: 0;
90 }
91 }
92
93 img {
94 display: block;
95 max-inline-size: 100%;
96 block-size: auto;
97 border-radius: var(--md-sys-shape-corner-small, 8px);
98 }
99
100 // Print: system colors so a Dark page still prints dark-on-white, links
101 // 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 still
104 // 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 prints
108 // dark on white even when the page behind it is dark and backgrounds print.
109 background: Canvas;
110 orphans: 3;
111 widows: 3;
112
113 h2,
114 h3 {
115 color: CanvasText;
116 break-after: avoid;
117 }
118
119 li,
120 blockquote,
121 img {
122 break-inside: avoid;
123 }
124
125 a {
126 color: CanvasText;
127 }
128
129 a::after {
130 content: " (" attr(href) ")";
131 font-size: 0.875em;
132 overflow-wrap: anywhere;
133 }
134
135 blockquote {
136 border-inline-start-color: CanvasText;
137 color: CanvasText;
138 }
139
140 img {
141 max-block-size: 8cm;
142 // The width and height attributes pin both sizes while the cap squashes
143 // 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";
4
5const 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>';
7
8/** 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);
11
12 return (
13 <>
14 <FormField
15 label="About the house"
16 htmlFor="about"
17 hint="Shown on the home page."
18 >
19 <RichTextEditor
20 id="about"
21 value={html}
22 onChange={setHtml}
23 aria-label="About the house"
24 />
25 </FormField>
26 <RichText html={html} />
27 </>
28 );
29};
30
31/** 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>");
34
35 return (
36 <RichTextEditor
37 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};
49
50/** `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>");
53
54 return (
55 <RichTextEditor
56 value={html}
57 onChange={setHtml}
58 aria-label="Artist bio"
59 features={["bold", "italic", "link"]}
60 />
61 );
62};
63
64/** 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 <RichTextEditor
68 id="body"
69 value=""
70 onChange={() => {}}
71 aria-label="Body"
72 invalid
73 />
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";
9
10beforeAll(stubLayout);
11
12/** 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};
29
30describe("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});
57
58describe("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");
65
66 return { dialog, linkButton, onChange };
67 };
68
69 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 );
87
88 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 });
94
95 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});
102
103describe("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 });
114
115 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});
123
124describe("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 screen
131 .getByRole("button", { name: "Link" })
132 .getAttribute("aria-pressed"),
133 ).toBe("true"),
134 );
135 });
136});
137
138describe("published CommonJS entry", () => {
139 const cjs = path.join(import.meta.dirname, "dist", "index.cjs");
140 const run = existsSync(cjs) ? it : it.skip;
141
142 // `__toESM(require("@tiptap/starter-kit"))` made `.default` the whole module
143 // 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";
7
8beforeAll(stubLayout);
9
10describe("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 });
18
19 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});
24
25describe("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 });
43
44 it("hides the Image button without onInsertImage", async () => {
45 await renderEditor();
46 expect(screen.queryByRole("button", { name: "Image" })).toBeNull();
47 });
48
49 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 });
55
56 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 });
64
65 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 screen
71 .getByRole("button", { name: "Bold" })
72 .getAttribute("aria-pressed"),
73 ).toBe("true"),
74 );
75 expect(
76 screen
77 .getByRole("button", { name: "Italic" })
78 .getAttribute("aria-pressed"),
79 ).toBe("false");
80 });
81
82 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 });
89
90 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});
104
105describe("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 });
112
113 it("marks the editing area invalid", async () => {
114 const { area } = await renderEditor({ invalid: true });
115 expect(area.getAttribute("aria-invalid")).toBe("true");
116 });
117});
118
119describe("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 });
136
137 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 });
145
146 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});
160
161describe("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 });
182
183 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 });
194
195 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 });
214
215 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 });
226
227 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});
239
240describe("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 });
250
251 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});