@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.
- package/README.md +2 -0
- package/dist/htmlEditor/HtmlEditor.d.ts +25 -0
- package/dist/htmlEditor/HtmlEditor.d.ts.map +1 -0
- package/dist/htmlEditor/HtmlEditor.js +233 -0
- package/dist/htmlEditor/HtmlEditor.js.map +1 -0
- package/dist/htmlEditor/HtmlView.d.ts +10 -0
- package/dist/htmlEditor/HtmlView.d.ts.map +1 -0
- package/dist/htmlEditor/HtmlView.js +28 -0
- package/dist/htmlEditor/HtmlView.js.map +1 -0
- package/dist/htmlEditor/htmlContentStyles.d.ts +3 -0
- package/dist/htmlEditor/htmlContentStyles.d.ts.map +1 -0
- package/dist/htmlEditor/htmlContentStyles.js +3 -0
- package/dist/htmlEditor/htmlContentStyles.js.map +1 -0
- package/dist/htmlEditor/index.d.ts +8 -0
- package/dist/htmlEditor/index.d.ts.map +1 -0
- package/dist/htmlEditor/index.js +4 -0
- package/dist/htmlEditor/index.js.map +1 -0
- package/dist/htmlEditor/safeHtml.d.ts +12 -0
- package/dist/htmlEditor/safeHtml.d.ts.map +1 -0
- package/dist/htmlEditor/safeHtml.js +137 -0
- package/dist/htmlEditor/safeHtml.js.map +1 -0
- package/dist/htmlEditor/view.d.ts +6 -0
- package/dist/htmlEditor/view.d.ts.map +1 -0
- package/dist/htmlEditor/view.js +3 -0
- package/dist/htmlEditor/view.js.map +1 -0
- package/package.json +52 -2
- package/src/htmlEditor/HtmlEditor.mdx +61 -0
- package/src/htmlEditor/HtmlEditor.stories.tsx +192 -0
- package/src/htmlEditor/HtmlEditor.test.tsx +308 -0
- package/src/htmlEditor/HtmlEditor.tsx +422 -0
- package/src/htmlEditor/HtmlView.mdx +37 -0
- package/src/htmlEditor/HtmlView.stories.tsx +119 -0
- package/src/htmlEditor/HtmlView.test.tsx +88 -0
- package/src/htmlEditor/HtmlView.tsx +71 -0
- package/src/htmlEditor/htmlContentStyles.ts +3 -0
- package/src/htmlEditor/htmlDocument.storyHelpers.ts +7 -0
- package/src/htmlEditor/index.ts +7 -0
- package/src/htmlEditor/safeHtml.test.ts +123 -0
- package/src/htmlEditor/safeHtml.ts +187 -0
- package/src/htmlEditor/view.ts +5 -0
- 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.
|
|
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	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
|
+
})
|