@charcuterie/ui 3.19.1 → 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.
Files changed (53) hide show
  1. package/dist/Board/BoardCard.d.ts +54 -5
  2. package/dist/Board/BoardCard.d.ts.map +1 -1
  3. package/dist/Board/BoardCard.js +2 -2
  4. package/dist/Board/BoardCard.js.map +1 -1
  5. package/dist/Combobox/Combobox.d.ts.map +1 -1
  6. package/dist/Combobox/Combobox.js +50 -1
  7. package/dist/Combobox/Combobox.js.map +1 -1
  8. package/dist/MarkdownEditor/markdownSpans.d.ts +17 -4
  9. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -1
  10. package/dist/MarkdownEditor/markdownSpans.js.map +1 -1
  11. package/dist/MarkdownLine/MarkdownLine.d.ts +104 -0
  12. package/dist/MarkdownLine/MarkdownLine.d.ts.map +1 -0
  13. package/dist/MarkdownLine/MarkdownLine.js +187 -0
  14. package/dist/MarkdownLine/MarkdownLine.js.map +1 -0
  15. package/dist/MarkdownLine/inlineMarkdown.d.ts +82 -0
  16. package/dist/MarkdownLine/inlineMarkdown.d.ts.map +1 -0
  17. package/dist/MarkdownLine/inlineMarkdown.js +509 -0
  18. package/dist/MarkdownLine/inlineMarkdown.js.map +1 -0
  19. package/dist/index.d.ts +5 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/markdownEditorCodeMirror/index.d.ts +1 -1
  24. package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -1
  25. package/dist/markdownEditorCodeMirror/index.js +1 -1
  26. package/dist/markdownEditorCodeMirror/index.js.map +1 -1
  27. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +1 -1
  28. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
  29. package/dist/safeUrls.d.ts.map +1 -0
  30. package/dist/safeUrls.js.map +1 -0
  31. package/package.json +1 -1
  32. package/src/Board/Board.stories.tsx +67 -0
  33. package/src/Board/Board.test.tsx +35 -0
  34. package/src/Board/BoardCard.tsx +65 -10
  35. package/src/Combobox/Combobox.test.tsx +63 -0
  36. package/src/Combobox/Combobox.tsx +64 -1
  37. package/src/MarkdownEditor/markdownSpans.ts +18 -5
  38. package/src/MarkdownLine/MarkdownLine.mdx +135 -0
  39. package/src/MarkdownLine/MarkdownLine.stories.tsx +290 -0
  40. package/src/MarkdownLine/MarkdownLine.test.tsx +241 -0
  41. package/src/MarkdownLine/MarkdownLine.tsx +331 -0
  42. package/src/MarkdownLine/inlineMarkdown.test.ts +253 -0
  43. package/src/MarkdownLine/inlineMarkdown.ts +768 -0
  44. package/src/index.ts +9 -2
  45. package/src/markdownEditorCodeMirror/index.ts +4 -4
  46. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +1 -1
  47. package/src/sourceRules.test.ts +17 -1
  48. package/dist/markdownEditorCodeMirror/safeUrls.d.ts.map +0 -1
  49. package/dist/markdownEditorCodeMirror/safeUrls.js.map +0 -1
  50. /package/dist/{markdownEditorCodeMirror/safeUrls.d.ts → safeUrls.d.ts} +0 -0
  51. /package/dist/{markdownEditorCodeMirror/safeUrls.js → safeUrls.js} +0 -0
  52. /package/src/{markdownEditorCodeMirror/safeUrls.test.ts → safeUrls.test.ts} +0 -0
  53. /package/src/{markdownEditorCodeMirror/safeUrls.ts → safeUrls.ts} +0 -0
@@ -0,0 +1,135 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as MarkdownLineStories from "./MarkdownLine.stories";
4
+
5
+ <Meta of={MarkdownLineStories} />
6
+
7
+ # MarkdownLine
8
+
9
+ **One line of markdown, drawn with its inline marks and nothing else.** Reach for it
10
+ wherever a stored string is a *name* rather than a document — a card title, a lane heading,
11
+ a table cell, a breadcrumb.
12
+
13
+ <Canvas of={MarkdownLineStories.Default} />
14
+
15
+ ## Props
16
+
17
+ <Controls />
18
+
19
+ ## Why marks belong in a title
20
+
21
+ A grid of task names set in one flat weight is a wall. The parts that tell two cards apart —
22
+ the path, the urgency, the thing that is already done — are exactly the parts markdown would
23
+ have marked, so the marks are doing the same job the priority bar and the project colour do
24
+ one layer up: making a list **scannable**.
25
+
26
+ <Canvas of={MarkdownLineStories.WhyItHelps} />
27
+
28
+ ## It is inline-only, and it is one line
29
+
30
+ | Rendered | Literal text |
31
+ | --- | --- |
32
+ | `**bold**`, `__bold__` | `# heading` |
33
+ | `*italic*`, `_italic_` | `- list item`, `1. list item` |
34
+ | `` `code` `` | `> blockquote` |
35
+ | `~~strikethrough~~` | `\| table \| row \|` |
36
+ | `[text](url)`, `<url>`, bare `https://…` and `www.…` | `![image](url)` |
37
+
38
+ A newline collapses to a space. Every surface that draws one of these clamps to one or two
39
+ lines anyway, so a hard break would only push the second half of a title out of a box that
40
+ was measured for one line.
41
+
42
+ ## File names are not markup
43
+
44
+ The case this component is most often asked to get wrong.
45
+
46
+ <Canvas of={MarkdownLineStories.FileNames} />
47
+
48
+ `ingest_the_files.sh` keeps its underscores because CommonMark's flanking rules say an
49
+ underscore **inside a word** opens nothing — the rule that makes `_` and `*` genuinely
50
+ different characters, and the one a hand-rolled "strip the markdown" regex always misses.
51
+ `2 * 3 * 4` is arithmetic for the same reason: a delimiter followed by a space cannot open.
52
+
53
+ ## `href` — a link inside a link
54
+
55
+ <Canvas of={MarkdownLineStories.AsALink} />
56
+
57
+ A card title is usually a link to the card, and a title may also *contain* a link. An anchor
58
+ inside an anchor is invalid HTML: the parser closes the outer one at the inner one's start
59
+ tag, so the text after the inner link silently stops opening the card. It looks completely
60
+ correct and half of it does not work.
61
+
62
+ So `href` is a prop rather than something you wrap the component in. Given one, the plain
63
+ runs become anchors to `href` and a markdown link stays a **sibling** anchor with its own
64
+ destination:
65
+
66
+ ```tsx
67
+ <MarkdownLine href={`/tasks/${task.id}`} value={task.title} />
68
+ ```
69
+
70
+ Consecutive runs sharing a destination are merged into one anchor, so `**Ship** it` is a
71
+ single tab stop and one entry in a screen reader's link list.
72
+
73
+ A markdown link **splits** the line into more than one anchor, and each is named by the words
74
+ it covers — `Ship`, `#53`, `tonight`. That is honest, and it is not good: `tonight` is a poor
75
+ name for a link to a task. Naming all three after the whole line was tried and is worse — two
76
+ links with the same name are ambiguous to anything driving the page by name, and this
77
+ library's own `expectAgentDrivable` refuses it. The conclusion is a design one rather than an
78
+ API one: **a link usually belongs in a task's description**, where it has a sentence around
79
+ it, rather than in its title.
80
+
81
+ <Canvas of={MarkdownLineStories.Routed} />
82
+
83
+ An in-app destination goes through the injected `RouterLinkProvider`; an off-origin one
84
+ does not, and opens in a new tab. A title that names a pull request is a reference, and
85
+ following it should not lose the board the reader was on.
86
+
87
+ ## `isInsideLink` — when the card is already the link
88
+
89
+ <Canvas of={MarkdownLineStories.InsideACardLink} />
90
+
91
+ The third and last answer to *who owns the link here*. `href` says this component does;
92
+ neither says the markdown links do; `isInsideLink` says **nobody** — draw the marks and emit
93
+ no anchor at all.
94
+
95
+ Reach for it whenever the line sits inside a link the caller already owns, which is what a
96
+ whole-card link is. The trap it closes has teeth even when no author ever typed a link: a
97
+ **bare URL autolinks**, so a captured note whose title ends in `https://…` would nest an
98
+ anchor without anybody writing `[]()`.
99
+
100
+ ## Anywhere a string is required
101
+
102
+ `aria-label`, `title`, `document.title`, a confirmation sentence — call `toPlainMarkdownText`
103
+ rather than writing a regex that strips the markup:
104
+
105
+ ```ts
106
+ import { toPlainMarkdownText } from "@charcuterie/ui"
107
+
108
+ document.title = `${toPlainMarkdownText(task.title)} — Docket`
109
+ ```
110
+
111
+ It is the **same parse** the component renders from, so it cannot drift. A second stripper is
112
+ how a card comes to read `Ingest 53 movies` while its own tooltip reads
113
+ ``Ingest 53 movies from `Downloads/MOVIES` ``.
114
+
115
+ ## A refused URL keeps its source
116
+
117
+ <Canvas of={MarkdownLineStories.RefusedUrl} />
118
+
119
+ URLs cross `safeUrls.ts` — an allowlist of `http`, `https`, `mailto` and `tel` — before they
120
+ reach an `href`, the same module `MarkdownView` and the CodeMirror editor use. A scheme that
121
+ does not survive it produces **no anchor at all**, and every character of the source stays on
122
+ screen. A reader who can see `[click me](javascript:…)` is better served than one shown a
123
+ confident blue word that silently refuses to work.
124
+
125
+ ## Not a small `MarkdownView`
126
+
127
+ `MarkdownView` renders a *document*, through CodeMirror, from the optional
128
+ `@charcuterie/ui/markdown-editor-codemirror` entry point. Neither half suits a name: an
129
+ editor state, a syntax tree and a decoration set per card is an absurd price for forty
130
+ characters, and the main entry may not reach the CodeMirror stack at all.
131
+
132
+ So this is a second parser, which is worth being nervous about — two parsers are two answers
133
+ to "what does this markdown mean". Three things keep them honest: this one is **inline-only**,
134
+ so there is no block level to disagree about; both call the **same** URL guard; and the
135
+ flanking rules here are CommonMark's rather than a convenient approximation.
@@ -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
+ })