@charcuterie/ui 3.12.0 → 3.14.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/Avatar/Avatar.d.ts +163 -0
  2. package/dist/Avatar/Avatar.d.ts.map +1 -0
  3. package/dist/Avatar/Avatar.js +139 -0
  4. package/dist/Avatar/Avatar.js.map +1 -0
  5. package/dist/Board/BoardLaneList.d.ts +29 -0
  6. package/dist/Board/BoardLaneList.d.ts.map +1 -1
  7. package/dist/Board/BoardLaneList.js +2 -1
  8. package/dist/Board/BoardLaneList.js.map +1 -1
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/markdownEditorCodeMirror/MarkdownView.d.ts +140 -0
  14. package/dist/markdownEditorCodeMirror/MarkdownView.d.ts.map +1 -0
  15. package/dist/markdownEditorCodeMirror/MarkdownView.js +268 -0
  16. package/dist/markdownEditorCodeMirror/MarkdownView.js.map +1 -0
  17. package/dist/markdownEditorCodeMirror/index.d.ts +14 -3
  18. package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -1
  19. package/dist/markdownEditorCodeMirror/index.js +12 -3
  20. package/dist/markdownEditorCodeMirror/index.js.map +1 -1
  21. package/dist/markdownEditorCodeMirror/livePreview.d.ts +42 -2
  22. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
  23. package/dist/markdownEditorCodeMirror/livePreview.js +243 -26
  24. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
  25. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
  26. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +49 -7
  27. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
  28. package/dist/markdownEditorCodeMirror/safeUrls.d.ts +46 -0
  29. package/dist/markdownEditorCodeMirror/safeUrls.d.ts.map +1 -0
  30. package/dist/markdownEditorCodeMirror/safeUrls.js +123 -0
  31. package/dist/markdownEditorCodeMirror/safeUrls.js.map +1 -0
  32. package/package.json +1 -1
  33. package/src/Avatar/Avatar.mdx +92 -0
  34. package/src/Avatar/Avatar.stories.tsx +254 -0
  35. package/src/Avatar/Avatar.test.tsx +143 -0
  36. package/src/Avatar/Avatar.tsx +270 -0
  37. package/src/Board/Board.mdx +26 -0
  38. package/src/Board/Board.stories.tsx +47 -21
  39. package/src/Board/Board.test.tsx +62 -0
  40. package/src/Board/BoardLaneList.tsx +52 -1
  41. package/src/MarkdownEditor/MarkdownEditor.mdx +5 -0
  42. package/src/index.ts +5 -0
  43. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +11 -0
  44. package/src/markdownEditorCodeMirror/MarkdownView.mdx +157 -0
  45. package/src/markdownEditorCodeMirror/MarkdownView.stories.tsx +314 -0
  46. package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +288 -0
  47. package/src/markdownEditorCodeMirror/MarkdownView.tsx +373 -0
  48. package/src/markdownEditorCodeMirror/index.ts +17 -2
  49. package/src/markdownEditorCodeMirror/livePreview.ts +329 -33
  50. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +64 -7
  51. package/src/markdownEditorCodeMirror/safeUrls.test.ts +0 -0
  52. package/src/markdownEditorCodeMirror/safeUrls.ts +142 -0
  53. package/src/sourceRules.test.ts +9 -1
@@ -15,6 +15,11 @@ stored string.
15
15
 
16
16
  <Canvas of={MarkdownEditorStories.Default} />
17
17
 
18
+ > **Reading rather than editing?** `isReadOnly` here leaves the toolbar on screen, because it
19
+ > means "an editor you cannot type in". A surface with no toolbar in the DOM is
20
+ > [`MarkdownView`](?path=/docs/components-data-markdownview--docs), in the
21
+ > `@charcuterie/ui/markdown-editor-codemirror` subpath.
22
+
18
23
  ## Props
19
24
 
20
25
  <Controls />
package/src/index.ts CHANGED
@@ -63,6 +63,11 @@ export type {
63
63
  AlertSize,
64
64
  } from "./Alert/Alert.tsx"
65
65
  export { Alert } from "./Alert/Alert.tsx"
66
+ export type {
67
+ AvatarProps,
68
+ AvatarSize,
69
+ } from "./Avatar/Avatar.tsx"
70
+ export { Avatar } from "./Avatar/Avatar.tsx"
66
71
  export type { BadgeProps } from "./Badge/Badge.tsx"
67
72
  export { Badge } from "./Badge/Badge.tsx"
68
73
  export type { BadgeShapeProps } from "./Badge/useBadgeShape.tsx"
@@ -22,6 +22,17 @@ import { MarkdownEditorCodeMirror } from "@charcuterie/ui/markdown-editor-codemi
22
22
 
23
23
  <Controls />
24
24
 
25
+ ## Reading, not editing
26
+
27
+ `isReadOnly` here means *an editor you cannot type in*: the toolbar stays, drawn in its
28
+ disabled colours above a document nobody is editing. That is right for a form field that is
29
+ temporarily locked, and wrong for reading a file.
30
+
31
+ For reading, take **[`MarkdownView`](?path=/docs/components-data-markdownview--docs)** from
32
+ this same subpath. It has no toolbar in the DOM at all, it is controlled rather than
33
+ uncontrolled, its content is a `role="article"` instead of a `role="textbox"`, and it renders
34
+ **identically** to this component because it is the same code — same range rules, same theme.
35
+
25
36
  ## Which of the two editors you want
26
37
 
27
38
  `MarkdownEditor` is still the default, and most consumers should keep it.
@@ -0,0 +1,157 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as MarkdownViewStories from "./MarkdownView.stories";
4
+
5
+ <Meta of={MarkdownViewStories} />
6
+
7
+ # MarkdownView
8
+
9
+ Rendered markdown, with **no toolbar in the DOM** and nothing to type into. The same
10
+ document, drawn the same way, as the editor beside it.
11
+
12
+ It is **opt-in**, from the same subpath as the editor:
13
+
14
+ ```tsx
15
+ import { MarkdownView } from "@charcuterie/ui/markdown-editor-codemirror"
16
+ ```
17
+
18
+ <Canvas of={MarkdownViewStories.Default} />
19
+
20
+ ## Props
21
+
22
+ <Controls />
23
+
24
+ ## Which of the three you want
25
+
26
+ | | `MarkdownEditor` | `MarkdownEditorCodeMirror` | `MarkdownView` |
27
+ | --- | --- | --- | --- |
28
+ | For | typing markdown | typing markdown, rendered | **reading** markdown |
29
+ | Surface | `<textarea>` + painted layer | CodeMirror 6 | CodeMirror 6 |
30
+ | New dependency | none | ~176 KB gz, optional peer | the same one |
31
+ | Toolbar | nine buttons | nine buttons + a switch | **none, ever** |
32
+ | Value | `defaultValue` | `defaultValue` | **`value`, controlled** |
33
+ | Content role | `textbox` | `textbox` | **`article`** |
34
+
35
+ `isReadOnly` on either editor is **not** this. It means *an editor you cannot type in*: the
36
+ nine formatting buttons are still there, still in the accessibility tree, drawn in their
37
+ disabled colours above a document nobody is editing. That is right for a form field that is
38
+ temporarily locked, and wrong for reading a file — which is what this exists for.
39
+
40
+ ## No toolbar means no toolbar
41
+
42
+ `queryAllByRole("toolbar")` returns nothing here, and so does `queryAllByRole("button")`.
43
+ That is asserted rather than described, because the workaround it replaces was a CSS rule
44
+ in a consuming app keyed on `role="toolbar"`, and CSS is a poor place to keep a promise:
45
+ `visibility: hidden` leaves the buttons in the accessibility tree, `opacity: 0` leaves them
46
+ in the tab order, and even `display: none` leaves the *overflow menu button* the toolbar
47
+ collapses into at narrow widths.
48
+
49
+ ## It renders what the editor renders
50
+
51
+ <Canvas of={MarkdownViewStories.SameAsTheEditor} />
52
+
53
+ One string, two surfaces, side by side. They agree because they **are the same code** —
54
+ `livePreviewRanges.ts` decides what every construct looks like and both call it, over the
55
+ same GFM parse, under the same theme.
56
+
57
+ That is the whole reason this lives in the CodeMirror subpath instead of being a
58
+ `react-markdown` reader in the main barrel. A second renderer agrees on the day it is
59
+ written and drifts afterwards, one construct at a time, and the bug arrives six months later
60
+ as *"the table changed when I saved it"*. There is no second renderer here to drift.
61
+
62
+ The cost is honest and worth stating: a consumer who wants **only** the reader still installs
63
+ CodeMirror. For an app that renders markdown in three places and edits it in one, that is the
64
+ right way round — the dependency is paid once.
65
+
66
+ ## It is controlled, and the editors are not
67
+
68
+ `value` in, and that is it. Fetch a file at a different commit hash, pass the new string, and
69
+ the page changes — no `key`, no remount, no stale document sitting above a new title.
70
+
71
+ The editors take `defaultValue` because reassigning an `EditorView`'s document from outside
72
+ throws away the undo history that belonged to it, which is a real cost and is why they ask
73
+ for a `key` instead. A view has no undo history and no caret to protect, so it has nothing to
74
+ lose by simply rendering what it is given.
75
+
76
+ ## A `- [ ]` box is inert unless you say where the tick goes
77
+
78
+ <Canvas of={MarkdownViewStories.AllStates} />
79
+
80
+ Pass `onToggleTask` and the checkboxes are operable; leave it out and they render
81
+ **disabled**. The state is still on screen either way — that is a fact about the document
82
+ and belongs there — but nothing pretends the reader can change it.
83
+
84
+ This is the same shape as `onUploadImage` on the editors: omit the handler and the capability
85
+ is simply not offered, which beats offering one that half works. Two of the three surfaces
86
+ this component was built for — a rendered comment, and a repo file fetched at a commit hash —
87
+ have nowhere to put a tick, and a box that visibly toggles and then loses the change on
88
+ reload is worse than one that says up front it cannot.
89
+
90
+ `onToggleTask` hands back the **whole next markdown document**, exactly like the editors'
91
+ `onChange`, so one autosave wires to both. The view applies the tick to its own document
92
+ immediately so the box responds under the pointer; if the consumer rejects the change, the
93
+ next `value` puts it back.
94
+
95
+ ## Hostile markdown
96
+
97
+ This will render files fetched from a git host, so the document is untrusted by default.
98
+
99
+ <Canvas of={MarkdownViewStories.HostileMarkdown} />
100
+
101
+ **Raw HTML in the source is never parsed as HTML.** There is no `innerHTML` in this subpath,
102
+ no `dangerouslySetInnerHTML`, and no sanitiser to have a bug in. CodeMirror's state holds the
103
+ source as *text* and every rendered node is `createElement` plus `textContent`, so a
104
+ `<script>` in a fetched README is drawn as the characters `<script>`. It is not stripped,
105
+ not escaped and not "sanitised" — there is simply no code path that would turn it into an
106
+ element.
107
+
108
+ **A URL is checked before it is used as one.** That was the one place the document reached the
109
+ platform, and it was live in the editor too: `[click](javascript:…)` concealed its markup,
110
+ painted as a link, and `window.open` ran it in this origin. Both components now go through an
111
+ allowlist that normalises the way a browser does, so `JaVaScript:` and a tab in the middle of
112
+ the word are the same URL to it. A rejected link is **left as source** — brackets, parentheses
113
+ and all — rather than repainted as a link-coloured word that silently refuses to work.
114
+ Images take `http:`, `https:` and `data:image/*` only; `data:text/html` is not an image.
115
+
116
+ **The reader is not a beacon.** Remote images carry `referrerpolicy="no-referrer"`, so an
117
+ image in somebody else's file cannot learn which page loaded it.
118
+
119
+ What is *not* claimed: nothing here stops a document linking somewhere unpleasant, and the
120
+ images the markdown names are still fetched — that is what rendering them means.
121
+
122
+ ## Accessibility
123
+
124
+ The content is `role="article"`, not the `role="textbox"` CodeMirror writes by default, and
125
+ that swap is the difference between reading a document and being trapped in a text field: a
126
+ screen reader entering a textbox switches to forms mode and reads the contents as one flat
127
+ string, with no heading navigation and no table navigation.
128
+
129
+ So the structure has to be real, and two pieces of it are:
130
+
131
+ - **Headings carry `role="heading"` and an `aria-level`**, so the H key steps between
132
+ sections. The editor deliberately has none of this — ARIA flattens a textbox's contents, so
133
+ a heading role inside one would be a decoration that lies about what it does.
134
+ - **Links are real `<a href>` elements**, in the tab order, activated by Enter, with an
135
+ address the browser will show and copy. The editor cannot have these: an anchor inside a
136
+ `contenteditable` competes with the caret for its line, which is why the editor paints a
137
+ span and translates a `mousedown` instead — and why clicking link text *there* cannot place
138
+ a caret.
139
+
140
+ <Canvas of={MarkdownViewStories.Interactive} />
141
+
142
+ **The document itself is not a tab stop.** There is nothing in it to operate, and a reading
143
+ surface that swallowed a Tab would be one more thing between the reader and the next control.
144
+ Every link inside it is reachable, in document order.
145
+
146
+ List structure is **visual only**, and worth knowing rather than discovering: the `-` bullet
147
+ is drawn as a dimmed marker rather than concealed, the same way it is in the editor, so a
148
+ list reads as a list on screen but is not a `<ul>` in the tree. Tables are real `<table>`
149
+ elements with real `<th>`s, because the table widget builds one.
150
+
151
+ ## The Narrow View
152
+
153
+ <Canvas of={MarkdownViewStories.Responsive} />
154
+
155
+ A table too wide for its column scrolls **inside its own block** rather than pushing the
156
+ column sideways, and an image is capped at the width it is given. Nothing here is keyed to a
157
+ viewport width; a column in a three-up board is narrow on a 4K display.
@@ -0,0 +1,314 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import { useState } from "react"
3
+
4
+ import {
5
+ ContainerBoard,
6
+ StoryCell,
7
+ StoryGrid,
8
+ } from "../board.storyHelpers.tsx"
9
+ import { Card } from "../Card/Card.tsx"
10
+ import { MarkdownEditorCodeMirror } from "./MarkdownEditorCodeMirror.tsx"
11
+ import { MarkdownView } from "./MarkdownView.tsx"
12
+
13
+ /**
14
+ * Invented, like every fixture in this repo — a task in a made-up
15
+ * homelab project, and deliberately the **same string** the editor's
16
+ * stories use.
17
+ *
18
+ * That is what makes the `SameAsTheEditor` story an argument rather
19
+ * than an illustration: the two surfaces are handed one document and
20
+ * a reader compares the frames.
21
+ */
22
+ const SAMPLE = `# Rack move, phase two
23
+
24
+ The **short** version: pull the _old_ switch, land the new one, and
25
+ keep the ~~three~~ two uplinks live throughout.
26
+
27
+ ## Before the window
28
+
29
+ - [x] Label every patch lead
30
+ - [ ] Photograph the current cabling
31
+ - [ ] Cut over at 02:00
32
+
33
+ 1. Power down the shelf
34
+ 2. Swap the rails
35
+ 3. Bring it back up
36
+
37
+ > Nothing here touches the storage pool.
38
+
39
+ Run \`ping -c 3 gateway.invalid\` before and after, and read
40
+ [the runbook](https://example.invalid/runbook) first.
41
+
42
+ | Port | Goes to |
43
+ | --- | --- |
44
+ | 1 | uplink |
45
+ | 2 | shelf |
46
+
47
+ \`\`\`sh
48
+ ip -br addr show
49
+ \`\`\`
50
+
51
+ Replacement rails ordered from https://example.invalid/product?id=1234
52
+ `
53
+
54
+ /**
55
+ * A drawing rather than a fetched file, so the story needs no
56
+ * network and the frame is the same on every machine. The same
57
+ * technique `Avatar`'s portrait uses, and one of the few things
58
+ * `data:` is genuinely good for.
59
+ */
60
+ const DIAGRAM_URL = `data:image/svg+xml;utf8,${encodeURIComponent(
61
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 60"><rect width="240" height="60" rx="6" fill="#2F6F4E"/><rect x="12" y="14" width="96" height="32" rx="4" fill="#F2E8CF"/><rect x="132" y="14" width="96" height="32" rx="4" fill="#F2E8CF"/><path d="M108 30h24" stroke="#F2E8CF" stroke-width="4"/></svg>',
62
+ )}`
63
+
64
+ const WITH_IMAGE = `## Rack inventory
65
+
66
+ ![Two shelves and the uplink between them](${DIAGRAM_URL})
67
+
68
+ | Port | Goes to | **Speed** |
69
+ | :--- | :---: | ---: |
70
+ | 1 | uplink | 10G |
71
+ | 2 | shelf | 1G |
72
+ `
73
+
74
+ /**
75
+ * The document nobody in this house wrote: what a fetched file looks
76
+ * like when its author would rather it did something else.
77
+ *
78
+ * Every line here is a thing that used to work. The `javascript:`
79
+ * link opened and ran; the `<script>` was never a script but is
80
+ * worth showing anyway; the `data:text/html` image was an `src` this
81
+ * component wrote verbatim.
82
+ */
83
+ const HOSTILE = `# A file fetched from somewhere else
84
+
85
+ [Looks like a link](javascript:alert(document.domain)) — and is not one.
86
+
87
+ <script>alert("this is four coloured characters, not a script")</script>
88
+
89
+ <img src=x onerror="alert(1)">
90
+
91
+ ![Not an image](data:text/html,<script>alert(1)</script>)
92
+
93
+ [This one is fine](https://example.invalid/runbook), and so is ./RELEASING.md.
94
+ `
95
+
96
+ const CHECKLIST = `## Before the window
97
+
98
+ - [x] Label every patch lead
99
+ - [ ] Photograph the current cabling
100
+ - [ ] Cut over at 02:00
101
+ `
102
+
103
+ /**
104
+ * A second list rather than the same one twice, and the difference
105
+ * is load bearing: two checkboxes with the same accessible name on
106
+ * one page are two things `getByRole("checkbox", { name })` cannot
107
+ * tell apart — which is the drivability property this library gates
108
+ * on, failing in a *story* rather than in a component.
109
+ */
110
+ const TICKABLE_CHECKLIST = `## After the window
111
+
112
+ - [x] Bring the shelf back up
113
+ - [ ] Re-check both uplinks
114
+ - [ ] Close the change record
115
+ `
116
+
117
+ /**
118
+ * The state the checkbox story cannot show without one: a tick that
119
+ * goes somewhere.
120
+ *
121
+ * `onToggleTask` hands back the whole next markdown document, which
122
+ * is the same shape the editors' `onChange` uses — so a consumer
123
+ * wires one autosave and points both at it.
124
+ */
125
+ const TickableChecklist = () => {
126
+ const [value, setValue] = useState(TICKABLE_CHECKLIST)
127
+
128
+ return (
129
+ <MarkdownView
130
+ label="Tickable checklist"
131
+ onToggleTask={setValue}
132
+ value={value}
133
+ />
134
+ )
135
+ }
136
+
137
+ const meta = {
138
+ title: "Components/Data/MarkdownView",
139
+ component: MarkdownView,
140
+ parameters: { layout: "padded" },
141
+ args: {
142
+ label: "Description",
143
+ onToggleTask: undefined,
144
+ value: SAMPLE,
145
+ },
146
+ } satisfies Meta<typeof MarkdownView>
147
+
148
+ export default meta
149
+
150
+ type Story = StoryObj<typeof meta>
151
+
152
+ /**
153
+ * The whole point in one frame: the document is drawn, and there is
154
+ * **no toolbar above it**. Not a disabled one, not a hidden one —
155
+ * `getAllByRole("toolbar")` returns nothing, because nothing in this
156
+ * component can render one.
157
+ */
158
+ export const Default: Story = {}
159
+
160
+ /**
161
+ * The same string, in both surfaces, side by side.
162
+ *
163
+ * This is the requirement the component exists to meet, and it is
164
+ * the one a screenshot can actually check: the heading is the same
165
+ * size, the table has the same borders and the same alignment, the
166
+ * checkboxes are in the same places, and the bare URL is a link in
167
+ * both. They agree because they are the same code — one parser, one
168
+ * set of range rules, one theme — rather than because somebody kept
169
+ * two renderers in step.
170
+ */
171
+ export const SameAsTheEditor: Story = {
172
+ render: (viewProps) => (
173
+ <StoryGrid columns={2}>
174
+ <StoryCell align="stretch" label="MarkdownView">
175
+ <MarkdownView {...viewProps} />
176
+ </StoryCell>
177
+
178
+ <StoryCell
179
+ align="stretch"
180
+ label="MarkdownEditorCodeMirror"
181
+ >
182
+ <MarkdownEditorCodeMirror
183
+ defaultValue={SAMPLE}
184
+ label="Description"
185
+ />
186
+ </StoryCell>
187
+ </StoryGrid>
188
+ ),
189
+ }
190
+
191
+ /**
192
+ * Everything the reader can be shown, including the two states that
193
+ * are easy to get wrong: an empty document, and a checkbox nobody
194
+ * may tick.
195
+ */
196
+ export const AllStates: Story = {
197
+ render: (viewProps) => (
198
+ <StoryGrid columns={2}>
199
+ <StoryCell
200
+ align="stretch"
201
+ label="checkboxes are inert without onToggleTask"
202
+ >
203
+ <MarkdownView
204
+ {...viewProps}
205
+ label="Read-only checklist"
206
+ value={CHECKLIST}
207
+ />
208
+ </StoryCell>
209
+
210
+ <StoryCell
211
+ align="stretch"
212
+ label="…and operable with one"
213
+ >
214
+ <TickableChecklist />
215
+ </StoryCell>
216
+
217
+ <StoryCell
218
+ align="stretch"
219
+ label="an image and a table"
220
+ >
221
+ <MarkdownView
222
+ {...viewProps}
223
+ label="Rack inventory"
224
+ value={WITH_IMAGE}
225
+ />
226
+ </StoryCell>
227
+
228
+ <StoryCell align="stretch" label="an empty document">
229
+ <MarkdownView
230
+ {...viewProps}
231
+ label="Nothing written yet"
232
+ value=""
233
+ />
234
+ </StoryCell>
235
+ </StoryGrid>
236
+ ),
237
+ }
238
+
239
+ /**
240
+ * A file from a git host, rendering as the characters it contains.
241
+ *
242
+ * The `javascript:` link keeps its brackets and its parentheses on
243
+ * purpose. A link-coloured word that silently refuses to work tells
244
+ * the reader nothing; the source tells them exactly what the file
245
+ * tried to do.
246
+ */
247
+ export const HostileMarkdown: Story = {
248
+ args: {
249
+ label: "README.md at 9f3c1ab",
250
+ value: HOSTILE,
251
+ },
252
+ }
253
+
254
+ /**
255
+ * The three container widths. A table that will not fit scrolls
256
+ * inside its own block rather than pushing the column sideways, and
257
+ * an image is capped at the width it is given — which is what keeps
258
+ * the Narrow View readable instead of merely small.
259
+ */
260
+ export const Responsive: Story = {
261
+ render: (viewProps) => (
262
+ <ContainerBoard>
263
+ {(width) => (
264
+ <Card heading={`Rack inventory (${width})`}>
265
+ <MarkdownView
266
+ {...viewProps}
267
+ label={`Rack inventory at ${width}`}
268
+ value={WITH_IMAGE}
269
+ />
270
+ </Card>
271
+ )}
272
+ </ContainerBoard>
273
+ ),
274
+ }
275
+
276
+ /**
277
+ * The complete keyboard path, which for a reader is short and is the
278
+ * point.
279
+ *
280
+ * The document itself is **not** a tab stop — there is nothing in it
281
+ * to operate, and a reading surface that swallowed a Tab would be
282
+ * one more thing between the reader and the next control. What *is*
283
+ * reachable is every link in it, in document order, because they are
284
+ * real anchors rather than painted spans: Tab to one, Enter to open
285
+ * it, and the context menu will copy its address.
286
+ *
287
+ * The buttons on either side are there so the path is visible — Tab
288
+ * from the first lands on each link in turn and ends on the second.
289
+ */
290
+ export const Interactive: Story = {
291
+ args: {
292
+ label: "Rack move, phase two",
293
+ value: SAMPLE,
294
+ },
295
+ render: (viewProps) => (
296
+ <div className="flex flex-col items-start gap-3">
297
+ <button
298
+ className="rounded-md border border-border-default px-3 py-1 text-content-primary text-sm"
299
+ type="button"
300
+ >
301
+ {"Before"}
302
+ </button>
303
+
304
+ <MarkdownView {...viewProps} />
305
+
306
+ <button
307
+ className="rounded-md border border-border-default px-3 py-1 text-content-primary text-sm"
308
+ type="button"
309
+ >
310
+ {"After"}
311
+ </button>
312
+ </div>
313
+ ),
314
+ }