@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.
Files changed (48) 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/MarkdownEditor/markdownSpans.d.ts +17 -4
  6. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -1
  7. package/dist/MarkdownEditor/markdownSpans.js.map +1 -1
  8. package/dist/MarkdownLine/MarkdownLine.d.ts +104 -0
  9. package/dist/MarkdownLine/MarkdownLine.d.ts.map +1 -0
  10. package/dist/MarkdownLine/MarkdownLine.js +187 -0
  11. package/dist/MarkdownLine/MarkdownLine.js.map +1 -0
  12. package/dist/MarkdownLine/inlineMarkdown.d.ts +82 -0
  13. package/dist/MarkdownLine/inlineMarkdown.d.ts.map +1 -0
  14. package/dist/MarkdownLine/inlineMarkdown.js +509 -0
  15. package/dist/MarkdownLine/inlineMarkdown.js.map +1 -0
  16. package/dist/index.d.ts +5 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/markdownEditorCodeMirror/index.d.ts +1 -1
  21. package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -1
  22. package/dist/markdownEditorCodeMirror/index.js +1 -1
  23. package/dist/markdownEditorCodeMirror/index.js.map +1 -1
  24. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +1 -1
  25. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
  26. package/dist/safeUrls.d.ts.map +1 -0
  27. package/dist/safeUrls.js.map +1 -0
  28. package/package.json +1 -1
  29. package/src/Board/Board.stories.tsx +67 -0
  30. package/src/Board/Board.test.tsx +35 -0
  31. package/src/Board/BoardCard.tsx +65 -10
  32. package/src/MarkdownEditor/markdownSpans.ts +18 -5
  33. package/src/MarkdownLine/MarkdownLine.mdx +135 -0
  34. package/src/MarkdownLine/MarkdownLine.stories.tsx +290 -0
  35. package/src/MarkdownLine/MarkdownLine.test.tsx +241 -0
  36. package/src/MarkdownLine/MarkdownLine.tsx +331 -0
  37. package/src/MarkdownLine/inlineMarkdown.test.ts +253 -0
  38. package/src/MarkdownLine/inlineMarkdown.ts +768 -0
  39. package/src/index.ts +9 -2
  40. package/src/markdownEditorCodeMirror/index.ts +4 -4
  41. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +1 -1
  42. package/src/sourceRules.test.ts +17 -1
  43. package/dist/markdownEditorCodeMirror/safeUrls.d.ts.map +0 -1
  44. package/dist/markdownEditorCodeMirror/safeUrls.js.map +0 -1
  45. /package/dist/{markdownEditorCodeMirror/safeUrls.d.ts → safeUrls.d.ts} +0 -0
  46. /package/dist/{markdownEditorCodeMirror/safeUrls.js → safeUrls.js} +0 -0
  47. /package/src/{markdownEditorCodeMirror/safeUrls.test.ts → safeUrls.test.ts} +0 -0
  48. /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
+ })