@charcuterie/ui 3.4.1 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Stepper/Stepper.d.ts +89 -0
- package/dist/Stepper/Stepper.d.ts.map +1 -0
- package/dist/Stepper/Stepper.js +87 -0
- package/dist/Stepper/Stepper.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.js +346 -3
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +99 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js +370 -19
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
- package/package.json +1 -1
- package/src/Stepper/Stepper.mdx +86 -0
- package/src/Stepper/Stepper.stories.tsx +224 -0
- package/src/Stepper/Stepper.test.tsx +112 -0
- package/src/Stepper/Stepper.tsx +246 -0
- package/src/index.ts +7 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +28 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +42 -0
- package/src/markdownEditorCodeMirror/livePreview.ts +465 -3
- package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +349 -8
- package/src/markdownEditorCodeMirror/livePreviewRanges.ts +669 -27
- package/src/sourceRules.test.ts +8 -1
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
import { useState } from "react"
|
|
3
|
+
import { Button } from "../Button/Button.tsx"
|
|
4
|
+
import {
|
|
5
|
+
ContainerBoard,
|
|
6
|
+
StoryCell,
|
|
7
|
+
StoryGrid,
|
|
8
|
+
StorySection,
|
|
9
|
+
} from "../board.storyHelpers.tsx"
|
|
10
|
+
import { Card } from "../Card/Card.tsx"
|
|
11
|
+
import type { Step } from "./Stepper.tsx"
|
|
12
|
+
import { Stepper } from "./Stepper.tsx"
|
|
13
|
+
|
|
14
|
+
const RIP: Step[] = [
|
|
15
|
+
{
|
|
16
|
+
description: "3 of 3 discs read",
|
|
17
|
+
key: "rip",
|
|
18
|
+
label: "Rip",
|
|
19
|
+
status: "done",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
description: "2 of 5 titles tagged",
|
|
23
|
+
key: "tag",
|
|
24
|
+
label: "Tag",
|
|
25
|
+
status: "current",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
description: "Opens when Tag is finished",
|
|
29
|
+
key: "file",
|
|
30
|
+
label: "File",
|
|
31
|
+
status: "upcoming",
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const meta = {
|
|
36
|
+
title: "Components/Data/Stepper",
|
|
37
|
+
component: Stepper,
|
|
38
|
+
parameters: { layout: "padded" },
|
|
39
|
+
args: {
|
|
40
|
+
headingLevel: 3,
|
|
41
|
+
label: "Ingest sequence",
|
|
42
|
+
orientation: "vertical",
|
|
43
|
+
steps: RIP,
|
|
44
|
+
},
|
|
45
|
+
} satisfies Meta<typeof Stepper>
|
|
46
|
+
|
|
47
|
+
export default meta
|
|
48
|
+
|
|
49
|
+
type Story = StoryObj<typeof meta>
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The marker is the ordinal and never a tick — this library ships
|
|
53
|
+
* no icons and no symbol glyphs, and a `✓` the font lacks renders
|
|
54
|
+
* as an empty box in the one position where an empty box reads as
|
|
55
|
+
* an error.
|
|
56
|
+
*/
|
|
57
|
+
export const Default: Story = {}
|
|
58
|
+
|
|
59
|
+
export const AllVariants: Story = {
|
|
60
|
+
render: (stepperProps) => (
|
|
61
|
+
<StorySection title="Four statuses, two orientations, and the case the component exists for: steps that CARRY something.">
|
|
62
|
+
<StoryGrid columns={2}>
|
|
63
|
+
<StoryCell align="stretch" label="vertical">
|
|
64
|
+
<Stepper {...stepperProps} />
|
|
65
|
+
</StoryCell>
|
|
66
|
+
|
|
67
|
+
<StoryCell
|
|
68
|
+
align="stretch"
|
|
69
|
+
label="orientation=horizontal"
|
|
70
|
+
>
|
|
71
|
+
<Stepper
|
|
72
|
+
{...stepperProps}
|
|
73
|
+
label="Ingest sequence, across"
|
|
74
|
+
orientation="horizontal"
|
|
75
|
+
/>
|
|
76
|
+
</StoryCell>
|
|
77
|
+
|
|
78
|
+
<StoryCell align="stretch" label="status=blocked">
|
|
79
|
+
{/* Blocked is not a worse `upcoming`. Its turn HAS come
|
|
80
|
+
and something is in the way — a thing to go and fix
|
|
81
|
+
rather than a thing to wait for. */}
|
|
82
|
+
<Stepper
|
|
83
|
+
{...stepperProps}
|
|
84
|
+
label="A sequence held up"
|
|
85
|
+
steps={[
|
|
86
|
+
{ key: "a", label: "Rip", status: "done" },
|
|
87
|
+
{
|
|
88
|
+
description:
|
|
89
|
+
"Waiting on Rip, which has no titles yet — add one, or delete the step",
|
|
90
|
+
key: "b",
|
|
91
|
+
label: "Tag",
|
|
92
|
+
status: "blocked",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
key: "c",
|
|
96
|
+
label: "File",
|
|
97
|
+
status: "upcoming",
|
|
98
|
+
},
|
|
99
|
+
]}
|
|
100
|
+
/>
|
|
101
|
+
</StoryCell>
|
|
102
|
+
|
|
103
|
+
<StoryCell
|
|
104
|
+
align="stretch"
|
|
105
|
+
label="steps that carry content"
|
|
106
|
+
>
|
|
107
|
+
<Stepper
|
|
108
|
+
{...stepperProps}
|
|
109
|
+
label="A sequence with its work under it"
|
|
110
|
+
steps={[
|
|
111
|
+
{
|
|
112
|
+
content: (
|
|
113
|
+
<Card>Read the disc into an ISO</Card>
|
|
114
|
+
),
|
|
115
|
+
key: "a",
|
|
116
|
+
label: "Rip",
|
|
117
|
+
status: "done",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
content: <Card>Match each title</Card>,
|
|
121
|
+
key: "b",
|
|
122
|
+
label: "Tag",
|
|
123
|
+
status: "current",
|
|
124
|
+
},
|
|
125
|
+
]}
|
|
126
|
+
/>
|
|
127
|
+
</StoryCell>
|
|
128
|
+
</StoryGrid>
|
|
129
|
+
</StorySection>
|
|
130
|
+
),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const AllStates: Story = {
|
|
134
|
+
render: (stepperProps) => (
|
|
135
|
+
<StorySection title="Every status beside every other, so the four are legible as a set rather than one at a time.">
|
|
136
|
+
<Stepper
|
|
137
|
+
{...stepperProps}
|
|
138
|
+
label="Every status"
|
|
139
|
+
steps={[
|
|
140
|
+
{ key: "a", label: "Done", status: "done" },
|
|
141
|
+
{
|
|
142
|
+
key: "b",
|
|
143
|
+
label: "In progress",
|
|
144
|
+
status: "current",
|
|
145
|
+
},
|
|
146
|
+
{ key: "c", label: "Blocked", status: "blocked" },
|
|
147
|
+
{
|
|
148
|
+
key: "d",
|
|
149
|
+
label: "Not started",
|
|
150
|
+
status: "upcoming",
|
|
151
|
+
},
|
|
152
|
+
]}
|
|
153
|
+
/>
|
|
154
|
+
</StorySection>
|
|
155
|
+
),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `horizontal` is a REQUEST. Below `cq-md` it falls back to the
|
|
160
|
+
* column, because four steps side by side in a narrow container
|
|
161
|
+
* squash into `2. / De- / dupe` — measured, in the app this
|
|
162
|
+
* component came out of.
|
|
163
|
+
*/
|
|
164
|
+
export const Responsive: Story = {
|
|
165
|
+
args: { orientation: "horizontal" },
|
|
166
|
+
render: (stepperProps) => (
|
|
167
|
+
<ContainerBoard>
|
|
168
|
+
<Stepper {...stepperProps} />
|
|
169
|
+
</ContainerBoard>
|
|
170
|
+
),
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The whole keyboard path, which is the point of the headings: a
|
|
175
|
+
* stepper is a document outline, not a widget. There is nothing to
|
|
176
|
+
* focus and nothing to arrow between — advancing is the app's
|
|
177
|
+
* operation, and the component only reports where the sequence got
|
|
178
|
+
* to.
|
|
179
|
+
*/
|
|
180
|
+
export const Interactive: Story = {
|
|
181
|
+
render: (stepperProps) => {
|
|
182
|
+
const [reached, setReached] = useState(1)
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div className="flex flex-col gap-3">
|
|
186
|
+
<div className="flex gap-2">
|
|
187
|
+
<Button
|
|
188
|
+
isDisabled={reached === 0}
|
|
189
|
+
onClick={() => {
|
|
190
|
+
setReached((was) => was - 1)
|
|
191
|
+
}}
|
|
192
|
+
type="button"
|
|
193
|
+
>
|
|
194
|
+
Back
|
|
195
|
+
</Button>
|
|
196
|
+
<Button
|
|
197
|
+
intent="accent"
|
|
198
|
+
isDisabled={reached === RIP.length}
|
|
199
|
+
onClick={() => {
|
|
200
|
+
setReached((was) => was + 1)
|
|
201
|
+
}}
|
|
202
|
+
type="button"
|
|
203
|
+
>
|
|
204
|
+
Advance
|
|
205
|
+
</Button>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<Stepper
|
|
209
|
+
{...stepperProps}
|
|
210
|
+
steps={RIP.map((step, index) => ({
|
|
211
|
+
...step,
|
|
212
|
+
description: undefined,
|
|
213
|
+
status:
|
|
214
|
+
index < reached
|
|
215
|
+
? ("done" as const)
|
|
216
|
+
: index === reached
|
|
217
|
+
? ("current" as const)
|
|
218
|
+
: ("upcoming" as const),
|
|
219
|
+
}))}
|
|
220
|
+
/>
|
|
221
|
+
</div>
|
|
222
|
+
)
|
|
223
|
+
},
|
|
224
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
6
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
7
|
+
import * as stories from "./Stepper.stories.tsx"
|
|
8
|
+
|
|
9
|
+
const { AllStates, Default, Interactive, Responsive } =
|
|
10
|
+
composeStories(stories)
|
|
11
|
+
|
|
12
|
+
test("the sequence is an ordered list with a name", async () => {
|
|
13
|
+
const { canvas } = await mountStory(Default)
|
|
14
|
+
|
|
15
|
+
// A `<ol>`, not a stack of divs: the ORDER is the meaning, and a
|
|
16
|
+
// screen reader announcing "list, 3 items" is what carries it.
|
|
17
|
+
await expect(
|
|
18
|
+
canvas.getByRole("list", { name: "Ingest sequence" }),
|
|
19
|
+
).toBeVisible()
|
|
20
|
+
await expect(
|
|
21
|
+
canvas.getAllByRole("listitem"),
|
|
22
|
+
).toHaveLength(3)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test("each step is a heading at the level the caller asked for", async () => {
|
|
26
|
+
const { canvas } = await mountStory(Default)
|
|
27
|
+
|
|
28
|
+
await expect(
|
|
29
|
+
canvas.getByRole("heading", { level: 3, name: /Rip/ }),
|
|
30
|
+
).toBeVisible()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The rule this component turns on. Four statuses painted as four
|
|
35
|
+
* marker colours is a WCAG 1.4.1 failure however clear it looks,
|
|
36
|
+
* so every status reaches a screen reader as a word — and that is
|
|
37
|
+
* also the only channel that survives greyscale and the ePaper
|
|
38
|
+
* build.
|
|
39
|
+
*/
|
|
40
|
+
test("every status is a word, not only a colour", async () => {
|
|
41
|
+
const { canvas } = await mountStory(AllStates)
|
|
42
|
+
|
|
43
|
+
for (const status of [
|
|
44
|
+
"Done",
|
|
45
|
+
"In progress",
|
|
46
|
+
"Blocked",
|
|
47
|
+
"Not started",
|
|
48
|
+
]) {
|
|
49
|
+
// Twice each: once as the step's visible label in this story,
|
|
50
|
+
// once as the visually-hidden status.
|
|
51
|
+
await expect(
|
|
52
|
+
canvas.getAllByText(status).length,
|
|
53
|
+
).toBeGreaterThanOrEqual(2)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("the marker is the ordinal, so it renders in a font with no tick glyph", async () => {
|
|
58
|
+
const { canvas } = await mountStory(Default)
|
|
59
|
+
|
|
60
|
+
// Not `✓`. `docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-
|
|
61
|
+
// glyphs.md` — a glyph the font lacks paints as an empty box, and
|
|
62
|
+
// an empty box in the "done" position reads as an error.
|
|
63
|
+
await expect(canvas.getByText("1")).toBeVisible()
|
|
64
|
+
await expect(canvas.getByText("3")).toBeVisible()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The last step must not draw a connector into empty space. A real
|
|
69
|
+
* element rather than a `::before` is what makes this assertable at
|
|
70
|
+
* all — a pseudo-element has nothing to count.
|
|
71
|
+
*/
|
|
72
|
+
test("the last step draws no connector", async () => {
|
|
73
|
+
const { canvas } = await mountStory(Default)
|
|
74
|
+
|
|
75
|
+
const connectors = canvas
|
|
76
|
+
.getAllByRole("listitem")
|
|
77
|
+
.map((item: HTMLElement) =>
|
|
78
|
+
item.querySelector("span[aria-hidden='true']"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
await expect(connectors.filter(Boolean)).toHaveLength(2)
|
|
82
|
+
await expect(connectors[2]).toBeNull()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test("advancing moves which step is current", async () => {
|
|
86
|
+
const { canvas } = await mountStory(Interactive)
|
|
87
|
+
|
|
88
|
+
const heading = () =>
|
|
89
|
+
canvas.getByRole("heading", { level: 3, name: /Tag/ })
|
|
90
|
+
|
|
91
|
+
await expect(heading()).toBeVisible()
|
|
92
|
+
|
|
93
|
+
await userEvent.click(
|
|
94
|
+
canvas.getByRole("button", { name: "Advance" }),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
// Tag was current and is now done — the status word is the
|
|
98
|
+
// assertion, because it is the only channel that is not a colour.
|
|
99
|
+
await expect(
|
|
100
|
+
heading().textContent?.includes("Done"),
|
|
101
|
+
).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test("no axe violations, in either orientation", async () => {
|
|
105
|
+
const vertical = await mountStory(Default)
|
|
106
|
+
|
|
107
|
+
await expectNoAxeViolations(vertical.canvasElement)
|
|
108
|
+
|
|
109
|
+
const horizontal = await mountStory(Responsive)
|
|
110
|
+
|
|
111
|
+
await expectNoAxeViolations(horizontal.canvasElement)
|
|
112
|
+
})
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import { toClassName } from "../toClassName.ts"
|
|
4
|
+
import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where a step sits in the sequence.
|
|
8
|
+
*
|
|
9
|
+
* `blocked` is not a worse `upcoming`. Upcoming means "its turn has
|
|
10
|
+
* not come"; blocked means "its turn has come and something is in
|
|
11
|
+
* the way", which is a thing to go and fix rather than a thing to
|
|
12
|
+
* wait for. Docket's phases need exactly this split — a phase gated
|
|
13
|
+
* behind an unfinished one, versus a phase gated behind an EMPTY
|
|
14
|
+
* one, where the second is a mistake somebody has to correct.
|
|
15
|
+
*/
|
|
16
|
+
export type StepStatus =
|
|
17
|
+
| "blocked"
|
|
18
|
+
| "current"
|
|
19
|
+
| "done"
|
|
20
|
+
| "upcoming"
|
|
21
|
+
|
|
22
|
+
export type Step = {
|
|
23
|
+
/** What the marker shows instead of its ordinal position. */
|
|
24
|
+
readonly marker?: ReactNode
|
|
25
|
+
readonly key: string
|
|
26
|
+
readonly label: ReactNode
|
|
27
|
+
/** One line under the label — why it is blocked, what it is
|
|
28
|
+
* waiting on, when it finished. */
|
|
29
|
+
readonly description?: ReactNode
|
|
30
|
+
/**
|
|
31
|
+
* What BELONGS to this step: the tasks in a phase, the fields in
|
|
32
|
+
* a form stage, the checks in a pipeline.
|
|
33
|
+
*
|
|
34
|
+
* The reason this component is not a `ProgressBar` with a legend.
|
|
35
|
+
* A bar says a sequence is half done; only the contents say which
|
|
36
|
+
* things go together and which one is next.
|
|
37
|
+
*/
|
|
38
|
+
readonly content?: ReactNode
|
|
39
|
+
readonly status?: StepStatus
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type StepperOrientation = "horizontal" | "vertical"
|
|
43
|
+
|
|
44
|
+
export type StepperProps = {
|
|
45
|
+
readonly className?: string
|
|
46
|
+
/**
|
|
47
|
+
* What level the step labels are. The caller's, because only the
|
|
48
|
+
* caller knows what the stepper sits under — one below a page
|
|
49
|
+
* `<h1>` wants `2`, one inside an already-deep section wants `4`.
|
|
50
|
+
* Getting it wrong is an axe `heading-order` failure and a broken
|
|
51
|
+
* screen-reader outline.
|
|
52
|
+
*/
|
|
53
|
+
readonly headingLevel?: 2 | 3 | 4 | 5
|
|
54
|
+
/** The list's accessible name. Required — an unnamed sequence of
|
|
55
|
+
* numbers is a sequence of numbers. */
|
|
56
|
+
readonly label: string
|
|
57
|
+
/**
|
|
58
|
+
* `horizontal` is a REQUEST, not a promise: it collapses to
|
|
59
|
+
* vertical below `cq-md`, because four steps side by side in a
|
|
60
|
+
* narrow container squash into unreadable slivers (`2. / De- /
|
|
61
|
+
* dupe`, measured). A step carrying `content` should stay
|
|
62
|
+
* vertical — a column cannot hold a list.
|
|
63
|
+
*/
|
|
64
|
+
readonly orientation?: StepperOrientation
|
|
65
|
+
readonly steps: readonly Step[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An ordered sequence of steps, with what belongs to each one.
|
|
70
|
+
*
|
|
71
|
+
* ### Why the status is never colour alone
|
|
72
|
+
*
|
|
73
|
+
* Four states painted only as four marker colours is a WCAG 1.4.1
|
|
74
|
+
* failure however clear it looks, so each status reaches a screen
|
|
75
|
+
* reader as a word — `VisuallyHidden`, next to the label — and gets
|
|
76
|
+
* a second visual channel besides hue: a **filled** marker for done
|
|
77
|
+
* and current, an **outlined** one for upcoming and blocked, and a
|
|
78
|
+
* connector that is solid behind finished work and dashed ahead of
|
|
79
|
+
* it.
|
|
80
|
+
*
|
|
81
|
+
* ### Why the marker is a number and never a tick
|
|
82
|
+
*
|
|
83
|
+
* This library
|
|
84
|
+
* [ships no icons and no symbol glyphs](../../../../docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md).
|
|
85
|
+
* A `✓` renders as an empty box wherever the font lacks it — which
|
|
86
|
+
* includes the sandbox chromium, the kiosk image and the ePaper
|
|
87
|
+
* build — and an empty box in the "done" position reads as an error.
|
|
88
|
+
* The ordinal always renders. An app that owns a glyph set can pass
|
|
89
|
+
* one as `marker`.
|
|
90
|
+
*
|
|
91
|
+
* ### Why the connector is an element
|
|
92
|
+
*
|
|
93
|
+
* Not a `::before`. `styles.css` is reserved for the handful of
|
|
94
|
+
* visuals a Tailwind utility genuinely cannot express — the looping
|
|
95
|
+
* animations and the scrollbar — and a line between two markers is
|
|
96
|
+
* not one of them. A real `<span aria-hidden>` also stops the last
|
|
97
|
+
* step drawing a connector into empty space, which is a
|
|
98
|
+
* `:not(:last-child)` selector nobody remembers to write.
|
|
99
|
+
*/
|
|
100
|
+
export const Stepper = ({
|
|
101
|
+
className,
|
|
102
|
+
headingLevel = 3,
|
|
103
|
+
label,
|
|
104
|
+
orientation = "vertical",
|
|
105
|
+
steps,
|
|
106
|
+
}: StepperProps) => {
|
|
107
|
+
const Heading = `h${headingLevel}` as "h3"
|
|
108
|
+
const isHorizontal = orientation === "horizontal"
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
// The container is a WRAPPER, never the `<ol>` itself.
|
|
112
|
+
//
|
|
113
|
+
// An element cannot answer its own container query: declaring
|
|
114
|
+
// `container-type` on the list makes it the query container for
|
|
115
|
+
// its DESCENDANTS, and `cq-md:` on the list resolves against
|
|
116
|
+
// whatever container is further up — usually none. The first
|
|
117
|
+
// version of this component put both on the `<ol>`, and the
|
|
118
|
+
// result was a horizontal stepper whose CONNECTORS went
|
|
119
|
+
// horizontal (they are children, so they matched) while the
|
|
120
|
+
// list stayed a column. Valid CSS, no error, nothing for axe or
|
|
121
|
+
// a DOM test to say, and obvious the moment it was rendered.
|
|
122
|
+
// `Board` carries the same note for the same reason.
|
|
123
|
+
<div className={toClassName("@container", className)}>
|
|
124
|
+
<ol
|
|
125
|
+
aria-label={label}
|
|
126
|
+
className={toClassName(
|
|
127
|
+
"flex list-none flex-col gap-5 p-0",
|
|
128
|
+
// Horizontal is the WIDE answer only. Below `cq-md` this
|
|
129
|
+
// falls back to the column, which is the same rule
|
|
130
|
+
// `Board` follows and for the same reason: the owner
|
|
131
|
+
// browses zoomed in, so a window's width is not the
|
|
132
|
+
// number of pixels anything actually has.
|
|
133
|
+
isHorizontal && "cq-md:flex-row cq-md:gap-0",
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
{steps.map((step, index) => {
|
|
137
|
+
const status = step.status ?? "upcoming"
|
|
138
|
+
const isLast = index === steps.length - 1
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<li
|
|
142
|
+
className={toClassName(
|
|
143
|
+
"relative grid grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)] gap-x-3",
|
|
144
|
+
isHorizontal &&
|
|
145
|
+
"cq-md:flex cq-md:min-w-0 cq-md:flex-1 cq-md:flex-col cq-md:gap-y-2",
|
|
146
|
+
// Blocked and upcoming recede; the step you can act
|
|
147
|
+
// on is the one that keeps full contrast. Opacity
|
|
148
|
+
// rather than a muted token so the step's own
|
|
149
|
+
// contents dim with it — a task list at full strength
|
|
150
|
+
// under a dimmed heading reads as startable.
|
|
151
|
+
(status === "blocked" ||
|
|
152
|
+
status === "upcoming") &&
|
|
153
|
+
"opacity-70",
|
|
154
|
+
)}
|
|
155
|
+
key={step.key}
|
|
156
|
+
>
|
|
157
|
+
<span
|
|
158
|
+
className={toClassName(
|
|
159
|
+
"col-start-1 row-start-1 grid size-7 place-items-center rounded-full border text-sm tabular-nums",
|
|
160
|
+
STATUS_MARKER_CLASS[status],
|
|
161
|
+
isHorizontal && "cq-md:col-start-1",
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
{step.marker ?? index + 1}
|
|
165
|
+
</span>
|
|
166
|
+
|
|
167
|
+
{/* Absent on the last step, so nothing draws a line
|
|
168
|
+
into empty space. */}
|
|
169
|
+
{isLast ? null : (
|
|
170
|
+
<span
|
|
171
|
+
aria-hidden
|
|
172
|
+
className={toClassName(
|
|
173
|
+
"col-start-1 row-start-2 my-1 w-0.5 justify-self-center rounded-full",
|
|
174
|
+
// Solid behind finished work, dashed ahead of it
|
|
175
|
+
// — the second visual channel that keeps this
|
|
176
|
+
// from being colour alone.
|
|
177
|
+
status === "done"
|
|
178
|
+
? "bg-intent-success-solid"
|
|
179
|
+
: "bg-border-subtle",
|
|
180
|
+
isHorizontal &&
|
|
181
|
+
"cq-md:absolute cq-md:top-3.5 cq-md:start-7 cq-md:my-0 cq-md:h-0.5 cq-md:w-[calc(100%-1.75rem)]",
|
|
182
|
+
)}
|
|
183
|
+
/>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
<div
|
|
187
|
+
className={toClassName(
|
|
188
|
+
"col-start-2 row-span-2 row-start-1 min-w-0",
|
|
189
|
+
isHorizontal && "cq-md:col-start-1",
|
|
190
|
+
)}
|
|
191
|
+
>
|
|
192
|
+
<Heading className="m-0 flex flex-wrap items-center gap-2 text-md font-medium text-content-primary">
|
|
193
|
+
{step.label}
|
|
194
|
+
|
|
195
|
+
{/* The status as a WORD. Four marker colours is
|
|
196
|
+
four colours as far as assistive technology is
|
|
197
|
+
concerned, and this is the only channel that
|
|
198
|
+
survives a screen reader, a greyscale print and
|
|
199
|
+
the ePaper build at once. */}
|
|
200
|
+
<VisuallyHidden>
|
|
201
|
+
{STATUS_LABEL[status]}
|
|
202
|
+
</VisuallyHidden>
|
|
203
|
+
</Heading>
|
|
204
|
+
|
|
205
|
+
{step.description == null ? null : (
|
|
206
|
+
<p className="mt-0.5 mb-0 text-sm text-content-secondary">
|
|
207
|
+
{step.description}
|
|
208
|
+
</p>
|
|
209
|
+
)}
|
|
210
|
+
|
|
211
|
+
{step.content == null ? null : (
|
|
212
|
+
<div className="mt-2">{step.content}</div>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
</li>
|
|
216
|
+
)
|
|
217
|
+
})}
|
|
218
|
+
</ol>
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Written out in full, never interpolated. Tailwind v4 scans source
|
|
225
|
+
* text for COMPLETE class strings, so `` `bg-intent-${x}-solid` ``
|
|
226
|
+
* generates nothing at all and the element paints transparent with
|
|
227
|
+
* no error and no failing test —
|
|
228
|
+
* `tailwindCandidates.test.ts` is what makes this near-duplication
|
|
229
|
+
* safe to keep.
|
|
230
|
+
*/
|
|
231
|
+
const STATUS_MARKER_CLASS: Record<StepStatus, string> = {
|
|
232
|
+
blocked:
|
|
233
|
+
"border-intent-warning-border bg-intent-warning-surface text-intent-warning-content",
|
|
234
|
+
current:
|
|
235
|
+
"border-intent-accent-border bg-intent-accent-surface text-intent-accent-content",
|
|
236
|
+
done: "border-intent-success-border bg-intent-success-solid text-intent-success-on-solid",
|
|
237
|
+
upcoming:
|
|
238
|
+
"border-border-subtle bg-surface-raised text-content-secondary",
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const STATUS_LABEL: Record<StepStatus, string> = {
|
|
242
|
+
blocked: "Blocked",
|
|
243
|
+
current: "In progress",
|
|
244
|
+
done: "Done",
|
|
245
|
+
upcoming: "Not started",
|
|
246
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -328,6 +328,13 @@ export type {
|
|
|
328
328
|
export { SortableTableHeader } from "./SortableTableHeader/SortableTableHeader.tsx"
|
|
329
329
|
export type { SpinnerProps } from "./Spinner/Spinner.tsx"
|
|
330
330
|
export { Spinner } from "./Spinner/Spinner.tsx"
|
|
331
|
+
export type {
|
|
332
|
+
Step,
|
|
333
|
+
StepperOrientation,
|
|
334
|
+
StepperProps,
|
|
335
|
+
StepStatus,
|
|
336
|
+
} from "./Stepper/Stepper.tsx"
|
|
337
|
+
export { Stepper } from "./Stepper/Stepper.tsx"
|
|
331
338
|
export type {
|
|
332
339
|
SwatchAppearance,
|
|
333
340
|
SwatchProps,
|
|
@@ -98,6 +98,34 @@ blank line at the top and bottom of the block that reads as a rendering bug. A l
|
|
|
98
98
|
stays, because hiding `-` turns an unordered list into indented prose — Obsidian substitutes
|
|
99
99
|
a `•`, which means choosing a width, and this component has no business choosing one.
|
|
100
100
|
|
|
101
|
+
## Tables render, and the caret edits them
|
|
102
|
+
|
|
103
|
+
<Canvas of={MarkdownEditorCodeMirrorStories.Tables} />
|
|
104
|
+
|
|
105
|
+
A table is the one construct on this surface whose meaning is *geometry*. Every other
|
|
106
|
+
decoration hangs on text CodeMirror draws anyway; a column cannot, because column two's text
|
|
107
|
+
is on four separate lines. So the pipes stop being drawn and a real `<table>` is drawn
|
|
108
|
+
instead — alignment from the delimiter row, and `**bold**`, `` `code` ``, links and images
|
|
109
|
+
rendering inside the cells exactly as they do in a paragraph.
|
|
110
|
+
|
|
111
|
+
**Click a cell and the markdown comes back with the caret in that cell.** That is the same
|
|
112
|
+
trade as a link or an image, and it is the only way in: you cannot edit a column you cannot
|
|
113
|
+
see. Arrow keys work too — Down from the line above enters at the top row, Up from below
|
|
114
|
+
enters at the bottom — because CodeMirror otherwise steps *over* a block widget and the
|
|
115
|
+
table would be unreachable without a mouse.
|
|
116
|
+
|
|
117
|
+
Three decisions worth knowing:
|
|
118
|
+
|
|
119
|
+
- **The widest row wins, not the header.** GFM drops cells past the header's width. An
|
|
120
|
+
editor that did the same would put text you typed on screen nowhere, so short rows gain
|
|
121
|
+
empty cells instead and nothing is ever hidden.
|
|
122
|
+
- **An empty cell keeps its column.** The parser emits no cell node for `| |`; the `|`
|
|
123
|
+
positions are what the columns are read from, so a blank cell does not shift the row.
|
|
124
|
+
- **A wide table scrolls inside its own block** rather than pushing the editor sideways.
|
|
125
|
+
|
|
126
|
+
`\|` — the only way to put a pipe *in* a cell — now conceals its backslash like any other
|
|
127
|
+
marker, in tables and everywhere else.
|
|
128
|
+
|
|
101
129
|
## Clicking a link opens it
|
|
102
130
|
|
|
103
131
|
Obsidian's behaviour, not VS Code's: where the markup is hidden, a link that looks like a
|
|
@@ -47,6 +47,28 @@ ip -br addr show
|
|
|
47
47
|
Replacement rails ordered from https://example.invalid/product?id=1234
|
|
48
48
|
`
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Alignment from the delimiter row, inline markup inside cells, a
|
|
52
|
+
* deliberately blank cell, and a row that is one column short —
|
|
53
|
+
* every table decision in one fixture.
|
|
54
|
+
*/
|
|
55
|
+
const TABLES = `## Rack inventory
|
|
56
|
+
|
|
57
|
+
| Port | Goes to | **Speed** | Notes |
|
|
58
|
+
| :--- | :---: | ---: | --- |
|
|
59
|
+
| 1 | uplink | 10G | swapped \`2026-08-19\` |
|
|
60
|
+
| 2 | | 1G | see [the runbook](https://example.invalid/runbook) |
|
|
61
|
+
| 3 | shelf | 1G |
|
|
62
|
+
| 4 | ~~spare~~ | — | pulled \\| relabel |
|
|
63
|
+
|
|
64
|
+
A table with no outer pipes is still a table:
|
|
65
|
+
|
|
66
|
+
Disk | Size
|
|
67
|
+
--- | ---:
|
|
68
|
+
front-left | 18 TB
|
|
69
|
+
front-right | 18 TB
|
|
70
|
+
`
|
|
71
|
+
|
|
50
72
|
const meta = {
|
|
51
73
|
title: "Components/Controls/MarkdownEditorCodeMirror",
|
|
52
74
|
component: MarkdownEditorCodeMirror,
|
|
@@ -145,3 +167,23 @@ export const CaretReveal: Story = {
|
|
|
145
167
|
label: "Description",
|
|
146
168
|
},
|
|
147
169
|
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The construct whose meaning is geometry.
|
|
173
|
+
*
|
|
174
|
+
* Everything else on this surface is a decoration hung on text that
|
|
175
|
+
* is drawn anyway — a column is not, because column two's text is
|
|
176
|
+
* on four separate lines. So the pipes stop being drawn and a real
|
|
177
|
+
* table is drawn instead, alignment and all, with `**bold**` and
|
|
178
|
+
* links rendering inside the cells like anywhere else.
|
|
179
|
+
*
|
|
180
|
+
* Click a cell and the markdown comes back with the caret in that
|
|
181
|
+
* cell — the same trade the rest of the surface makes for a link or
|
|
182
|
+
* an image.
|
|
183
|
+
*/
|
|
184
|
+
export const Tables: Story = {
|
|
185
|
+
args: {
|
|
186
|
+
defaultValue: TABLES,
|
|
187
|
+
label: "Description",
|
|
188
|
+
},
|
|
189
|
+
}
|