@charcuterie/ui 3.26.0 → 3.28.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 +4 -2
- package/dist/Board/Board.d.ts +20 -7
- package/dist/Board/Board.d.ts.map +1 -1
- package/dist/Board/Board.js +16 -10
- package/dist/Board/Board.js.map +1 -1
- package/dist/Board/BoardCard.d.ts +18 -12
- package/dist/Board/BoardCard.d.ts.map +1 -1
- package/dist/Board/BoardCard.js +12 -12
- package/dist/Board/BoardCard.js.map +1 -1
- package/dist/Board/BoardLaneList.d.ts +2 -1
- package/dist/Board/BoardLaneList.d.ts.map +1 -1
- package/dist/Board/BoardLaneList.js +13 -4
- package/dist/Board/BoardLaneList.js.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.js +130 -74
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
- package/dist/Stepper/Stepper.d.ts +10 -1
- package/dist/Stepper/Stepper.d.ts.map +1 -1
- package/dist/Stepper/Stepper.js +25 -5
- package/dist/Stepper/Stepper.js.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +135 -85
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -1
- package/package.json +1 -1
- package/src/Board/Board.mdx +12 -0
- package/src/Board/Board.stories.tsx +48 -0
- package/src/Board/Board.test.tsx +43 -0
- package/src/Board/Board.tsx +34 -8
- package/src/Board/BoardCard.tsx +26 -16
- package/src/Board/BoardLaneList.tsx +35 -5
- package/src/MarkdownEditor/MarkdownEditor.mdx +32 -4
- package/src/MarkdownEditor/MarkdownEditor.test.tsx +108 -0
- package/src/MarkdownEditor/MarkdownEditor.tsx +89 -0
- package/src/Stepper/Stepper.stories.tsx +27 -0
- package/src/Stepper/Stepper.test.tsx +17 -2
- package/src/Stepper/Stepper.tsx +51 -8
- package/src/icons.storyHelpers.tsx +11 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +3 -1
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +31 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.test.tsx +109 -1
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +84 -0
|
@@ -89,6 +89,7 @@ export type BoardLaneListProps = {
|
|
|
89
89
|
headingLevel: 2 | 3 | 4 | 5
|
|
90
90
|
isVisibleWhenNarrow: boolean
|
|
91
91
|
lane: BoardLane
|
|
92
|
+
laneLayout: "columns" | "rows"
|
|
92
93
|
moveIcon?: ReactNode
|
|
93
94
|
moveTargets: readonly { key: string; label: string }[]
|
|
94
95
|
onMoveToLane?: (
|
|
@@ -144,6 +145,7 @@ export const BoardLaneList = ({
|
|
|
144
145
|
headingLevel,
|
|
145
146
|
isVisibleWhenNarrow,
|
|
146
147
|
lane,
|
|
148
|
+
laneLayout,
|
|
147
149
|
moveIcon,
|
|
148
150
|
moveTargets,
|
|
149
151
|
onMoveToLane,
|
|
@@ -189,7 +191,11 @@ export const BoardLaneList = ({
|
|
|
189
191
|
// queries the **board**, not the list below — an element
|
|
190
192
|
// carrying `container-type` still resolves its own container
|
|
191
193
|
// queries against its nearest ancestor container.
|
|
192
|
-
|
|
194
|
+
laneLayout === "rows"
|
|
195
|
+
? "flex"
|
|
196
|
+
: isVisibleWhenNarrow
|
|
197
|
+
? "flex"
|
|
198
|
+
: "hidden cq-lg:flex",
|
|
193
199
|
)}
|
|
194
200
|
>
|
|
195
201
|
{/*
|
|
@@ -201,8 +207,20 @@ export const BoardLaneList = ({
|
|
|
201
207
|
* treatment becomes expressible.
|
|
202
208
|
*/}
|
|
203
209
|
<div className="@container">
|
|
204
|
-
<div
|
|
205
|
-
|
|
210
|
+
<div
|
|
211
|
+
className={toClassName(
|
|
212
|
+
"gap-2 rounded-lg border border-border-subtle px-3 py-2",
|
|
213
|
+
laneLayout === "rows"
|
|
214
|
+
? "grid bg-surface-raised cq-lg:grid-cols-[12rem_minmax(0,1fr)] cq-lg:items-start cq-lg:gap-x-3"
|
|
215
|
+
: "flex flex-col bg-surface-raised cq-lg:border-0 cq-lg:bg-transparent cq-lg:p-0",
|
|
216
|
+
)}
|
|
217
|
+
>
|
|
218
|
+
<header
|
|
219
|
+
className={toClassName(
|
|
220
|
+
"flex items-center gap-2",
|
|
221
|
+
laneLayout === "rows" && "flex-wrap",
|
|
222
|
+
)}
|
|
223
|
+
>
|
|
206
224
|
<Heading
|
|
207
225
|
className="font-semibold text-content-primary text-sm"
|
|
208
226
|
id={headingId}
|
|
@@ -245,7 +263,12 @@ export const BoardLaneList = ({
|
|
|
245
263
|
</header>
|
|
246
264
|
|
|
247
265
|
<ul
|
|
248
|
-
className=
|
|
266
|
+
className={toClassName(
|
|
267
|
+
"relative",
|
|
268
|
+
laneLayout === "rows"
|
|
269
|
+
? "grid grid-cols-[repeat(auto-fit,minmax(min(100%,14rem),1fr))] gap-2"
|
|
270
|
+
: "flex flex-col",
|
|
271
|
+
)}
|
|
249
272
|
ref={registerListElement}
|
|
250
273
|
>
|
|
251
274
|
{lane.items.length === 0 ? (
|
|
@@ -266,6 +289,7 @@ export const BoardLaneList = ({
|
|
|
266
289
|
) : (
|
|
267
290
|
lane.items.map((item, index) => (
|
|
268
291
|
<BoardCard
|
|
292
|
+
isInGrid={laneLayout === "rows"}
|
|
269
293
|
item={item}
|
|
270
294
|
key={item.key}
|
|
271
295
|
laneLabel={lane.label}
|
|
@@ -304,7 +328,13 @@ export const BoardLaneList = ({
|
|
|
304
328
|
</ul>
|
|
305
329
|
|
|
306
330
|
{hiddenCount > 0 ? (
|
|
307
|
-
<p
|
|
331
|
+
<p
|
|
332
|
+
className={toClassName(
|
|
333
|
+
"text-content-muted text-xs",
|
|
334
|
+
laneLayout === "rows" &&
|
|
335
|
+
"cq-lg:col-start-2",
|
|
336
|
+
)}
|
|
337
|
+
>
|
|
308
338
|
{lane.onShowMore ? (
|
|
309
339
|
<Button
|
|
310
340
|
appearance="ghost"
|
|
@@ -114,7 +114,7 @@ A consumer that must swap the whole document — Docket opening a different task
|
|
|
114
114
|
with a `key`. That is one line, and it is honest about the fact that the old undo history
|
|
115
115
|
belonged to the old document.
|
|
116
116
|
|
|
117
|
-
##
|
|
117
|
+
## Put an image at the cursor — paste, drop, or press **Image**
|
|
118
118
|
|
|
119
119
|
```tsx
|
|
120
120
|
<MarkdownEditor
|
|
@@ -137,8 +137,35 @@ during an upload and the insertion point has to move with what they type. It car
|
|
|
137
137
|
counter so two files of the same name do not resolve into each other's slot, and a failed
|
|
138
138
|
upload leaves a document the user can read and fix rather than a dangling widget.
|
|
139
139
|
|
|
140
|
-
Omit `onUploadImage` and
|
|
141
|
-
image on its own, which is the correct
|
|
140
|
+
Omit `onUploadImage` and none of the three is offered — no toolbar button, and a paste left
|
|
141
|
+
to the browser. A `<textarea>` ignores a pasted image on its own, which is the correct
|
|
142
|
+
fallback rather than a broken one.
|
|
143
|
+
|
|
144
|
+
### The **Image** button, and why it is in the toolbar
|
|
145
|
+
|
|
146
|
+
Paste and drop are both mouse-and-keyboard gestures. On a tablet there is nothing to paste
|
|
147
|
+
from and nothing to drag, so wiring `onUploadImage` also puts an **Image** action in the
|
|
148
|
+
toolbar: it opens the platform file picker, which on a phone is where the camera is offered.
|
|
149
|
+
|
|
150
|
+
It is a toolbar action rather than a `FileDropZone` under the box **because of the caret**.
|
|
151
|
+
Pressing anything outside the editor moves focus out of it, so a control beside the editor
|
|
152
|
+
can only append to the end of the document — and an image in prose belongs at the point it
|
|
153
|
+
is explained, not stapled to the bottom. The `<textarea>` keeps its `selectionStart` while
|
|
154
|
+
blurred, so the insertion lands where the user last was.
|
|
155
|
+
|
|
156
|
+
**It is the fourth action, and the position is load-bearing.** `Toolbar` overflows from the
|
|
157
|
+
**end** of the list, so the order in that list is the priority order. Placed last — beside
|
|
158
|
+
Link, where it reads most naturally — Image was the first thing to collapse, hidden behind
|
|
159
|
+
"More actions" at 900px. A control nobody can find does not fix "adding an image is buried";
|
|
160
|
+
it moves the problem. Fourth is visible wherever four actions fit, and it reads as a group:
|
|
161
|
+
emphasis, structure, insert.
|
|
162
|
+
|
|
163
|
+
⚠️ **It still collapses on a genuinely narrow editor, and that is not a bug to route around.**
|
|
164
|
+
Measured on the `Interactive` story: at 900px the bar carries eight actions and Image is one
|
|
165
|
+
of them; at 460px it carries three, and everything after Heading is in the overflow. A
|
|
166
|
+
comment box in a rail is that width. Keep a `FileDropZone` beside such an editor as well —
|
|
167
|
+
appending to the end of the document is the right trade when the alternative is a control
|
|
168
|
+
nobody can reach.
|
|
142
169
|
|
|
143
170
|
## Composition, and the one component that did not fit
|
|
144
171
|
|
|
@@ -152,7 +179,8 @@ component.
|
|
|
152
179
|
`<label>` around a file input covering its whole area — correct for "drop a disc image here",
|
|
153
180
|
and wrong for a text surface, where the drop has to land *at a character offset* and the area
|
|
154
181
|
has to keep taking clicks as caret placement. So the editor handles its own drop, and
|
|
155
|
-
`FileDropZone` stays the right answer for the attachment list beside it
|
|
182
|
+
`FileDropZone` stays the right answer for the attachment list beside it — and for a
|
|
183
|
+
narrow editor whose toolbar has collapsed the Image action away.
|
|
156
184
|
|
|
157
185
|
<Canvas of={MarkdownEditorStories.Responsive} />
|
|
158
186
|
|
|
@@ -625,3 +625,111 @@ test("the hint stays described when the container is narrow", async () => {
|
|
|
625
625
|
|
|
626
626
|
await expectNoAxeViolations(canvasElement)
|
|
627
627
|
})
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* THE IMAGE BUTTON, and the reason it is in the toolbar rather
|
|
631
|
+
* than beside the editor.
|
|
632
|
+
*
|
|
633
|
+
* The editor already took a paste and a drop. Neither exists on a
|
|
634
|
+
* tablet, so the only way in there was a `FileDropZone` under the
|
|
635
|
+
* box — which appends to the END of the document, because pressing
|
|
636
|
+
* it moves focus out of the editor. An image in prose belongs at
|
|
637
|
+
* the point it is explained, so the control has to be one the
|
|
638
|
+
* editor owns.
|
|
639
|
+
*
|
|
640
|
+
* ⚠️ It is the FOURTH action, before the lists, because `Toolbar`
|
|
641
|
+
* overflows from the end and last put it behind "More actions" at
|
|
642
|
+
* 900px. It still collapses on a narrow enough surface, so a test
|
|
643
|
+
* cannot assume the button is on the bar.
|
|
644
|
+
*/
|
|
645
|
+
const openImageButton = async (
|
|
646
|
+
canvas: Canvas,
|
|
647
|
+
body: Canvas,
|
|
648
|
+
) => {
|
|
649
|
+
const onTheBar = canvas.queryByRole("button", {
|
|
650
|
+
name: "Image",
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
if (onTheBar) {
|
|
654
|
+
return onTheBar
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
await userEvent.click(
|
|
658
|
+
canvas.getByRole("button", { name: "More actions" }),
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
// The menu is PORTALLED, so it is on `body` and not inside the
|
|
662
|
+
// story's canvas.
|
|
663
|
+
return body.getByRole("menuitem", { name: "Image" })
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
test("the toolbar offers Image when uploads are wired", async () => {
|
|
667
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
668
|
+
|
|
669
|
+
await expect(
|
|
670
|
+
await openImageButton(canvas, body),
|
|
671
|
+
).not.toBeNull()
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
/** A button that opens a file picker and then has nowhere to send
|
|
675
|
+
* the file is worse than no button. */
|
|
676
|
+
test("the toolbar hides Image when nothing can take the file", async () => {
|
|
677
|
+
const { body, canvas } = await mountStory(NoUpload)
|
|
678
|
+
|
|
679
|
+
const overflow = canvas.queryByRole("button", {
|
|
680
|
+
name: "More actions",
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
if (overflow) {
|
|
684
|
+
await userEvent.click(overflow)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
await expect(
|
|
688
|
+
body.queryByRole("button", { name: "Image" }),
|
|
689
|
+
).toBeNull()
|
|
690
|
+
|
|
691
|
+
await expect(
|
|
692
|
+
body.queryByRole("menuitem", { name: "Image" }),
|
|
693
|
+
).toBeNull()
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
test("a picked file uploads and lands at the caret", async () => {
|
|
697
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
698
|
+
|
|
699
|
+
const editor = getEditor(canvas, "Task description")
|
|
700
|
+
|
|
701
|
+
await userEvent.click(editor)
|
|
702
|
+
|
|
703
|
+
editor.setSelectionRange(0, 0)
|
|
704
|
+
|
|
705
|
+
await userEvent.click(await openImageButton(canvas, body))
|
|
706
|
+
|
|
707
|
+
const picker = canvas
|
|
708
|
+
.getByRole("toolbar")
|
|
709
|
+
.parentElement?.querySelector(
|
|
710
|
+
'input[type="file"]',
|
|
711
|
+
) as HTMLInputElement
|
|
712
|
+
|
|
713
|
+
await expect(picker).not.toBeNull()
|
|
714
|
+
|
|
715
|
+
// The picker itself is the browser's; what this drives is the
|
|
716
|
+
// `change` it fires on the way back.
|
|
717
|
+
await userEvent.upload(
|
|
718
|
+
picker,
|
|
719
|
+
new File(["binary"], "jig.png", { type: "image/png" }),
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
await waitFor(() => {
|
|
723
|
+
expect(editor.value).toContain(
|
|
724
|
+
"",
|
|
725
|
+
)
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
// At the caret — offset 0 — not appended to the end.
|
|
729
|
+
await expect(editor.value.startsWith(").toBe(
|
|
730
|
+
true,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
// And it is still markdown, with no tag anywhere.
|
|
734
|
+
await expect(editor.value).not.toContain("<img")
|
|
735
|
+
})
|
|
@@ -63,6 +63,7 @@ export type MarkdownEditorIcons = Partial<
|
|
|
63
63
|
| "bulletedList"
|
|
64
64
|
| "code"
|
|
65
65
|
| "heading"
|
|
66
|
+
| "image"
|
|
66
67
|
| "italic"
|
|
67
68
|
| "link"
|
|
68
69
|
| "numberedList"
|
|
@@ -286,6 +287,12 @@ export const MarkdownEditor = ({
|
|
|
286
287
|
|
|
287
288
|
const layerRef = useRef<HTMLDivElement>(null)
|
|
288
289
|
|
|
290
|
+
/** The toolbar's Image button presses this. It is a real
|
|
291
|
+
* `<input type="file">` because nothing else opens a file
|
|
292
|
+
* picker, and it is the only way in on a touch device — there
|
|
293
|
+
* is nothing to paste from and nothing to drag. */
|
|
294
|
+
const imageInputRef = useRef<HTMLInputElement>(null)
|
|
295
|
+
|
|
289
296
|
const uploadCount = useRef(0)
|
|
290
297
|
|
|
291
298
|
const [value, setValue] = useState(defaultValue)
|
|
@@ -674,6 +681,54 @@ export const MarkdownEditor = ({
|
|
|
674
681
|
)
|
|
675
682
|
},
|
|
676
683
|
},
|
|
684
|
+
/*
|
|
685
|
+
IMAGE, and only when the consumer can actually take
|
|
686
|
+
one. A button that opens a file picker and then has
|
|
687
|
+
nowhere to send the file is worse than no button.
|
|
688
|
+
|
|
689
|
+
Spread rather than a `filter(Boolean)` so the array
|
|
690
|
+
stays a `ToolbarItem[]` without a cast.
|
|
691
|
+
|
|
692
|
+
⚠️ FOURTH, and the position is load-bearing rather
|
|
693
|
+
than aesthetic. `Toolbar` overflows from the END of
|
|
694
|
+
this list, so the order IS the priority order. Put
|
|
695
|
+
last, beside Link where it reads most naturally, and
|
|
696
|
+
Image was the first thing to collapse into "More
|
|
697
|
+
actions" — at 900px it was already hidden, and in a
|
|
698
|
+
comment box in a rail it never appeared at all. That
|
|
699
|
+
is the complaint this button exists to answer:
|
|
700
|
+
*"adding an image is way at the bottom"*. Hiding it
|
|
701
|
+
behind an overflow trigger moves the problem rather
|
|
702
|
+
than fixing it.
|
|
703
|
+
|
|
704
|
+
Fourth is visible wherever four actions fit — at
|
|
705
|
+
900px the bar carries eight. It still collapses on a
|
|
706
|
+
genuinely narrow editor (at 460px the bar carries
|
|
707
|
+
three), which is why a comment box in a rail keeps a
|
|
708
|
+
`FileDropZone` beside it. It also reads: emphasis,
|
|
709
|
+
then structure, then insert.
|
|
710
|
+
|
|
711
|
+
The caret is why this belongs in the toolbar at all
|
|
712
|
+
rather than beside the editor. A `FileDropZone` under
|
|
713
|
+
the box appends to the end of the document, because
|
|
714
|
+
pressing it moves focus out; the textarea keeps its
|
|
715
|
+
`selectionStart` while blurred, so the insertion
|
|
716
|
+
lands where the user last was — which is the whole
|
|
717
|
+
point of an image in prose.
|
|
718
|
+
*/
|
|
719
|
+
...(onUploadImage
|
|
720
|
+
? [
|
|
721
|
+
{
|
|
722
|
+
icon: icons?.image,
|
|
723
|
+
isDisabled: isDisabled || isReadOnly,
|
|
724
|
+
key: "image",
|
|
725
|
+
label: "Image",
|
|
726
|
+
onSelect: () => {
|
|
727
|
+
imageInputRef.current?.click()
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
]
|
|
731
|
+
: []),
|
|
677
732
|
{
|
|
678
733
|
icon: icons?.bulletedList,
|
|
679
734
|
isDisabled: isDisabled || isReadOnly,
|
|
@@ -755,6 +810,40 @@ export const MarkdownEditor = ({
|
|
|
755
810
|
overflow="menu"
|
|
756
811
|
overflowIcon={icons?.overflow}
|
|
757
812
|
/>
|
|
813
|
+
|
|
814
|
+
{/*
|
|
815
|
+
The Image button's actual control.
|
|
816
|
+
|
|
817
|
+
`hidden`, not `sr-only`: `display: none` takes it out of
|
|
818
|
+
the accessibility tree, which is what is wanted here —
|
|
819
|
+
the toolbar button is the named control, and a second
|
|
820
|
+
unlabelled file input announced beside it would be one
|
|
821
|
+
control too many. `.click()` on a hidden input opens the
|
|
822
|
+
picker in every browser the fleet runs.
|
|
823
|
+
|
|
824
|
+
`multiple`, because a drop already takes several and the
|
|
825
|
+
two ways in should not disagree. The value is cleared
|
|
826
|
+
after each pick so choosing the SAME file twice fires
|
|
827
|
+
`change` the second time.
|
|
828
|
+
*/}
|
|
829
|
+
{onUploadImage ? (
|
|
830
|
+
<input
|
|
831
|
+
accept="image/*"
|
|
832
|
+
aria-hidden="true"
|
|
833
|
+
className="hidden"
|
|
834
|
+
multiple
|
|
835
|
+
onChange={(changeEvent) => {
|
|
836
|
+
uploadImages(
|
|
837
|
+
Array.from(changeEvent.target.files ?? []),
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
changeEvent.target.value = ""
|
|
841
|
+
}}
|
|
842
|
+
ref={imageInputRef}
|
|
843
|
+
tabIndex={-1}
|
|
844
|
+
type="file"
|
|
845
|
+
/>
|
|
846
|
+
) : null}
|
|
758
847
|
</div>
|
|
759
848
|
|
|
760
849
|
<div className="grid">
|
|
@@ -170,6 +170,33 @@ export const Responsive: Story = {
|
|
|
170
170
|
),
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/** Short labels can opt out of the collapse when the sequence is a
|
|
174
|
+
* compact status ladder inside a card. */
|
|
175
|
+
export const CompactCard: Story = {
|
|
176
|
+
args: {
|
|
177
|
+
appearance: "compact",
|
|
178
|
+
isResponsive: false,
|
|
179
|
+
orientation: "horizontal",
|
|
180
|
+
steps: [
|
|
181
|
+
{ key: "ordered", label: "Ordered", status: "done" },
|
|
182
|
+
{ key: "shipped", label: "Shipped", status: "done" },
|
|
183
|
+
{ key: "out", label: "Out today", status: "current" },
|
|
184
|
+
{
|
|
185
|
+
key: "delivered",
|
|
186
|
+
label: "Delivered",
|
|
187
|
+
status: "upcoming",
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
render: (stepperProps) => (
|
|
192
|
+
<div className="w-[22rem] max-w-full">
|
|
193
|
+
<Card>
|
|
194
|
+
<Stepper {...stepperProps} />
|
|
195
|
+
</Card>
|
|
196
|
+
</div>
|
|
197
|
+
),
|
|
198
|
+
}
|
|
199
|
+
|
|
173
200
|
/**
|
|
174
201
|
* The whole keyboard path, which is the point of the headings: a
|
|
175
202
|
* stepper is a document outline, not a widget. There is nothing to
|
|
@@ -6,8 +6,13 @@ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
|
6
6
|
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
7
7
|
import * as stories from "./Stepper.stories.tsx"
|
|
8
8
|
|
|
9
|
-
const {
|
|
10
|
-
|
|
9
|
+
const {
|
|
10
|
+
AllStates,
|
|
11
|
+
CompactCard,
|
|
12
|
+
Default,
|
|
13
|
+
Interactive,
|
|
14
|
+
Responsive,
|
|
15
|
+
} = composeStories(stories)
|
|
11
16
|
|
|
12
17
|
test("the sequence is an ordered list with a name", async () => {
|
|
13
18
|
const { canvas } = await mountStory(Default)
|
|
@@ -127,3 +132,13 @@ test("no axe violations, in either orientation", async () => {
|
|
|
127
132
|
|
|
128
133
|
await expectNoAxeViolations(horizontal.canvasElement)
|
|
129
134
|
})
|
|
135
|
+
|
|
136
|
+
test("a compact card can keep the horizontal ladder below cq-md", async () => {
|
|
137
|
+
const { canvas } = await mountStory(CompactCard)
|
|
138
|
+
|
|
139
|
+
await expect(
|
|
140
|
+
getComputedStyle(
|
|
141
|
+
canvas.getByRole("list", { name: "Ingest sequence" }),
|
|
142
|
+
).flexDirection,
|
|
143
|
+
).toBe("row")
|
|
144
|
+
})
|
package/src/Stepper/Stepper.tsx
CHANGED
|
@@ -43,6 +43,8 @@ export type Step = {
|
|
|
43
43
|
export type StepperOrientation = "horizontal" | "vertical"
|
|
44
44
|
|
|
45
45
|
export type StepperProps = {
|
|
46
|
+
/** `compact` is the dot-and-line ladder for short status labels in cards. */
|
|
47
|
+
readonly appearance?: "default" | "compact"
|
|
46
48
|
readonly className?: string
|
|
47
49
|
/**
|
|
48
50
|
* What level the step labels are. The caller's, because only the
|
|
@@ -55,6 +57,13 @@ export type StepperProps = {
|
|
|
55
57
|
/** The list's accessible name. Required — an unnamed sequence of
|
|
56
58
|
* numbers is a sequence of numbers. */
|
|
57
59
|
readonly label: string
|
|
60
|
+
/**
|
|
61
|
+
* Whether a requested horizontal sequence collapses below `cq-md`.
|
|
62
|
+
* Keep this on for prose-sized steps. Turn it off only when the
|
|
63
|
+
* caller supplies short labels that are designed to remain a compact
|
|
64
|
+
* ladder inside a card.
|
|
65
|
+
*/
|
|
66
|
+
readonly isResponsive?: boolean
|
|
58
67
|
/**
|
|
59
68
|
* `horizontal` is a REQUEST, not a promise: it collapses to
|
|
60
69
|
* vertical below `cq-md`, because four steps side by side in a
|
|
@@ -99,14 +108,17 @@ export type StepperProps = {
|
|
|
99
108
|
* `:not(:last-child)` selector nobody remembers to write.
|
|
100
109
|
*/
|
|
101
110
|
export const Stepper = ({
|
|
111
|
+
appearance = "default",
|
|
102
112
|
className,
|
|
103
113
|
headingLevel = 3,
|
|
114
|
+
isResponsive = true,
|
|
104
115
|
label,
|
|
105
116
|
orientation = "vertical",
|
|
106
117
|
steps,
|
|
107
118
|
}: StepperProps) => {
|
|
108
119
|
const Heading = `h${headingLevel}` as "h3"
|
|
109
120
|
const isHorizontal = orientation === "horizontal"
|
|
121
|
+
const isCompact = appearance === "compact"
|
|
110
122
|
|
|
111
123
|
return (
|
|
112
124
|
// The container is a WRAPPER, never the `<ol>` itself.
|
|
@@ -131,7 +143,10 @@ export const Stepper = ({
|
|
|
131
143
|
// `Board` follows and for the same reason: the owner
|
|
132
144
|
// browses zoomed in, so a window's width is not the
|
|
133
145
|
// number of pixels anything actually has.
|
|
134
|
-
isHorizontal &&
|
|
146
|
+
isHorizontal &&
|
|
147
|
+
(isResponsive
|
|
148
|
+
? "cq-md:flex-row cq-md:gap-0"
|
|
149
|
+
: "flex-row gap-0"),
|
|
135
150
|
)}
|
|
136
151
|
>
|
|
137
152
|
{steps.map((step, index) => {
|
|
@@ -163,7 +178,10 @@ export const Stepper = ({
|
|
|
163
178
|
className={toClassName(
|
|
164
179
|
"relative grid grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)] gap-x-3",
|
|
165
180
|
isHorizontal &&
|
|
166
|
-
|
|
181
|
+
(isResponsive
|
|
182
|
+
? "cq-md:flex cq-md:min-w-0 cq-md:flex-1 cq-md:flex-col cq-md:gap-y-2"
|
|
183
|
+
: "flex min-w-0 flex-1 flex-col gap-y-2"),
|
|
184
|
+
isCompact && "gap-y-1",
|
|
167
185
|
// Blocked and upcoming recede; the step you can act
|
|
168
186
|
// on is the one that keeps full contrast. Opacity
|
|
169
187
|
// rather than a muted token so the step's own
|
|
@@ -177,12 +195,23 @@ export const Stepper = ({
|
|
|
177
195
|
>
|
|
178
196
|
<span
|
|
179
197
|
className={toClassName(
|
|
180
|
-
|
|
198
|
+
isCompact
|
|
199
|
+
? "col-start-1 row-start-1 grid size-3 place-items-center rounded-full border text-xs tabular-nums"
|
|
200
|
+
: "col-start-1 row-start-1 grid size-7 place-items-center rounded-full border text-sm tabular-nums",
|
|
181
201
|
STATUS_MARKER_CLASS[status],
|
|
182
|
-
isHorizontal &&
|
|
202
|
+
isHorizontal &&
|
|
203
|
+
(isResponsive
|
|
204
|
+
? "cq-md:col-start-1"
|
|
205
|
+
: "col-start-1"),
|
|
183
206
|
)}
|
|
184
207
|
>
|
|
185
|
-
{
|
|
208
|
+
{isCompact ? (
|
|
209
|
+
<VisuallyHidden>
|
|
210
|
+
{step.marker ?? index + 1}
|
|
211
|
+
</VisuallyHidden>
|
|
212
|
+
) : (
|
|
213
|
+
(step.marker ?? index + 1)
|
|
214
|
+
)}
|
|
186
215
|
</span>
|
|
187
216
|
|
|
188
217
|
{/* Absent on the last step, so nothing draws a line
|
|
@@ -199,7 +228,13 @@ export const Stepper = ({
|
|
|
199
228
|
? "bg-intent-success-solid"
|
|
200
229
|
: "bg-border-subtle",
|
|
201
230
|
isHorizontal &&
|
|
202
|
-
|
|
231
|
+
(isCompact
|
|
232
|
+
? isResponsive
|
|
233
|
+
? "cq-md:absolute cq-md:top-1.5 cq-md:start-3 cq-md:my-0 cq-md:h-0.5 cq-md:w-[calc(100%-0.75rem)]"
|
|
234
|
+
: "absolute top-1.5 start-3 my-0 h-0.5 w-[calc(100%-0.75rem)]"
|
|
235
|
+
: isResponsive
|
|
236
|
+
? "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)]"
|
|
237
|
+
: "absolute top-3.5 start-7 my-0 h-0.5 w-[calc(100%-1.75rem)]"),
|
|
203
238
|
)}
|
|
204
239
|
/>
|
|
205
240
|
)}
|
|
@@ -207,10 +242,18 @@ export const Stepper = ({
|
|
|
207
242
|
<div
|
|
208
243
|
className={toClassName(
|
|
209
244
|
"col-start-2 row-span-2 row-start-1 min-w-0",
|
|
210
|
-
isHorizontal &&
|
|
245
|
+
isHorizontal &&
|
|
246
|
+
(isResponsive
|
|
247
|
+
? "cq-md:col-start-1"
|
|
248
|
+
: "col-start-1"),
|
|
211
249
|
)}
|
|
212
250
|
>
|
|
213
|
-
<Heading
|
|
251
|
+
<Heading
|
|
252
|
+
className={toClassName(
|
|
253
|
+
"m-0 flex flex-wrap items-center gap-2 font-medium text-content-primary",
|
|
254
|
+
isCompact ? "text-xs" : "text-md",
|
|
255
|
+
)}
|
|
256
|
+
>
|
|
214
257
|
{step.label}
|
|
215
258
|
|
|
216
259
|
{/* The status as a WORD. Four marker colours is
|
|
@@ -295,6 +295,16 @@ export const LinkIcon = (): ReactNode => (
|
|
|
295
295
|
</svg>
|
|
296
296
|
)
|
|
297
297
|
|
|
298
|
+
export const ImageIcon = (): ReactNode => (
|
|
299
|
+
<svg {...iconProps} aria-hidden="true">
|
|
300
|
+
<rect height="16" rx="2" width="18" x="3" y="4" />
|
|
301
|
+
|
|
302
|
+
<path d="M3 15l5-5 4 4 3-3 6 6" />
|
|
303
|
+
|
|
304
|
+
<circle cx="8.5" cy="9" r="1.2" />
|
|
305
|
+
</svg>
|
|
306
|
+
)
|
|
307
|
+
|
|
298
308
|
/**
|
|
299
309
|
* The whole `MarkdownEditorIcons` bag, ready to spread into either
|
|
300
310
|
* editor's `icons` prop.
|
|
@@ -304,6 +314,7 @@ export const MARKDOWN_ICONS = {
|
|
|
304
314
|
bulletedList: <BulletedListIcon />,
|
|
305
315
|
code: <CodeIcon />,
|
|
306
316
|
heading: <HeadingIcon />,
|
|
317
|
+
image: <ImageIcon />,
|
|
307
318
|
italic: <ItalicIcon />,
|
|
308
319
|
link: <LinkIcon />,
|
|
309
320
|
numberedList: <NumberedListIcon />,
|
|
@@ -148,7 +148,9 @@ from either edge, or flip the Markdown source toggle, which is exactly what it i
|
|
|
148
148
|
|
|
149
149
|
## Pasting
|
|
150
150
|
|
|
151
|
-
- **An image**
|
|
151
|
+
- **An image** — pasted, dropped, or picked with the toolbar's **Image** action, which
|
|
152
|
+
appears only when `onUploadImage` is wired and is the only way in on a tablet — runs
|
|
153
|
+
`onUploadImage` and inserts `` at the caret, then
|
|
152
154
|
renders it in place. The component does not own storage — it hands you a `File`.
|
|
153
155
|
- **A URL over selected text** becomes `[selection](url)`, the way it does in every other
|
|
154
156
|
editor. Deliberately strict: one absolute `http(s)` URL and nothing else, because mangling
|
|
@@ -242,3 +242,34 @@ export const IconsVersusWords: Story = {
|
|
|
242
242
|
</StoryGrid>
|
|
243
243
|
),
|
|
244
244
|
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* With an upload wired, so the **Image** toolbar action appears.
|
|
248
|
+
*
|
|
249
|
+
* The editor already took a paste and a drop; neither exists on a
|
|
250
|
+
* tablet, and a `FileDropZone` beside the box appends to the end of
|
|
251
|
+
* the document because pressing it moves focus out. The toolbar
|
|
252
|
+
* button keeps the insertion at the caret, which is the whole point
|
|
253
|
+
* of an image in prose.
|
|
254
|
+
*
|
|
255
|
+
* The upload here is a fake that resolves after a beat, so the
|
|
256
|
+
* placeholder is visible while it runs. A real consumer returns a
|
|
257
|
+
* URL from its own blob storage.
|
|
258
|
+
*/
|
|
259
|
+
export const Uploading: Story = {
|
|
260
|
+
args: {
|
|
261
|
+
defaultValue:
|
|
262
|
+
"Press Image, or paste one, and it lands where the caret is.\n",
|
|
263
|
+
label: "Task description",
|
|
264
|
+
onUploadImage: async (file: File) => {
|
|
265
|
+
await new Promise((resolve) => {
|
|
266
|
+
setTimeout(resolve, 600)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
alt: file.name,
|
|
271
|
+
url: `https://example.invalid/blobs/${encodeURIComponent(file.name)}`,
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
}
|