@charcuterie/ui 3.19.2 → 3.20.0
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/dist/Board/BoardCard.d.ts +54 -5
- package/dist/Board/BoardCard.d.ts.map +1 -1
- package/dist/Board/BoardCard.js +2 -2
- package/dist/Board/BoardCard.js.map +1 -1
- package/dist/MarkdownEditor/markdownSpans.d.ts +17 -4
- package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -1
- package/dist/MarkdownEditor/markdownSpans.js.map +1 -1
- package/dist/MarkdownLine/MarkdownLine.d.ts +104 -0
- package/dist/MarkdownLine/MarkdownLine.d.ts.map +1 -0
- package/dist/MarkdownLine/MarkdownLine.js +187 -0
- package/dist/MarkdownLine/MarkdownLine.js.map +1 -0
- package/dist/MarkdownLine/inlineMarkdown.d.ts +82 -0
- package/dist/MarkdownLine/inlineMarkdown.d.ts.map +1 -0
- package/dist/MarkdownLine/inlineMarkdown.js +509 -0
- package/dist/MarkdownLine/inlineMarkdown.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/markdownEditorCodeMirror/index.d.ts +1 -1
- package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/index.js +1 -1
- package/dist/markdownEditorCodeMirror/index.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
- package/dist/safeUrls.d.ts.map +1 -0
- package/dist/safeUrls.js.map +1 -0
- package/package.json +1 -1
- package/src/Board/Board.stories.tsx +67 -0
- package/src/Board/Board.test.tsx +35 -0
- package/src/Board/BoardCard.tsx +65 -10
- package/src/MarkdownEditor/markdownSpans.ts +18 -5
- package/src/MarkdownLine/MarkdownLine.mdx +135 -0
- package/src/MarkdownLine/MarkdownLine.stories.tsx +290 -0
- package/src/MarkdownLine/MarkdownLine.test.tsx +241 -0
- package/src/MarkdownLine/MarkdownLine.tsx +331 -0
- package/src/MarkdownLine/inlineMarkdown.test.ts +253 -0
- package/src/MarkdownLine/inlineMarkdown.ts +768 -0
- package/src/index.ts +9 -2
- package/src/markdownEditorCodeMirror/index.ts +4 -4
- package/src/markdownEditorCodeMirror/livePreviewRanges.ts +1 -1
- package/src/sourceRules.test.ts +17 -1
- package/dist/markdownEditorCodeMirror/safeUrls.d.ts.map +0 -1
- package/dist/markdownEditorCodeMirror/safeUrls.js.map +0 -1
- /package/dist/{markdownEditorCodeMirror/safeUrls.d.ts → safeUrls.d.ts} +0 -0
- /package/dist/{markdownEditorCodeMirror/safeUrls.js → safeUrls.js} +0 -0
- /package/src/{markdownEditorCodeMirror/safeUrls.test.ts → safeUrls.test.ts} +0 -0
- /package/src/{markdownEditorCodeMirror/safeUrls.ts → safeUrls.ts} +0 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
StoryCell,
|
|
6
|
+
StoryGrid,
|
|
7
|
+
StorySection,
|
|
8
|
+
} from "../board.storyHelpers.tsx"
|
|
9
|
+
import { RouterLinkProvider } from "../RouterLink/RouterLinkProvider.tsx"
|
|
10
|
+
import type { RouterLinkProps } from "../RouterLink/routerLink.ts"
|
|
11
|
+
import { MarkdownLine } from "./MarkdownLine.tsx"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A stand-in for react-router's `<Link>`, the same one
|
|
15
|
+
* `TextLink.stories.tsx` uses. The marker attribute is how the
|
|
16
|
+
* boards show which destinations the injected router was handed and
|
|
17
|
+
* which went straight to the platform.
|
|
18
|
+
*/
|
|
19
|
+
const SoftRouterLink = ({
|
|
20
|
+
href,
|
|
21
|
+
...linkProps
|
|
22
|
+
}: RouterLinkProps): ReactNode => (
|
|
23
|
+
<a {...linkProps} data-router="soft" href={href} />
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Task names, invented. Every string in this file is fixture data —
|
|
28
|
+
* this package is published, and a screenshot of a story is not
|
|
29
|
+
* greppable by anybody checking for household detail later.
|
|
30
|
+
*/
|
|
31
|
+
const CARD_TITLES = [
|
|
32
|
+
String.raw`Ingest 53 movies from \`Downloads/MOVIES\` into \`G:\Movies\``.replaceAll(
|
|
33
|
+
String.raw`\``,
|
|
34
|
+
"`",
|
|
35
|
+
),
|
|
36
|
+
"**Urgent:** the rack fans are running at 100%",
|
|
37
|
+
"Re-tag the *Halloween* playlist before October",
|
|
38
|
+
"~~Buy a second spool~~ — arrived Tuesday",
|
|
39
|
+
"Run ingest_the_files.sh nightly, not hourly",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
const meta = {
|
|
43
|
+
title: "Components/Content/MarkdownLine",
|
|
44
|
+
component: MarkdownLine,
|
|
45
|
+
parameters: { layout: "padded" },
|
|
46
|
+
args: {
|
|
47
|
+
value: CARD_TITLES[0] ?? "",
|
|
48
|
+
},
|
|
49
|
+
} satisfies Meta<typeof MarkdownLine>
|
|
50
|
+
|
|
51
|
+
export default meta
|
|
52
|
+
|
|
53
|
+
type Story = StoryObj<typeof meta>
|
|
54
|
+
|
|
55
|
+
export const Default: Story = {}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The four marks, each in the shape a title actually uses it for.
|
|
59
|
+
* Code is the one that earns its place most often — an agent-written
|
|
60
|
+
* task name is a third file paths by volume.
|
|
61
|
+
*/
|
|
62
|
+
export const AllMarks: Story = {
|
|
63
|
+
render: () => (
|
|
64
|
+
<StorySection title="Inline marks only. A heading, a list marker and a table pipe are literal characters here.">
|
|
65
|
+
<StoryGrid columns={2}>
|
|
66
|
+
<StoryCell label="code">
|
|
67
|
+
<MarkdownLine value={CARD_TITLES[0] ?? ""} />
|
|
68
|
+
</StoryCell>
|
|
69
|
+
|
|
70
|
+
<StoryCell label="strong">
|
|
71
|
+
<MarkdownLine value={CARD_TITLES[1] ?? ""} />
|
|
72
|
+
</StoryCell>
|
|
73
|
+
|
|
74
|
+
<StoryCell label="emphasis">
|
|
75
|
+
<MarkdownLine value={CARD_TITLES[2] ?? ""} />
|
|
76
|
+
</StoryCell>
|
|
77
|
+
|
|
78
|
+
<StoryCell label="strikethrough">
|
|
79
|
+
<MarkdownLine value={CARD_TITLES[3] ?? ""} />
|
|
80
|
+
</StoryCell>
|
|
81
|
+
</StoryGrid>
|
|
82
|
+
</StorySection>
|
|
83
|
+
),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* WHY THE MARKS ARE WORTH HAVING AT ALL.
|
|
88
|
+
*
|
|
89
|
+
* Five names in one flat weight against the same five with their
|
|
90
|
+
* marks drawn. The argument is not decoration: in the flat column
|
|
91
|
+
* the eye has to read every title to the end to tell two of them
|
|
92
|
+
* apart, and in the marked one the part that *differs* is the part
|
|
93
|
+
* that is drawn differently.
|
|
94
|
+
*/
|
|
95
|
+
export const WhyItHelps: Story = {
|
|
96
|
+
render: () => (
|
|
97
|
+
<StorySection title="The same five task names, as source and as a line.">
|
|
98
|
+
<StoryGrid columns={2}>
|
|
99
|
+
<StoryCell align="stretch" label="raw markdown">
|
|
100
|
+
<ul className="flex flex-col gap-2 text-content-primary text-md">
|
|
101
|
+
{CARD_TITLES.map((title) => (
|
|
102
|
+
<li key={title}>{title}</li>
|
|
103
|
+
))}
|
|
104
|
+
</ul>
|
|
105
|
+
</StoryCell>
|
|
106
|
+
|
|
107
|
+
<StoryCell align="stretch" label="MarkdownLine">
|
|
108
|
+
<ul className="flex flex-col gap-2 text-content-primary text-md">
|
|
109
|
+
{CARD_TITLES.map((title) => (
|
|
110
|
+
<li key={title}>
|
|
111
|
+
<MarkdownLine value={title} />
|
|
112
|
+
</li>
|
|
113
|
+
))}
|
|
114
|
+
</ul>
|
|
115
|
+
</StoryCell>
|
|
116
|
+
</StoryGrid>
|
|
117
|
+
</StorySection>
|
|
118
|
+
),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A file name is the case this component is most often asked to get
|
|
123
|
+
* *wrong*. `ingest_the_files.sh` has two underscores and is not
|
|
124
|
+
* italic, because CommonMark's flanking rules say an underscore
|
|
125
|
+
* inside a word opens nothing — and a Windows path keeps its
|
|
126
|
+
* backslashes.
|
|
127
|
+
*/
|
|
128
|
+
export const FileNames: Story = {
|
|
129
|
+
render: () => (
|
|
130
|
+
<StorySection title="Markup that is not markup. None of these lines has a mark in it.">
|
|
131
|
+
<StoryGrid columns={1}>
|
|
132
|
+
<StoryCell align="stretch" label="left alone">
|
|
133
|
+
<ul className="flex flex-col gap-2 font-mono text-content-primary text-sm">
|
|
134
|
+
<li>
|
|
135
|
+
<MarkdownLine value="Run ingest_the_files.sh nightly" />
|
|
136
|
+
</li>
|
|
137
|
+
|
|
138
|
+
<li>
|
|
139
|
+
<MarkdownLine
|
|
140
|
+
value={String.raw`Copy to G:\Movies\Kids`}
|
|
141
|
+
/>
|
|
142
|
+
</li>
|
|
143
|
+
|
|
144
|
+
<li>
|
|
145
|
+
<MarkdownLine value="2 * 3 * 4 is twenty-four" />
|
|
146
|
+
</li>
|
|
147
|
+
|
|
148
|
+
<li>
|
|
149
|
+
<MarkdownLine value="# 4 in the queue" />
|
|
150
|
+
</li>
|
|
151
|
+
</ul>
|
|
152
|
+
</StoryCell>
|
|
153
|
+
</StoryGrid>
|
|
154
|
+
</StorySection>
|
|
155
|
+
),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* THE `href` CASE, and the one that is easy to get wrong.
|
|
160
|
+
*
|
|
161
|
+
* A card title is usually a link to the card. When the title also
|
|
162
|
+
* *contains* a link, `href` makes the component emit siblings —
|
|
163
|
+
* plain runs anchored to the card, the markdown link anchored to its
|
|
164
|
+
* own destination. Nesting them would be invalid HTML, and the
|
|
165
|
+
* browser would silently drop the second half of the card's link.
|
|
166
|
+
*/
|
|
167
|
+
export const AsALink: Story = {
|
|
168
|
+
render: () => (
|
|
169
|
+
<StorySection title="`href` anchors the plain text. A link inside the line keeps its own destination.">
|
|
170
|
+
<StoryGrid columns={1}>
|
|
171
|
+
<StoryCell
|
|
172
|
+
align="stretch"
|
|
173
|
+
label="the whole line opens the task"
|
|
174
|
+
>
|
|
175
|
+
<MarkdownLine
|
|
176
|
+
href="/tasks/7"
|
|
177
|
+
value="Ingest **53** movies overnight"
|
|
178
|
+
/>
|
|
179
|
+
</StoryCell>
|
|
180
|
+
|
|
181
|
+
<StoryCell
|
|
182
|
+
align="stretch"
|
|
183
|
+
label="two destinations, three sibling anchors"
|
|
184
|
+
>
|
|
185
|
+
<MarkdownLine
|
|
186
|
+
href="/tasks/7"
|
|
187
|
+
value="Ship [#53](https://example.invalid/pulls/53) tonight"
|
|
188
|
+
/>
|
|
189
|
+
</StoryCell>
|
|
190
|
+
|
|
191
|
+
<StoryCell
|
|
192
|
+
align="stretch"
|
|
193
|
+
label="no href — only the markdown link is clickable"
|
|
194
|
+
>
|
|
195
|
+
<MarkdownLine value="Ship [#53](https://example.invalid/pulls/53) tonight" />
|
|
196
|
+
</StoryCell>
|
|
197
|
+
</StoryGrid>
|
|
198
|
+
</StorySection>
|
|
199
|
+
),
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* With a router injected, an in-app destination goes through it and
|
|
204
|
+
* an off-origin one does not — `data-router="soft"` marks the ones
|
|
205
|
+
* the router was handed. Off-origin links open in a new tab, because
|
|
206
|
+
* a title that names a pull request is a reference and following it
|
|
207
|
+
* should not lose the board the reader was on.
|
|
208
|
+
*/
|
|
209
|
+
export const Routed: Story = {
|
|
210
|
+
decorators: [
|
|
211
|
+
(Story) => (
|
|
212
|
+
<RouterLinkProvider link={SoftRouterLink}>
|
|
213
|
+
<Story />
|
|
214
|
+
</RouterLinkProvider>
|
|
215
|
+
),
|
|
216
|
+
],
|
|
217
|
+
render: () => (
|
|
218
|
+
<StorySection title="The injected router gets `/tasks/7`. It never gets the pull request.">
|
|
219
|
+
<StoryGrid columns={1}>
|
|
220
|
+
<StoryCell
|
|
221
|
+
align="stretch"
|
|
222
|
+
label="routed and unrouted, in one line"
|
|
223
|
+
>
|
|
224
|
+
<MarkdownLine
|
|
225
|
+
href="/tasks/7"
|
|
226
|
+
value="Ship [#53](https://example.invalid/pulls/53) tonight"
|
|
227
|
+
/>
|
|
228
|
+
</StoryCell>
|
|
229
|
+
</StoryGrid>
|
|
230
|
+
</StorySection>
|
|
231
|
+
),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A URL whose scheme is not on `safeUrls.ts`'s allowlist keeps
|
|
236
|
+
* **every character of its source** and becomes no link at all. A
|
|
237
|
+
* reader who can see the trap is better served than one shown a
|
|
238
|
+
* confident blue word that silently refuses to work.
|
|
239
|
+
*/
|
|
240
|
+
/**
|
|
241
|
+
* A whole card title as one link, with a code span inside it — the
|
|
242
|
+
* shape Docket's backlog rows and board cards are.
|
|
243
|
+
*
|
|
244
|
+
* It exists so a test can assert the link's **accessible name**,
|
|
245
|
+
* which is where a mark's surrounding spaces go missing without
|
|
246
|
+
* anything on screen changing.
|
|
247
|
+
*/
|
|
248
|
+
export const TitleAsALink: Story = {
|
|
249
|
+
render: () => (
|
|
250
|
+
<MarkdownLine
|
|
251
|
+
href="/tasks/7"
|
|
252
|
+
value="Ingest 53 movies from `Downloads/MOVIES` tonight"
|
|
253
|
+
/>
|
|
254
|
+
),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* INSIDE A WHOLE-CARD LINK.
|
|
259
|
+
*
|
|
260
|
+
* The card is the link — middle-click opens it, like anything else
|
|
261
|
+
* on the web — so the line inside it emits no anchor of its own. The
|
|
262
|
+
* marks still draw; a markdown link's text still reads; nothing
|
|
263
|
+
* inside navigates separately.
|
|
264
|
+
*
|
|
265
|
+
* The bare URL is the case that makes this a prop rather than a
|
|
266
|
+
* convention. It autolinks, so a captured note ending in a URL would
|
|
267
|
+
* nest an anchor without anybody having typed `[]()`.
|
|
268
|
+
*/
|
|
269
|
+
export const InsideACardLink: Story = {
|
|
270
|
+
render: () => (
|
|
271
|
+
<a
|
|
272
|
+
className="block max-w-sm rounded-lg border border-border-subtle bg-surface-raised p-3 hover:bg-surface-sunken"
|
|
273
|
+
href="/triage/7"
|
|
274
|
+
>
|
|
275
|
+
<MarkdownLine
|
|
276
|
+
className="block font-medium text-content-primary"
|
|
277
|
+
isInsideLink
|
|
278
|
+
value="Ingest 53 movies from `Downloads/MOVIES` — https://example.invalid/list"
|
|
279
|
+
/>
|
|
280
|
+
|
|
281
|
+
<span className="mt-1 block text-content-secondary text-sm">
|
|
282
|
+
Filed from the capture inbox
|
|
283
|
+
</span>
|
|
284
|
+
</a>
|
|
285
|
+
),
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export const RefusedUrl: Story = {
|
|
289
|
+
args: { value: "[click me](javascript:alert(1))" },
|
|
290
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the runs become in the DOM — the half `inlineMarkdown.test.ts`
|
|
3
|
+
* deliberately does not assert.
|
|
4
|
+
*
|
|
5
|
+
* The load-bearing test here is the nesting one. Every other
|
|
6
|
+
* assertion in this file would still pass if the component wrapped
|
|
7
|
+
* itself in an anchor, the page would look exactly right, and half
|
|
8
|
+
* of each title would quietly stop opening the task.
|
|
9
|
+
*
|
|
10
|
+
* Subjects are the composed **stories**, per this package's
|
|
11
|
+
* story/test split — so what is asserted is what a reader sees.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { composeStories } from "@storybook/react"
|
|
15
|
+
import { expect, userEvent } from "storybook/test"
|
|
16
|
+
import { test } from "vitest"
|
|
17
|
+
|
|
18
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
19
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
20
|
+
import * as stories from "./MarkdownLine.stories.tsx"
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
AllMarks,
|
|
24
|
+
AsALink,
|
|
25
|
+
Default,
|
|
26
|
+
FileNames,
|
|
27
|
+
InsideACardLink,
|
|
28
|
+
RefusedUrl,
|
|
29
|
+
Routed,
|
|
30
|
+
TitleAsALink,
|
|
31
|
+
} = composeStories(stories)
|
|
32
|
+
|
|
33
|
+
test("a code span is a `code` element, and the backticks are gone", async () => {
|
|
34
|
+
const { canvasElement } = await mountStory(Default)
|
|
35
|
+
|
|
36
|
+
await expect(
|
|
37
|
+
[...canvasElement.querySelectorAll("code")].map(
|
|
38
|
+
(element) => element.textContent,
|
|
39
|
+
),
|
|
40
|
+
).toEqual(["Downloads/MOVIES", String.raw`G:\Movies`])
|
|
41
|
+
|
|
42
|
+
await expect(canvasElement.textContent).toBe(
|
|
43
|
+
String.raw`Ingest 53 movies from Downloads/MOVIES into G:\Movies`,
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("each mark renders its own element", async () => {
|
|
48
|
+
const { canvasElement } = await mountStory(AllMarks)
|
|
49
|
+
|
|
50
|
+
await expect(
|
|
51
|
+
canvasElement.querySelector("strong")?.textContent,
|
|
52
|
+
).toBe("Urgent:")
|
|
53
|
+
|
|
54
|
+
await expect(
|
|
55
|
+
canvasElement.querySelector("em")?.textContent,
|
|
56
|
+
).toBe("Halloween")
|
|
57
|
+
|
|
58
|
+
await expect(
|
|
59
|
+
canvasElement.querySelector("s")?.textContent,
|
|
60
|
+
).toBe("Buy a second spool")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The rule that keeps a file name a file name. Asserted on the DOM
|
|
65
|
+
* rather than only on the runs because this is the one a reader
|
|
66
|
+
* reports — *"why is half my path in italics"* — and the story is
|
|
67
|
+
* what they would be looking at.
|
|
68
|
+
*/
|
|
69
|
+
test("an underscore inside a word marks nothing", async () => {
|
|
70
|
+
const { canvasElement } = await mountStory(FileNames)
|
|
71
|
+
|
|
72
|
+
await expect(canvasElement.querySelector("em")).toBe(null)
|
|
73
|
+
await expect(canvasElement.querySelector("strong")).toBe(
|
|
74
|
+
null,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
await expect(canvasElement.textContent).toContain(
|
|
78
|
+
"ingest_the_files.sh",
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test("a line with no href has nothing to click", async () => {
|
|
83
|
+
const { canvasElement } = await mountStory(Default)
|
|
84
|
+
|
|
85
|
+
await expect(canvasElement.querySelector("a")).toBe(null)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `<a><a/></a>` is not something React refuses and not something the
|
|
90
|
+
* browser reports. The HTML parser closes the outer anchor at the
|
|
91
|
+
* inner one's start tag, so the text *after* the inner link stops
|
|
92
|
+
* being part of the card's link — it looks correct and half of it
|
|
93
|
+
* does not work.
|
|
94
|
+
*/
|
|
95
|
+
test("a link inside the line is a SIBLING anchor, never a nested one", async () => {
|
|
96
|
+
const { canvas, canvasElement } =
|
|
97
|
+
await mountStory(AsALink)
|
|
98
|
+
|
|
99
|
+
await expect(canvasElement.querySelector("a a")).toBe(
|
|
100
|
+
null,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
await expect(
|
|
104
|
+
canvas
|
|
105
|
+
.getAllByRole("link")
|
|
106
|
+
.map((link: HTMLElement) =>
|
|
107
|
+
link.getAttribute("href"),
|
|
108
|
+
),
|
|
109
|
+
).toEqual([
|
|
110
|
+
"/tasks/7",
|
|
111
|
+
"/tasks/7",
|
|
112
|
+
"https://example.invalid/pulls/53",
|
|
113
|
+
"/tasks/7",
|
|
114
|
+
"https://example.invalid/pulls/53",
|
|
115
|
+
])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Each anchor is named by the words it covers, and the names are
|
|
120
|
+
* therefore DISTINCT.
|
|
121
|
+
*
|
|
122
|
+
* Naming all three after the whole line was tried first and is
|
|
123
|
+
* worse: two links with one name are ambiguous to anything driving
|
|
124
|
+
* the page by name, and `expectAgentDrivable` refuses it outright.
|
|
125
|
+
* What is announced now matches what is on screen, which is also
|
|
126
|
+
* WCAG 2.5.3.
|
|
127
|
+
*/
|
|
128
|
+
test("split anchors are named by their own words", async () => {
|
|
129
|
+
const { canvas } = await mountStory(Routed)
|
|
130
|
+
|
|
131
|
+
const link = expectAgentDrivable(canvas, {
|
|
132
|
+
name: "Ship",
|
|
133
|
+
role: "link",
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
await expect(link).toHaveAttribute("href", "/tasks/7")
|
|
137
|
+
|
|
138
|
+
await expect(
|
|
139
|
+
expectAgentDrivable(canvas, {
|
|
140
|
+
name: "tonight",
|
|
141
|
+
role: "link",
|
|
142
|
+
}),
|
|
143
|
+
).toHaveAttribute("href", "/tasks/7")
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test("Tab reaches every anchor in the line", async () => {
|
|
147
|
+
const { canvas } = await mountStory(Routed)
|
|
148
|
+
|
|
149
|
+
const links = canvas.getAllByRole("link")
|
|
150
|
+
|
|
151
|
+
await userEvent.tab()
|
|
152
|
+
|
|
153
|
+
await expect(links[0]).toHaveFocus()
|
|
154
|
+
|
|
155
|
+
await userEvent.tab()
|
|
156
|
+
|
|
157
|
+
await expect(links[1]).toHaveFocus()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The injected router gets the in-app destination and never gets the
|
|
162
|
+
* off-origin one, which would push a route the SPA does not have
|
|
163
|
+
* onto its history stack.
|
|
164
|
+
*/
|
|
165
|
+
test("the router is handed the in-app destination only", async () => {
|
|
166
|
+
const { canvas } = await mountStory(Routed)
|
|
167
|
+
|
|
168
|
+
const [task, pullRequest] = canvas.getAllByRole("link")
|
|
169
|
+
|
|
170
|
+
await expect(task).toHaveAttribute("data-router", "soft")
|
|
171
|
+
await expect(pullRequest).not.toHaveAttribute(
|
|
172
|
+
"data-router",
|
|
173
|
+
)
|
|
174
|
+
await expect(pullRequest).toHaveAttribute(
|
|
175
|
+
"target",
|
|
176
|
+
"_blank",
|
|
177
|
+
)
|
|
178
|
+
await expect(pullRequest).toHaveAttribute(
|
|
179
|
+
"rel",
|
|
180
|
+
"noopener noreferrer",
|
|
181
|
+
)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The guard itself lives in `safeUrls.ts` and is tested there. What
|
|
186
|
+
* matters here is the *response* to a refusal: no anchor at all,
|
|
187
|
+
* rather than a dead one.
|
|
188
|
+
*/
|
|
189
|
+
test("a refused scheme renders its source as text", async () => {
|
|
190
|
+
const { canvasElement } = await mountStory(RefusedUrl)
|
|
191
|
+
|
|
192
|
+
await expect(canvasElement.querySelector("a")).toBe(null)
|
|
193
|
+
|
|
194
|
+
await expect(canvasElement.textContent).toBe(
|
|
195
|
+
"[click me](javascript:alert(1))",
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* THE SPACES AROUND A MARK SURVIVE INTO THE ACCESSIBLE NAME.
|
|
201
|
+
*
|
|
202
|
+
* The name computation trims each **element** child's contribution
|
|
203
|
+
* before joining them, so a line whose every run was wrapped in a
|
|
204
|
+
* `<span>` announced as `Ingest 53 movies fromDownloads/MOVIESinto`
|
|
205
|
+
* — painted correctly, announced as one run-on word, and invisible
|
|
206
|
+
* to any assertion that reads `textContent`.
|
|
207
|
+
*
|
|
208
|
+
* Found through `Board`'s move handle, which is named after the
|
|
209
|
+
* card's title. The fix is that an unmarked run is a text node
|
|
210
|
+
* rather than a wrapped one; this is the guard that keeps it.
|
|
211
|
+
*/
|
|
212
|
+
test("the accessible name keeps the spaces around a mark", async () => {
|
|
213
|
+
const { canvas } = await mountStory(TitleAsALink)
|
|
214
|
+
|
|
215
|
+
await expect(
|
|
216
|
+
canvas.getByRole("link", {
|
|
217
|
+
name: "Ingest 53 movies from Downloads/MOVIES tonight",
|
|
218
|
+
}),
|
|
219
|
+
).toBeInTheDocument()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* `isInsideLink` emits no anchor of its own — not even for a bare
|
|
224
|
+
* URL, which is the case that makes it a prop rather than a
|
|
225
|
+
* convention. A captured note ending in a URL would otherwise nest
|
|
226
|
+
* an anchor inside the card's link with nobody having typed `[]()`.
|
|
227
|
+
*/
|
|
228
|
+
test("isInsideLink draws the marks and adds no anchor", async () => {
|
|
229
|
+
const { canvas, canvasElement } =
|
|
230
|
+
await mountStory(InsideACardLink)
|
|
231
|
+
|
|
232
|
+
await expect(canvasElement.querySelector("a a")).toBe(
|
|
233
|
+
null,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
await expect(canvas.getAllByRole("link")).toHaveLength(1)
|
|
237
|
+
|
|
238
|
+
await expect(
|
|
239
|
+
canvasElement.querySelector("code")?.textContent,
|
|
240
|
+
).toBe("Downloads/MOVIES")
|
|
241
|
+
})
|