@charcuterie/ui 4.19.0 → 4.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +2 -0
  2. package/dist/htmlEditor/HtmlEditor.d.ts +25 -0
  3. package/dist/htmlEditor/HtmlEditor.d.ts.map +1 -0
  4. package/dist/htmlEditor/HtmlEditor.js +233 -0
  5. package/dist/htmlEditor/HtmlEditor.js.map +1 -0
  6. package/dist/htmlEditor/HtmlView.d.ts +10 -0
  7. package/dist/htmlEditor/HtmlView.d.ts.map +1 -0
  8. package/dist/htmlEditor/HtmlView.js +28 -0
  9. package/dist/htmlEditor/HtmlView.js.map +1 -0
  10. package/dist/htmlEditor/htmlContentStyles.d.ts +3 -0
  11. package/dist/htmlEditor/htmlContentStyles.d.ts.map +1 -0
  12. package/dist/htmlEditor/htmlContentStyles.js +3 -0
  13. package/dist/htmlEditor/htmlContentStyles.js.map +1 -0
  14. package/dist/htmlEditor/index.d.ts +8 -0
  15. package/dist/htmlEditor/index.d.ts.map +1 -0
  16. package/dist/htmlEditor/index.js +4 -0
  17. package/dist/htmlEditor/index.js.map +1 -0
  18. package/dist/htmlEditor/safeHtml.d.ts +12 -0
  19. package/dist/htmlEditor/safeHtml.d.ts.map +1 -0
  20. package/dist/htmlEditor/safeHtml.js +137 -0
  21. package/dist/htmlEditor/safeHtml.js.map +1 -0
  22. package/dist/htmlEditor/view.d.ts +6 -0
  23. package/dist/htmlEditor/view.d.ts.map +1 -0
  24. package/dist/htmlEditor/view.js +3 -0
  25. package/dist/htmlEditor/view.js.map +1 -0
  26. package/package.json +52 -2
  27. package/src/htmlEditor/HtmlEditor.mdx +61 -0
  28. package/src/htmlEditor/HtmlEditor.stories.tsx +192 -0
  29. package/src/htmlEditor/HtmlEditor.test.tsx +308 -0
  30. package/src/htmlEditor/HtmlEditor.tsx +422 -0
  31. package/src/htmlEditor/HtmlView.mdx +37 -0
  32. package/src/htmlEditor/HtmlView.stories.tsx +119 -0
  33. package/src/htmlEditor/HtmlView.test.tsx +88 -0
  34. package/src/htmlEditor/HtmlView.tsx +71 -0
  35. package/src/htmlEditor/htmlContentStyles.ts +3 -0
  36. package/src/htmlEditor/htmlDocument.storyHelpers.ts +7 -0
  37. package/src/htmlEditor/index.ts +7 -0
  38. package/src/htmlEditor/safeHtml.test.ts +123 -0
  39. package/src/htmlEditor/safeHtml.ts +187 -0
  40. package/src/htmlEditor/view.ts +5 -0
  41. package/src/sourceRules.test.ts +22 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "4.19.0",
3
+ "version": "4.20.1",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,7 +40,17 @@
40
40
  "types": "./dist/tokens.d.ts",
41
41
  "default": "./dist/tokens.js"
42
42
  },
43
- "./src/*": "./src/*"
43
+ "./src/*": "./src/*",
44
+ "./html-editor": {
45
+ "source": "./src/htmlEditor/index.ts",
46
+ "types": "./dist/htmlEditor/index.d.ts",
47
+ "default": "./dist/htmlEditor/index.js"
48
+ },
49
+ "./html-view": {
50
+ "source": "./src/htmlEditor/view.ts",
51
+ "types": "./dist/htmlEditor/view.d.ts",
52
+ "default": "./dist/htmlEditor/view.js"
53
+ }
44
54
  },
45
55
  "files": [
46
56
  "dist",
@@ -66,6 +76,14 @@
66
76
  "@codemirror/state": "^6",
67
77
  "@codemirror/view": "^6",
68
78
  "@lezer/common": "^1",
79
+ "@tiptap/core": "^3.31.4",
80
+ "@tiptap/extension-image": "^3.31.4",
81
+ "@tiptap/extension-text-align": "^3.31.4",
82
+ "@tiptap/extension-text-style": "^3.31.4",
83
+ "@tiptap/pm": "^3.31.4",
84
+ "@tiptap/react": "^3.31.4",
85
+ "@tiptap/starter-kit": "^3.31.4",
86
+ "parse5": "^8.0.1",
69
87
  "react": ">=19",
70
88
  "react-dom": ">=19",
71
89
  "react-router": ">=7"
@@ -89,6 +107,30 @@
89
107
  "@lezer/common": {
90
108
  "optional": true
91
109
  },
110
+ "@tiptap/core": {
111
+ "optional": true
112
+ },
113
+ "@tiptap/extension-image": {
114
+ "optional": true
115
+ },
116
+ "@tiptap/extension-text-align": {
117
+ "optional": true
118
+ },
119
+ "@tiptap/extension-text-style": {
120
+ "optional": true
121
+ },
122
+ "@tiptap/pm": {
123
+ "optional": true
124
+ },
125
+ "@tiptap/react": {
126
+ "optional": true
127
+ },
128
+ "@tiptap/starter-kit": {
129
+ "optional": true
130
+ },
131
+ "parse5": {
132
+ "optional": true
133
+ },
92
134
  "react-router": {
93
135
  "optional": true
94
136
  }
@@ -102,8 +144,16 @@
102
144
  "@codemirror/view": "^6",
103
145
  "@lezer/common": "^1",
104
146
  "@storybook/react": "^10.3.6",
147
+ "@tiptap/core": "^3.31.4",
148
+ "@tiptap/extension-image": "^3.31.4",
149
+ "@tiptap/extension-text-align": "^3.31.4",
150
+ "@tiptap/extension-text-style": "^3.31.4",
151
+ "@tiptap/pm": "^3.31.4",
152
+ "@tiptap/react": "^3.31.4",
153
+ "@tiptap/starter-kit": "^3.31.4",
105
154
  "@types/react": "^19.2.14",
106
155
  "@types/react-dom": "^19.2.3",
156
+ "parse5": "^8.0.1",
107
157
  "react": "^19.2.8",
108
158
  "react-dom": "^19.2.8",
109
159
  "react-router": "^8.3.0",
@@ -0,0 +1,61 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+ import * as HtmlEditorStories from "./HtmlEditor.stories";
3
+
4
+ <Meta of={HtmlEditorStories} />
5
+
6
+ # HtmlEditor
7
+
8
+ Rich authoring for records stored as **HTML**. It lives in the opt-in
9
+ `@charcuterie/ui/html-editor` subpath. Markdown remains a separate format.
10
+
11
+ <Canvas of={HtmlEditorStories.Playground} />
12
+
13
+ ## Preserve untouched records
14
+
15
+ `defaultValue` initializes the document once. Focus, selection, blur and toolbar navigation
16
+ never call `onChange`. An optional `name` renders a hidden form control whose original HTML
17
+ bytes stay intact until a real edit. Changes emit sanitized HTML. A changed external default
18
+ does not replace the document or its undo history; use a React `key` to switch records.
19
+
20
+ ```tsx
21
+ import { Field } from "@charcuterie/ui"
22
+ import { HtmlEditor } from "@charcuterie/ui/html-editor"
23
+
24
+ <Field label="Project notes">
25
+ <HtmlEditor key={record.id} defaultValue={record.notes} name="notes" />
26
+ </Field>
27
+ ```
28
+
29
+ The editor supports bold, italic, underline, lists, alignment, links, text colours, images and
30
+ undo/redo. Keyboard shortcuts and document history come from Tiptap/ProseMirror; controls,
31
+ overflow, dialogs and field wiring come from Charcuterie. Apps supply optional icons.
32
+
33
+ <Canvas of={HtmlEditorStories.AllVariants} />
34
+
35
+ ## HTML is untrusted
36
+
37
+ Initial content and rich clipboard HTML pass through the same HTML5 parser and closed
38
+ document allowlist as `HtmlView`. Scripts, foreign namespaces, event handlers, arbitrary
39
+ attributes and resource-loading CSS are removed. URL attributes use the shared link/image
40
+ scheme guards. Colours and text alignment are the only retained inline CSS properties.
41
+ This is a safe display/edit projection, not an automatic rewrite of the stored source.
42
+
43
+ On the first real edit, unsupported markup is omitted from the edited HTML. Preserve the
44
+ original field for an untouched save. This format is intended for document notes, not arbitrary
45
+ web pages. Server-side authorization and storage validation remain the app's responsibility.
46
+
47
+ <Canvas of={HtmlEditorStories.AllStates} />
48
+
49
+ ## Install only when used
50
+
51
+ The subpath's optional peers are `parse5`, `@tiptap/core`, `@tiptap/react`, `@tiptap/pm`,
52
+ `@tiptap/starter-kit`, `@tiptap/extension-image`, `@tiptap/extension-text-style` and
53
+ `@tiptap/extension-text-align`. Importing the main UI entry loads none of them. A reader-only app uses `@charcuterie/ui/html-view`
54
+ and installs only `parse5`.
55
+
56
+ <Canvas of={HtmlEditorStories.Responsive} />
57
+ <Canvas of={HtmlEditorStories.Interactive} />
58
+
59
+ ## Props
60
+
61
+ <Controls />
@@ -0,0 +1,192 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import { useState } from "react"
4
+ import { Button } from "../Button/Button.tsx"
5
+ import {
6
+ ContainerBoard,
7
+ StoryCell,
8
+ StoryGrid,
9
+ } from "../board.storyHelpers.tsx"
10
+ import { Field } from "../Field/Field.tsx"
11
+ import { HtmlEditor } from "./HtmlEditor.tsx"
12
+ import { HtmlView } from "./HtmlView.tsx"
13
+ import {
14
+ HOSTILE_HTML,
15
+ HTML_DOCUMENT,
16
+ ORIGINAL_HTML,
17
+ } from "./htmlDocument.storyHelpers.ts"
18
+
19
+ const meta = {
20
+ title: "Components/Controls/HtmlEditor",
21
+ component: HtmlEditor,
22
+ parameters: { layout: "padded" },
23
+ args: {
24
+ defaultValue: HTML_DOCUMENT,
25
+ label: "Notes",
26
+ isDisabled: false,
27
+ isReadOnly: false,
28
+ },
29
+ argTypes: { icons: { control: false } },
30
+ } satisfies Meta<typeof HtmlEditor>
31
+ export default meta
32
+ type Story = StoryObj<typeof meta>
33
+
34
+ export const Playground: Story = {
35
+ parameters: playgroundParameters,
36
+ }
37
+ export const AllVariants: Story = {
38
+ render: () => (
39
+ <StoryGrid columns={2}>
40
+ <StoryCell align="stretch" label="Existing rich HTML">
41
+ <HtmlEditor
42
+ defaultValue={HTML_DOCUMENT}
43
+ label="Rich notes"
44
+ toolbarLabel="Rich note formatting"
45
+ />
46
+ </StoryCell>
47
+ <StoryCell align="stretch" label="Inside a Field">
48
+ <Field
49
+ label="Recipe notes"
50
+ description="Stored as HTML; existing notes stay untouched until an edit."
51
+ >
52
+ <HtmlEditor
53
+ defaultValue="<p>Inspect the fit.</p>"
54
+ toolbarLabel="Recipe note formatting"
55
+ />
56
+ </Field>
57
+ </StoryCell>
58
+ <StoryCell align="stretch" label="Empty">
59
+ <HtmlEditor
60
+ label="Empty notes"
61
+ toolbarLabel="Empty note formatting"
62
+ />
63
+ </StoryCell>
64
+ </StoryGrid>
65
+ ),
66
+ }
67
+ export const AllStates: Story = {
68
+ render: () => (
69
+ <StoryGrid columns={2}>
70
+ <StoryCell align="stretch" label="Editing">
71
+ <HtmlEditor
72
+ defaultValue={HTML_DOCUMENT}
73
+ label="Editable notes"
74
+ toolbarLabel="Editable note formatting"
75
+ />
76
+ </StoryCell>
77
+ <StoryCell align="stretch" label="Read only">
78
+ <HtmlEditor
79
+ defaultValue={HTML_DOCUMENT}
80
+ isReadOnly
81
+ label="Read-only notes"
82
+ toolbarLabel="Read-only note formatting"
83
+ />
84
+ </StoryCell>
85
+ <StoryCell align="stretch" label="Disabled">
86
+ <HtmlEditor
87
+ defaultValue={HTML_DOCUMENT}
88
+ isDisabled
89
+ label="Disabled notes"
90
+ toolbarLabel="Disabled note formatting"
91
+ />
92
+ </StoryCell>
93
+ <StoryCell align="stretch" label="Invalid">
94
+ <Field
95
+ error="Add notes before saving."
96
+ isRequired
97
+ label="Required notes"
98
+ >
99
+ <HtmlEditor toolbarLabel="Required note formatting" />
100
+ </Field>
101
+ </StoryCell>
102
+ </StoryGrid>
103
+ ),
104
+ }
105
+ export const Responsive: Story = {
106
+ render: () => (
107
+ <ContainerBoard>
108
+ {(width) => (
109
+ <HtmlEditor
110
+ defaultValue={HTML_DOCUMENT}
111
+ label={`Notes at ${width}`}
112
+ toolbarLabel={`Formatting at ${width}`}
113
+ />
114
+ )}
115
+ </ContainerBoard>
116
+ ),
117
+ }
118
+ export const Interactive: Story = {
119
+ render: () => {
120
+ const [value, setValue] = useState(ORIGINAL_HTML)
121
+ const [defaultValue, setDefaultValue] =
122
+ useState(ORIGINAL_HTML)
123
+ const [changes, setChanges] = useState(0)
124
+ const [submitted, setSubmitted] = useState("")
125
+ return (
126
+ <div className="space-y-4">
127
+ <form
128
+ aria-label="Notes form"
129
+ className="space-y-4"
130
+ onSubmit={(event) => {
131
+ event.preventDefault()
132
+ setSubmitted(
133
+ String(
134
+ new FormData(event.currentTarget).get(
135
+ "notes",
136
+ ),
137
+ ),
138
+ )
139
+ }}
140
+ >
141
+ <Field label="Saved notes">
142
+ <HtmlEditor
143
+ defaultValue={defaultValue}
144
+ name="notes"
145
+ onChange={(next) => {
146
+ setValue(next)
147
+ setChanges((count) => count + 1)
148
+ }}
149
+ />
150
+ </Field>
151
+ <div className="flex flex-wrap gap-2">
152
+ <Button type="submit">Save notes</Button>
153
+ <Button
154
+ appearance="outline"
155
+ onClick={() =>
156
+ setDefaultValue(
157
+ "<p>External default changed</p>",
158
+ )
159
+ }
160
+ >
161
+ Change external default
162
+ </Button>
163
+ </div>
164
+ </form>
165
+ <p>Document edits: {changes}</p>
166
+ <section aria-label="Stored HTML">
167
+ <pre className="whitespace-pre-wrap wrap-anywhere">
168
+ {value}
169
+ </pre>
170
+ </section>
171
+ <section aria-label="Submitted HTML">
172
+ <pre className="whitespace-pre-wrap wrap-anywhere">
173
+ {submitted}
174
+ </pre>
175
+ </section>
176
+ <HtmlView
177
+ label="Saved note preview"
178
+ value={value}
179
+ />
180
+ </div>
181
+ )
182
+ },
183
+ }
184
+ export const Hostile: Story = {
185
+ args: {
186
+ defaultValue: HOSTILE_HTML,
187
+ label: "Untrusted notes",
188
+ },
189
+ }
190
+ export const Blank: Story = {
191
+ args: { defaultValue: "", label: "New notes" },
192
+ }
@@ -0,0 +1,308 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import {
3
+ expect,
4
+ userEvent,
5
+ waitFor,
6
+ within,
7
+ } from "storybook/test"
8
+ import { test } from "vitest"
9
+ import { userEvent as browserUserEvent } from "vitest/browser"
10
+
11
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
12
+ import { mountStory } from "../mountStory.testHelpers.ts"
13
+ import { expectAgentDrivable } from "../testing/index.ts"
14
+ import * as stories from "./HtmlEditor.stories.tsx"
15
+ import { ORIGINAL_HTML } from "./htmlDocument.storyHelpers.ts"
16
+
17
+ const {
18
+ Playground,
19
+ Interactive,
20
+ AllStates,
21
+ Hostile,
22
+ Blank,
23
+ Responsive,
24
+ } = composeStories(stories)
25
+
26
+ const pressAction = async (
27
+ canvas: ReturnType<typeof within>,
28
+ body: ReturnType<typeof within>,
29
+ name: string,
30
+ ) => {
31
+ const action = canvas.queryByRole("button", { name })
32
+ if (action) {
33
+ await userEvent.click(action)
34
+ return
35
+ }
36
+ await userEvent.click(
37
+ canvas.getByRole("button", { name: "More actions" }),
38
+ )
39
+ await userEvent.click(body.getByRole("button", { name }))
40
+ }
41
+
42
+ test("formatting a selection emits rich HTML and announces the active format", async () => {
43
+ const { canvas, body } = await mountStory(Interactive)
44
+ const editor = await canvas.findByRole("textbox", {
45
+ name: "Saved notes",
46
+ })
47
+ await userEvent.click(editor)
48
+ await browserUserEvent.keyboard("{Control>}a{/Control}")
49
+ await pressAction(canvas, body, "Bold")
50
+ await expect(
51
+ editor.querySelector("strong"),
52
+ ).toHaveTextContent("Original & untouched")
53
+ await expect(
54
+ canvas.getByRole("button", { name: "Bold" }),
55
+ ).toHaveAttribute("aria-pressed", "true")
56
+ await expect(
57
+ canvas.getByLabelText("Stored HTML"),
58
+ ).toHaveTextContent("<strong>")
59
+ })
60
+
61
+ test("link dialog rejects script schemes; canceling does not edit the document", async () => {
62
+ const { canvas, body } = await mountStory(Interactive)
63
+ const editor = await canvas.findByRole("textbox", {
64
+ name: "Saved notes",
65
+ })
66
+ await userEvent.click(editor)
67
+ await browserUserEvent.keyboard("{Control>}a{/Control}")
68
+ await pressAction(canvas, body, "Edit link")
69
+ const dialog = body.getByRole("dialog", {
70
+ name: "Edit link",
71
+ })
72
+ const fields = within(dialog)
73
+ await waitFor(() =>
74
+ expect(
75
+ fields.getByRole("textbox", { name: "URL" }),
76
+ ).toHaveFocus(),
77
+ )
78
+ await userEvent.type(
79
+ fields.getByRole("textbox", { name: "URL" }),
80
+ "javascript:attack()",
81
+ )
82
+ await userEvent.click(
83
+ fields.getByRole("button", { name: "Apply" }),
84
+ )
85
+ await expect(
86
+ fields.getByText("Enter a safe link URL."),
87
+ ).toBeInTheDocument()
88
+ await expect(
89
+ canvas.getByLabelText("Submitted HTML").textContent,
90
+ ).toBe("")
91
+ await expectNoAxeViolations(dialog)
92
+ await userEvent.click(
93
+ fields.getByRole("button", { name: "Cancel" }),
94
+ )
95
+ await expect(
96
+ canvas.getByText("Document edits: 0"),
97
+ ).toBeInTheDocument()
98
+ await expect(
99
+ canvas.getByLabelText("Stored HTML"),
100
+ ).toHaveTextContent(ORIGINAL_HTML)
101
+ })
102
+
103
+ test("applying a safe relative link edits the draft without submitting its record form", async () => {
104
+ const { canvas, body } = await mountStory(Interactive)
105
+ const editor = await canvas.findByRole("textbox", {
106
+ name: "Saved notes",
107
+ })
108
+ await userEvent.click(editor)
109
+ await browserUserEvent.keyboard("{Control>}a{/Control}")
110
+ await pressAction(canvas, body, "Edit link")
111
+ const dialog = body.getByRole("dialog", {
112
+ name: "Edit link",
113
+ })
114
+ await userEvent.type(
115
+ within(dialog).getByRole("textbox", { name: "URL" }),
116
+ "/recipe",
117
+ )
118
+ await userEvent.click(
119
+ within(dialog).getByRole("button", { name: "Apply" }),
120
+ )
121
+ await expect(editor.querySelector("a")).toHaveAttribute(
122
+ "href",
123
+ "/recipe",
124
+ )
125
+ await expect(editor.querySelector("a")).toHaveTextContent(
126
+ "Original & untouched",
127
+ )
128
+ await expect(
129
+ canvas.getByLabelText("Submitted HTML").textContent,
130
+ ).toBe("")
131
+ await userEvent.click(
132
+ canvas.getByRole("button", { name: "Save notes" }),
133
+ )
134
+ await expect(
135
+ canvas.getByLabelText("Submitted HTML").textContent,
136
+ ).toBe(canvas.getByLabelText("Stored HTML").textContent)
137
+ })
138
+
139
+ test("the editor and shared toolbar are named controls with keyboard access", async () => {
140
+ const { canvas, canvasElement } =
141
+ await mountStory(Playground)
142
+ await waitFor(() =>
143
+ expectAgentDrivable(canvas, {
144
+ role: "textbox",
145
+ name: "Notes",
146
+ }),
147
+ )
148
+ expectAgentDrivable(canvas, {
149
+ role: "toolbar",
150
+ name: "HTML formatting",
151
+ })
152
+ await expect(
153
+ canvas.getByRole("textbox", { name: "Notes" }),
154
+ ).toHaveAttribute("aria-multiline", "true")
155
+ await expectNoAxeViolations(canvasElement)
156
+ })
157
+
158
+ test("mount, focus, selection, blur and an untouched form submission preserve exact original HTML", async () => {
159
+ const { canvas } = await mountStory(Interactive)
160
+ const editor = await canvas.findByRole("textbox", {
161
+ name: "Saved notes",
162
+ })
163
+ await userEvent.click(editor)
164
+ await userEvent.keyboard("{ArrowRight}{ArrowLeft}")
165
+ await userEvent.click(
166
+ canvas.getByRole("button", { name: "Save notes" }),
167
+ )
168
+ await expect(
169
+ canvas.getByLabelText("Stored HTML"),
170
+ ).toHaveTextContent(ORIGINAL_HTML)
171
+ await expect(
172
+ canvas.getByLabelText("Submitted HTML"),
173
+ ).toHaveTextContent(ORIGINAL_HTML)
174
+ await expect(
175
+ canvas.getByText("Document edits: 0"),
176
+ ).toBeInTheDocument()
177
+ })
178
+
179
+ test("external defaults never overwrite the document; real edits emit HTML and keep undo/redo", async () => {
180
+ const { canvas } = await mountStory(Interactive)
181
+ const editor = await canvas.findByRole("textbox", {
182
+ name: "Saved notes",
183
+ })
184
+ await userEvent.click(
185
+ canvas.getByRole("button", {
186
+ name: "Change external default",
187
+ }),
188
+ )
189
+ await expect(editor).toHaveTextContent(
190
+ "Original & untouched",
191
+ )
192
+ await expect(
193
+ canvas.getByText("Document edits: 0"),
194
+ ).toBeInTheDocument()
195
+ await userEvent.click(editor)
196
+ await browserUserEvent.keyboard(
197
+ "{Control>}{End}{/Control} plus",
198
+ )
199
+ await waitFor(() =>
200
+ expect(
201
+ canvas.getByLabelText("Stored HTML"),
202
+ ).toHaveTextContent("plus"),
203
+ )
204
+ await browserUserEvent.keyboard("{Control>}z{/Control}")
205
+ await expect(editor).not.toHaveTextContent("plus")
206
+ await browserUserEvent.keyboard(
207
+ "{Control>}{Shift>}z{/Shift}{/Control}",
208
+ )
209
+ await expect(editor).toHaveTextContent("plus")
210
+ await expect(
211
+ canvas.getByLabelText("Stored HTML"),
212
+ ).not.toHaveTextContent("data-old-format")
213
+ })
214
+
215
+ test("read-only and disabled editors refuse typing and formatting", async () => {
216
+ const { canvas } = await mountStory(AllStates)
217
+ const readOnly = await canvas.findByRole("textbox", {
218
+ name: "Read-only notes",
219
+ })
220
+ await expect(readOnly).toHaveAttribute(
221
+ "contenteditable",
222
+ "false",
223
+ )
224
+ await expect(readOnly).toHaveAttribute(
225
+ "aria-readonly",
226
+ "true",
227
+ )
228
+ const disabled = canvas.getByRole("textbox", {
229
+ name: "Disabled notes",
230
+ })
231
+ await expect(disabled).toHaveAttribute(
232
+ "aria-disabled",
233
+ "true",
234
+ )
235
+ await expect(disabled).toHaveAttribute("tabindex", "-1")
236
+ await expect(
237
+ within(
238
+ canvas.getByRole("toolbar", {
239
+ name: "Read-only note formatting",
240
+ }),
241
+ ).getByRole("button", { name: "Bold" }),
242
+ ).toBeDisabled()
243
+ const required = canvas.getByRole("textbox", {
244
+ name: "Required notes",
245
+ })
246
+ await expect(required).toHaveAttribute(
247
+ "aria-invalid",
248
+ "true",
249
+ )
250
+ await expect(required).toHaveAttribute(
251
+ "aria-required",
252
+ "true",
253
+ )
254
+ })
255
+
256
+ test("initial untrusted HTML is filtered before entering the editing DOM", async () => {
257
+ const { canvas } = await mountStory(Hostile)
258
+ const editor = await canvas.findByRole("textbox", {
259
+ name: "Untrusted notes",
260
+ })
261
+ await expect(editor).toHaveTextContent("Visible notes")
262
+ await expect(
263
+ editor.querySelector(
264
+ "script,style,iframe,svg,math,[onclick],[onerror],a[href],img",
265
+ ),
266
+ ).toBeNull()
267
+ })
268
+
269
+ test("rich clipboard HTML crosses the same safe policy before it becomes a document edit", async () => {
270
+ const { canvas } = await mountStory(Blank)
271
+ const editor = await canvas.findByRole("textbox", {
272
+ name: "New notes",
273
+ })
274
+ await userEvent.click(editor)
275
+ const data = new DataTransfer()
276
+ data.setData(
277
+ "text/html",
278
+ '<p onclick="attack()"><strong>Pasted</strong> notes<a href="java&#x09;script:attack()">unsafe</a></p><img src="javascript:attack()" onerror="attack()"><script>attack()</script>',
279
+ )
280
+ editor.dispatchEvent(
281
+ new ClipboardEvent("paste", {
282
+ bubbles: true,
283
+ cancelable: true,
284
+ clipboardData: data,
285
+ }),
286
+ )
287
+ await expect(editor).toHaveTextContent(
288
+ "Pasted notesunsafe",
289
+ )
290
+ await expect(
291
+ editor.querySelector("strong"),
292
+ ).toHaveTextContent("Pasted")
293
+ await expect(
294
+ editor.querySelector(
295
+ "script,img,[onclick],[onerror],a[href]",
296
+ ),
297
+ ).toBeNull()
298
+ })
299
+
300
+ test("a narrow container uses the shared toolbar overflow without page overflow", async () => {
301
+ const { canvas } = await mountStory(Responsive)
302
+ for (const editor of await canvas.findAllByRole(
303
+ "textbox",
304
+ ))
305
+ await expect(editor.scrollWidth).toBeLessThanOrEqual(
306
+ editor.clientWidth + 1,
307
+ )
308
+ })