@charcuterie/ui 3.0.0 → 3.2.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/README.md +10 -0
- package/dist/Badge/Badge.d.ts +33 -3
- package/dist/Badge/Badge.d.ts.map +1 -1
- package/dist/Badge/Badge.js +20 -2
- package/dist/Badge/Badge.js.map +1 -1
- package/dist/Board/Board.d.ts +119 -0
- package/dist/Board/Board.d.ts.map +1 -0
- package/dist/Board/Board.js +165 -0
- package/dist/Board/Board.js.map +1 -0
- package/dist/Board/BoardCard.d.ts +120 -0
- package/dist/Board/BoardCard.d.ts.map +1 -0
- package/dist/Board/BoardCard.js +77 -0
- package/dist/Board/BoardCard.js.map +1 -0
- package/dist/Board/BoardLaneList.d.ts +103 -0
- package/dist/Board/BoardLaneList.d.ts.map +1 -0
- package/dist/Board/BoardLaneList.js +69 -0
- package/dist/Board/BoardLaneList.js.map +1 -0
- package/dist/Board/boardMove.d.ts +116 -0
- package/dist/Board/boardMove.d.ts.map +1 -0
- package/dist/Board/boardMove.js +116 -0
- package/dist/Board/boardMove.js.map +1 -0
- package/dist/Board/useBoardDrag.d.ts +44 -0
- package/dist/Board/useBoardDrag.d.ts.map +1 -0
- package/dist/Board/useBoardDrag.js +237 -0
- package/dist/Board/useBoardDrag.js.map +1 -0
- package/dist/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/Checkbox/Checkbox.js +15 -4
- package/dist/Checkbox/Checkbox.js.map +1 -1
- package/dist/DataTable/DataTable.d.ts +159 -0
- package/dist/DataTable/DataTable.d.ts.map +1 -0
- package/dist/DataTable/DataTable.js +197 -0
- package/dist/DataTable/DataTable.js.map +1 -0
- package/dist/DatePicker/DateGrid.d.ts +79 -0
- package/dist/DatePicker/DateGrid.d.ts.map +1 -0
- package/dist/DatePicker/DateGrid.js +220 -0
- package/dist/DatePicker/DateGrid.js.map +1 -0
- package/dist/DatePicker/DatePicker.d.ts +138 -0
- package/dist/DatePicker/DatePicker.d.ts.map +1 -0
- package/dist/DatePicker/DatePicker.js +384 -0
- package/dist/DatePicker/DatePicker.js.map +1 -0
- package/dist/DatePicker/datePresets.d.ts +29 -0
- package/dist/DatePicker/datePresets.d.ts.map +1 -0
- package/dist/DatePicker/datePresets.js +28 -0
- package/dist/DatePicker/datePresets.js.map +1 -0
- package/dist/DatePicker/parseDateInput.d.ts +106 -0
- package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
- package/dist/DatePicker/parseDateInput.js +419 -0
- package/dist/DatePicker/parseDateInput.js.map +1 -0
- package/dist/DatePicker/plainDate.d.ts +167 -0
- package/dist/DatePicker/plainDate.d.ts.map +1 -0
- package/dist/DatePicker/plainDate.js +272 -0
- package/dist/DatePicker/plainDate.js.map +1 -0
- package/dist/Dialog/Dialog.d.ts +12 -2
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +2 -2
- package/dist/Dialog/Dialog.js.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
- package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.js +379 -0
- package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.js +303 -0
- package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
- package/dist/Modal/Modal.d.ts +6 -2
- package/dist/Modal/Modal.d.ts.map +1 -1
- package/dist/Modal/Modal.js +1 -1
- package/dist/Modal/Modal.js.map +1 -1
- package/dist/Overlay/OverlayPanel.d.ts +13 -2
- package/dist/Overlay/OverlayPanel.d.ts.map +1 -1
- package/dist/Overlay/OverlayPanel.js +5 -1
- package/dist/Overlay/OverlayPanel.js.map +1 -1
- package/dist/categoricalStyles.d.ts +53 -0
- package/dist/categoricalStyles.d.ts.map +1 -0
- package/dist/categoricalStyles.js +195 -0
- package/dist/categoricalStyles.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/Badge/Badge.mdx +46 -0
- package/src/Badge/Badge.stories.tsx +98 -4
- package/src/Badge/Badge.test.tsx +157 -2
- package/src/Badge/Badge.tsx +49 -3
- package/src/Board/Board.mdx +199 -0
- package/src/Board/Board.stories.tsx +707 -0
- package/src/Board/Board.test.tsx +310 -0
- package/src/Board/Board.tsx +333 -0
- package/src/Board/BoardCard.tsx +290 -0
- package/src/Board/BoardLaneList.tsx +275 -0
- package/src/Board/boardMove.test.ts +213 -0
- package/src/Board/boardMove.ts +225 -0
- package/src/Board/useBoardDrag.ts +413 -0
- package/src/Checkbox/Checkbox.tsx +34 -3
- package/src/DataTable/DataTable.mdx +123 -0
- package/src/DataTable/DataTable.stories.tsx +411 -0
- package/src/DataTable/DataTable.test.tsx +386 -0
- package/src/DataTable/DataTable.tsx +497 -0
- package/src/DatePicker/DateGrid.tsx +467 -0
- package/src/DatePicker/DatePicker.mdx +170 -0
- package/src/DatePicker/DatePicker.stories.tsx +268 -0
- package/src/DatePicker/DatePicker.test.tsx +455 -0
- package/src/DatePicker/DatePicker.tsx +852 -0
- package/src/DatePicker/datePresets.ts +34 -0
- package/src/DatePicker/parseDateInput.test.ts +187 -0
- package/src/DatePicker/parseDateInput.ts +629 -0
- package/src/DatePicker/plainDate.test.ts +375 -0
- package/src/DatePicker/plainDate.ts +465 -0
- package/src/Dialog/Dialog.stories.tsx +48 -0
- package/src/Dialog/Dialog.test.tsx +30 -0
- package/src/Dialog/Dialog.tsx +13 -1
- package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
- package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
- package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
- package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
- package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
- package/src/MarkdownEditor/markdownCommands.ts +566 -0
- package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
- package/src/MarkdownEditor/markdownSpans.ts +458 -0
- package/src/Modal/Modal.tsx +7 -1
- package/src/Overlay/OverlayPanel.tsx +17 -1
- package/src/argTypes.storyHelpers.ts +39 -0
- package/src/categoricalStyles.ts +244 -0
- package/src/index.ts +89 -0
- package/src/sourceRules.test.ts +52 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as MarkdownEditorStories from "./MarkdownEditor.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={MarkdownEditorStories} />
|
|
6
|
+
|
|
7
|
+
# MarkdownEditor
|
|
8
|
+
|
|
9
|
+
A live hybrid markdown editor whose stored value is markdown and **only** markdown.
|
|
10
|
+
|
|
11
|
+
Docket asked for this one and its requirement is unusually sharp, because the tracker it
|
|
12
|
+
replaces destroyed data by storing HTML in a field it later read back as text. So the
|
|
13
|
+
constraint is not "prefer markdown" — it is that an HTML tag must not be *able* to reach the
|
|
14
|
+
stored string.
|
|
15
|
+
|
|
16
|
+
<Canvas of={MarkdownEditorStories.Default} />
|
|
17
|
+
|
|
18
|
+
## Props
|
|
19
|
+
|
|
20
|
+
<Controls />
|
|
21
|
+
|
|
22
|
+
## No HTML can reach the value, by construction
|
|
23
|
+
|
|
24
|
+
Four properties, and none of them is a rule somebody has to remember:
|
|
25
|
+
|
|
26
|
+
- the editing surface is a `<textarea>`, which takes **`text/plain`** off the clipboard and
|
|
27
|
+
nothing else. Copy a styled paragraph out of a browser and the `text/html` flavour is
|
|
28
|
+
simply never read;
|
|
29
|
+
- every command in `markdownCommands.ts` returns markdown. There is no serialiser and no
|
|
30
|
+
intermediate document model anywhere in the component;
|
|
31
|
+
- the painted layer emits `{ kind, text }` spans that React renders as **text nodes**. No
|
|
32
|
+
`innerHTML`, no sanitiser, and nothing to get wrong — a `<script>` in the source is a
|
|
33
|
+
handful of coloured characters on screen;
|
|
34
|
+
- image insertion escapes its alt text and brackets a dirty URL, so a filename with a `]` in
|
|
35
|
+
it cannot end the construct early.
|
|
36
|
+
|
|
37
|
+
`markdownCommands.test.ts` runs every command over a value full of tags and entities and
|
|
38
|
+
asserts each fragment survives **verbatim** — not "contains no `<`", because the fixture
|
|
39
|
+
deliberately contains `<` as literal text and a naive absence check would pass on an
|
|
40
|
+
escaped value and fail on a correct one.
|
|
41
|
+
|
|
42
|
+
<Canvas of={MarkdownEditorStories.AllVariants} />
|
|
43
|
+
|
|
44
|
+
## Why a `<textarea>` and not an editor core
|
|
45
|
+
|
|
46
|
+
Charcuterie is published, so a dependency here is a permanent cost to every consumer.
|
|
47
|
+
Measured on this machine — `esbuild --bundle --minify`, React external, gzip -9:
|
|
48
|
+
|
|
49
|
+
| Candidate | minified | gzipped |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| TipTap 3.30 + starter kit + `tiptap-markdown` | 720 KB | **238 KB** |
|
|
52
|
+
| CodeMirror 6 (`basicSetup` + `lang-markdown`) | 609 KB | **206 KB** |
|
|
53
|
+
| CodeMirror 6, hand-assembled minimum | 512 KB | **176 KB** |
|
|
54
|
+
| Lexical 0.49 + markdown/list/link/code nodes | 449 KB | **148 KB** |
|
|
55
|
+
| **This component** | — | **0 KB added** |
|
|
56
|
+
|
|
57
|
+
The full argument, including what the choice gives up and what would justify reversing it,
|
|
58
|
+
is in
|
|
59
|
+
[`docs/decisions/2026-08-19-the-markdown-editor-is-a-textarea-with-a-painted-layer.md`](https://github.com/Sawtaytoes/charcuterie/blob/master/docs/decisions/2026-08-19-the-markdown-editor-is-a-textarea-with-a-painted-layer.md).
|
|
60
|
+
|
|
61
|
+
The accessibility half of it is the half that is hard to buy back later. A `<textarea>`
|
|
62
|
+
arrives with caret movement, word-wise selection, `Home`/`End`, IME composition, spellcheck,
|
|
63
|
+
autocorrect, the platform's own undo stack, `role="textbox"` and `aria-multiline="true"` —
|
|
64
|
+
all of it, in every browser, tested by the vendors. A `contenteditable` starts with none of
|
|
65
|
+
those and re-earns each one, and the ones it never fully re-earns are IME and undo.
|
|
66
|
+
|
|
67
|
+
## The painted layer, and the geometry that constrains it
|
|
68
|
+
|
|
69
|
+
The surface is a real `<textarea>` with a painted layer behind it in the **same grid cell**.
|
|
70
|
+
The textarea owns the caret, the selection, the undo stack and the IME; the layer owns the
|
|
71
|
+
colour. Its text is transparent and its `caret-color` is not, which is what `caret-color`
|
|
72
|
+
exists for.
|
|
73
|
+
|
|
74
|
+
That only works while the two layers **lay out identically**, so:
|
|
75
|
+
|
|
76
|
+
- the tokenizer keeps **every character**. It never trims, never normalises a tab, never
|
|
77
|
+
drops an unterminated marker. `markdownSpans.test.ts` joins every span back together and
|
|
78
|
+
asserts it reproduces the source byte for byte;
|
|
79
|
+
- every span style is **metric-neutral**. Bold is faux bold — `text-shadow` at ±0.02em paints
|
|
80
|
+
the glyph twice, a hair apart, which thickens it and moves nothing. Italic is real, because
|
|
81
|
+
the surface is monospaced and a monospaced italic keeps the advance width by definition.
|
|
82
|
+
Headings get weight and colour rather than size. `MarkdownEditor.test.tsx` **measures** each
|
|
83
|
+
one against plain text and fails if any of them is wider.
|
|
84
|
+
|
|
85
|
+
The honest limit follows from the same constraint: **markers are dimmed, not hidden.**
|
|
86
|
+
Concealing `**` would change the line's width and there is no way to do it in this medium.
|
|
87
|
+
So "raw syntax on the cursor's line" becomes a contrast change — muted markers everywhere,
|
|
88
|
+
accent markers on the line the caret is on — and true concealment is the staged follow-up,
|
|
89
|
+
which needs a decoration-capable surface (CodeMirror 6) and would arrive as an **optional
|
|
90
|
+
subpath** so consumers that do not use it keep paying nothing.
|
|
91
|
+
|
|
92
|
+
<Canvas of={MarkdownEditorStories.NoUpload} />
|
|
93
|
+
|
|
94
|
+
## Uncontrolled, and that is not a shortcut
|
|
95
|
+
|
|
96
|
+
`defaultValue` in, `onChange` out — the same contract as `Select`, and here it protects
|
|
97
|
+
something specific. A controlled React `<textarea>` round-trips every keystroke through
|
|
98
|
+
render, and any programmatic `value` assignment **wipes the browser's undo stack**: Ctrl+Z
|
|
99
|
+
after a toolbar click then skips the paragraph you typed before it, or does nothing at all.
|
|
100
|
+
|
|
101
|
+
So the textarea is never handed a `value`. Its text is mirrored into state for *painting*
|
|
102
|
+
only, and every programmatic edit is applied as a range replacement through
|
|
103
|
+
`document.execCommand("insertText")` — deprecated, and still the only API that appends to
|
|
104
|
+
the native undo stack. `getMinimalEdit` is what turns a command's whole-string result back
|
|
105
|
+
into that range, so a bold toggle is **one** undo step over four characters rather than a
|
|
106
|
+
document-sized one.
|
|
107
|
+
|
|
108
|
+
A consumer that must swap the whole document — Docket opening a different task — remounts
|
|
109
|
+
with a `key`. That is one line, and it is honest about the fact that the old undo history
|
|
110
|
+
belonged to the old document.
|
|
111
|
+
|
|
112
|
+
## Paste an image at the cursor
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
<MarkdownEditor
|
|
116
|
+
label="Description"
|
|
117
|
+
onUploadImage={async (file) => {
|
|
118
|
+
const { url } = await uploadToBlobStore(file)
|
|
119
|
+
|
|
120
|
+
return { alt: file.name, url }
|
|
121
|
+
}}
|
|
122
|
+
/>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**The component owns no storage.** It hands you a `File`, waits, and inserts the URL you
|
|
126
|
+
return as `` at the caret. Docket keeps its own blob storage precisely so the URL
|
|
127
|
+
stays stable and theirs.
|
|
128
|
+
|
|
129
|
+
While the request is in flight the document holds a **markdown placeholder** —
|
|
130
|
+
`` — rather than an overlay, because the user keeps typing
|
|
131
|
+
during an upload and the insertion point has to move with what they type. It carries a
|
|
132
|
+
counter so two files of the same name do not resolve into each other's slot, and a failed
|
|
133
|
+
upload leaves a document the user can read and fix rather than a dangling widget.
|
|
134
|
+
|
|
135
|
+
Omit `onUploadImage` and image paste is simply not offered. A `<textarea>` ignores a pasted
|
|
136
|
+
image on its own, which is the correct fallback rather than a broken one.
|
|
137
|
+
|
|
138
|
+
## Composition, and the one component that did not fit
|
|
139
|
+
|
|
140
|
+
The toolbar is this package's `Toolbar`, so the row collapses into a **menu by measurement**
|
|
141
|
+
rather than at a breakpoint — which is what the Narrow View actually needs, since a lane in a
|
|
142
|
+
three-up board is narrow on a 4K display. `Field` wraps it through `SlotProps`, so a label, a
|
|
143
|
+
description and an error wire themselves to the `<textarea>` rather than stopping at this
|
|
144
|
+
component.
|
|
145
|
+
|
|
146
|
+
`FileDropZone` does **not** wrap it, and that is the interesting one. That component is a
|
|
147
|
+
`<label>` around a file input covering its whole area — correct for "drop a disc image here",
|
|
148
|
+
and wrong for a text surface, where the drop has to land *at a character offset* and the area
|
|
149
|
+
has to keep taking clicks as caret placement. So the editor handles its own drop, and
|
|
150
|
+
`FileDropZone` stays the right answer for the attachment list beside it.
|
|
151
|
+
|
|
152
|
+
<Canvas of={MarkdownEditorStories.Responsive} />
|
|
153
|
+
|
|
154
|
+
## Keyboard
|
|
155
|
+
|
|
156
|
+
| Key | Does |
|
|
157
|
+
| --- | --- |
|
|
158
|
+
| `Ctrl`/`Cmd` + `B` | Bold, and un-bold |
|
|
159
|
+
| `Ctrl`/`Cmd` + `I` | Italic |
|
|
160
|
+
| `Ctrl`/`Cmd` + `E` | Inline code |
|
|
161
|
+
| `Ctrl`/`Cmd` + `K` | Link, caret parked in the destination |
|
|
162
|
+
| `Ctrl`/`Cmd` + `]` / `[` | Indent / outdent the selected lines |
|
|
163
|
+
| `Enter` | Continues a list, a task list or a quote — and **clears** an empty item |
|
|
164
|
+
| `Tab` | Moves focus. Always. |
|
|
165
|
+
|
|
166
|
+
**`Tab` is never captured**, at any time. Indenting with Tab is the convention in editors
|
|
167
|
+
that own the whole page, and inside a form it is a keyboard trap: WCAG 2.1.2 wants focus to
|
|
168
|
+
be able to leave every component with the standard gesture, and a multi-line field on a task
|
|
169
|
+
form is exactly where somebody tabs onward.
|
|
170
|
+
|
|
171
|
+
Enter returns *nothing to do* rather than an unchanged state when the caret is not in a list,
|
|
172
|
+
so the keypress falls through to the browser and lands on the native undo stack as an
|
|
173
|
+
ordinary typing step.
|
|
174
|
+
|
|
175
|
+
<Canvas of={MarkdownEditorStories.AllStates} />
|
|
176
|
+
|
|
177
|
+
## What this deliberately does not parse
|
|
178
|
+
|
|
179
|
+
The tokenizer resolves **one line at a time**, carrying exactly one bit of cross-line state:
|
|
180
|
+
whether we are inside a fenced code block. Painting re-runs on every keystroke, so the cost
|
|
181
|
+
has to be linear in the line rather than in the document — and a mis-coloured marker is
|
|
182
|
+
cosmetic while a mis-*placed* one is a broken editor.
|
|
183
|
+
|
|
184
|
+
So these colour as plain text: setext headings (`Title` over `=====`), indented four-space
|
|
185
|
+
code blocks, reference links (`[a][b]`), autolinks in angle brackets, and emphasis that spans
|
|
186
|
+
a line break. They all still **store** correctly, because the stored value is the textarea's
|
|
187
|
+
and not the tokenizer's.
|
|
188
|
+
|
|
189
|
+
<Canvas of={MarkdownEditorStories.Interactive} />
|
|
@@ -0,0 +1,267 @@
|
|
|
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 { Field } from "../Field/Field.tsx"
|
|
10
|
+
import { MarkdownEditor } from "./MarkdownEditor.tsx"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Invented, like every fixture in this repo. A task in a made-up
|
|
14
|
+
* homelab project, exercising each construct the painted layer
|
|
15
|
+
* knows about.
|
|
16
|
+
*/
|
|
17
|
+
const SAMPLE = `## Rack move, phase two
|
|
18
|
+
|
|
19
|
+
The **short** version: pull the _old_ switch, land the new one, and
|
|
20
|
+
keep the ~~three~~ two uplinks live throughout.
|
|
21
|
+
|
|
22
|
+
- [x] Label every patch lead
|
|
23
|
+
- [ ] Photograph the current cabling
|
|
24
|
+
- [ ] Cut over at 02:00
|
|
25
|
+
|
|
26
|
+
1. Power down the shelf
|
|
27
|
+
2. Swap the rails
|
|
28
|
+
3. Bring it back up
|
|
29
|
+
|
|
30
|
+
> Nothing here touches the storage pool.
|
|
31
|
+
|
|
32
|
+
Run \`ping -c 3 gateway.invalid\` before and after, and read
|
|
33
|
+
[the runbook](https://example.invalid/runbook) first.
|
|
34
|
+
|
|
35
|
+
| Port | Goes to |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| 1 | uplink |
|
|
38
|
+
| 2 | shelf |
|
|
39
|
+
|
|
40
|
+
\`\`\`sh
|
|
41
|
+
ip -br addr show
|
|
42
|
+
\`\`\`
|
|
43
|
+
`
|
|
44
|
+
|
|
45
|
+
const noop = () => undefined
|
|
46
|
+
|
|
47
|
+
const meta = {
|
|
48
|
+
title: "Components/MarkdownEditor",
|
|
49
|
+
component: MarkdownEditor,
|
|
50
|
+
parameters: { layout: "padded" },
|
|
51
|
+
args: {
|
|
52
|
+
isDisabled: false,
|
|
53
|
+
isReadOnly: false,
|
|
54
|
+
label: "Description",
|
|
55
|
+
placeholder: "Write it in markdown…",
|
|
56
|
+
},
|
|
57
|
+
} satisfies Meta<typeof MarkdownEditor>
|
|
58
|
+
|
|
59
|
+
export default meta
|
|
60
|
+
|
|
61
|
+
type Story = StoryObj<typeof meta>
|
|
62
|
+
|
|
63
|
+
export const Default: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
defaultValue: SAMPLE,
|
|
66
|
+
label: "Description",
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const AllVariants: Story = {
|
|
71
|
+
args: { label: "Description" },
|
|
72
|
+
render: () => (
|
|
73
|
+
<StoryGrid columns={2}>
|
|
74
|
+
<StoryCell align="stretch" label="empty">
|
|
75
|
+
<MarkdownEditor
|
|
76
|
+
label="Empty description"
|
|
77
|
+
placeholder="Write it in markdown…"
|
|
78
|
+
/>
|
|
79
|
+
</StoryCell>
|
|
80
|
+
|
|
81
|
+
<StoryCell align="stretch" label="inside a Field">
|
|
82
|
+
<Field
|
|
83
|
+
description="Markdown is what gets stored."
|
|
84
|
+
label="Acceptance criteria"
|
|
85
|
+
>
|
|
86
|
+
<MarkdownEditor defaultValue="- [ ] It **works**" />
|
|
87
|
+
</Field>
|
|
88
|
+
</StoryCell>
|
|
89
|
+
|
|
90
|
+
<StoryCell align="stretch" label="a whole document">
|
|
91
|
+
<MarkdownEditor
|
|
92
|
+
defaultValue={SAMPLE}
|
|
93
|
+
label="Long description"
|
|
94
|
+
/>
|
|
95
|
+
</StoryCell>
|
|
96
|
+
|
|
97
|
+
<StoryCell
|
|
98
|
+
align="stretch"
|
|
99
|
+
label="HTML is text, not markup"
|
|
100
|
+
>
|
|
101
|
+
<MarkdownEditor
|
|
102
|
+
defaultValue={
|
|
103
|
+
"<script>alert('xss')</script>\n\nA tag pasted in here is four coloured characters, because nothing in this component ever builds HTML.\n\n& < > survive as literal text too."
|
|
104
|
+
}
|
|
105
|
+
label="HTML description"
|
|
106
|
+
/>
|
|
107
|
+
</StoryCell>
|
|
108
|
+
</StoryGrid>
|
|
109
|
+
),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const AllStates: Story = {
|
|
113
|
+
args: { label: "Description" },
|
|
114
|
+
render: () => (
|
|
115
|
+
<StoryGrid columns={2}>
|
|
116
|
+
<StoryCell align="stretch" label="idle">
|
|
117
|
+
<MarkdownEditor
|
|
118
|
+
defaultValue="An **idle** editor."
|
|
119
|
+
label="Idle description"
|
|
120
|
+
/>
|
|
121
|
+
</StoryCell>
|
|
122
|
+
|
|
123
|
+
<StoryCell align="stretch" label="read only">
|
|
124
|
+
<MarkdownEditor
|
|
125
|
+
defaultValue="A **read-only** editor still highlights."
|
|
126
|
+
isReadOnly
|
|
127
|
+
label="Read-only description"
|
|
128
|
+
/>
|
|
129
|
+
</StoryCell>
|
|
130
|
+
|
|
131
|
+
<StoryCell align="stretch" label="disabled">
|
|
132
|
+
<MarkdownEditor
|
|
133
|
+
defaultValue="A **disabled** editor."
|
|
134
|
+
isDisabled
|
|
135
|
+
label="Disabled description"
|
|
136
|
+
/>
|
|
137
|
+
</StoryCell>
|
|
138
|
+
|
|
139
|
+
<StoryCell
|
|
140
|
+
align="stretch"
|
|
141
|
+
label="invalid, in a Field"
|
|
142
|
+
>
|
|
143
|
+
<Field
|
|
144
|
+
error="A description is required before a task can leave Backlog."
|
|
145
|
+
isRequired
|
|
146
|
+
label="Task description"
|
|
147
|
+
>
|
|
148
|
+
<MarkdownEditor defaultValue="" />
|
|
149
|
+
</Field>
|
|
150
|
+
</StoryCell>
|
|
151
|
+
</StoryGrid>
|
|
152
|
+
),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The only honest way to story a container query: three fixed
|
|
157
|
+
* inline sizes, side by side, while the browser window never moves.
|
|
158
|
+
*
|
|
159
|
+
* The toolbar is this package's `Toolbar`, so it collapses into a
|
|
160
|
+
* **menu by measurement** rather than at a breakpoint — which is
|
|
161
|
+
* what the Narrow View actually needs, since a lane in a three-up
|
|
162
|
+
* board is narrow on a 4K display. The hint line under the surface
|
|
163
|
+
* is the container-query half: `sr-only` below `--cq-sm`, painted
|
|
164
|
+
* above it, and in the accessibility tree at every width.
|
|
165
|
+
*/
|
|
166
|
+
export const Responsive: Story = {
|
|
167
|
+
args: { label: "Description" },
|
|
168
|
+
render: () => (
|
|
169
|
+
<ContainerBoard>
|
|
170
|
+
{(width) => (
|
|
171
|
+
<MarkdownEditor
|
|
172
|
+
defaultValue={
|
|
173
|
+
"## Cut over\n\n- [ ] Label the leads\n- [ ] **Photograph** the cabling"
|
|
174
|
+
}
|
|
175
|
+
label={`Description at ${width}`}
|
|
176
|
+
/>
|
|
177
|
+
)}
|
|
178
|
+
</ContainerBoard>
|
|
179
|
+
),
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Type into it. The syntax colours as you go, and the markers on
|
|
184
|
+
* the line the caret is on paint in the accent colour while every
|
|
185
|
+
* other line's stay muted — that is the "raw syntax on the cursor's
|
|
186
|
+
* line" behaviour, in a medium where hiding the markers would move
|
|
187
|
+
* the text out from under the caret.
|
|
188
|
+
*
|
|
189
|
+
* Everything the toolbar does has a shortcut: **Ctrl+B**, **Ctrl+I**,
|
|
190
|
+
* **Ctrl+K**, **Ctrl+E**, **Ctrl+]** and **Ctrl+[**, and Enter
|
|
191
|
+
* continues a list. **Tab is never captured** — it moves focus, at
|
|
192
|
+
* every time, because a multi-line field on a form is exactly where
|
|
193
|
+
* somebody tabs onward.
|
|
194
|
+
*
|
|
195
|
+
* The upload here is a fake that resolves after a beat with a data
|
|
196
|
+
* URL, so the placeholder is visible while it runs. A real consumer
|
|
197
|
+
* returns a URL from its own blob storage.
|
|
198
|
+
*/
|
|
199
|
+
export const Interactive: Story = {
|
|
200
|
+
args: { label: "Description" },
|
|
201
|
+
render: function LiveEditor() {
|
|
202
|
+
const [stored, setStored] = useState(
|
|
203
|
+
"Paste an image, or press Ctrl+B and type.\n",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div className="flex flex-col gap-3">
|
|
208
|
+
<MarkdownEditor
|
|
209
|
+
label="Task description"
|
|
210
|
+
defaultValue={stored}
|
|
211
|
+
onChange={setStored}
|
|
212
|
+
onUploadImage={async (file) => {
|
|
213
|
+
await new Promise((resolve) => {
|
|
214
|
+
setTimeout(resolve, 600)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
alt: file.name,
|
|
219
|
+
url: `https://example.invalid/blobs/${encodeURIComponent(file.name)}`,
|
|
220
|
+
}
|
|
221
|
+
}}
|
|
222
|
+
placeholder="Write it in markdown…"
|
|
223
|
+
/>
|
|
224
|
+
|
|
225
|
+
<div className="flex flex-col gap-1">
|
|
226
|
+
<span className="font-mono text-content-muted text-xs">
|
|
227
|
+
what gets stored
|
|
228
|
+
</span>
|
|
229
|
+
|
|
230
|
+
{/*
|
|
231
|
+
Wraps rather than scrolls. A scrollable region has to be
|
|
232
|
+
keyboard-reachable — axe's
|
|
233
|
+
`scrollable-region-focusable`, a real rule rather than a
|
|
234
|
+
nag — and `biome --write --unsafe` strips a `tabIndex`
|
|
235
|
+
off a non-interactive element, so the two fight. Not
|
|
236
|
+
being a scroll container settles it.
|
|
237
|
+
*/}
|
|
238
|
+
<pre className="whitespace-pre-wrap rounded-md bg-surface-sunken p-3 font-mono text-content-secondary text-sm wrap-anywhere">
|
|
239
|
+
{stored}
|
|
240
|
+
</pre>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
)
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Nothing but the surface, for a screenshot of the painted layer on
|
|
249
|
+
* its own.
|
|
250
|
+
*/
|
|
251
|
+
export const NoUpload: Story = {
|
|
252
|
+
args: {
|
|
253
|
+
defaultValue:
|
|
254
|
+
"# Heading\n\n**bold**, _italic_, `code`, ~~struck~~, [a link](https://example.invalid).\n\n- [ ] a task\n- [x] a done task\n\n> a quote\n",
|
|
255
|
+
label: "Description",
|
|
256
|
+
onUploadImage: undefined,
|
|
257
|
+
},
|
|
258
|
+
render: (args) => <MarkdownEditor {...args} />,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export const Blank: Story = {
|
|
262
|
+
args: {
|
|
263
|
+
label: "Description",
|
|
264
|
+
onChange: noop,
|
|
265
|
+
placeholder: "Write it in markdown…",
|
|
266
|
+
},
|
|
267
|
+
}
|