@charcuterie/ui 3.5.0 → 3.7.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 (41) hide show
  1. package/dist/Checkbox/Checkbox.d.ts +39 -1
  2. package/dist/Checkbox/Checkbox.d.ts.map +1 -1
  3. package/dist/Checkbox/Checkbox.js +131 -71
  4. package/dist/Checkbox/Checkbox.js.map +1 -1
  5. package/dist/Field/Field.d.ts +67 -2
  6. package/dist/Field/Field.d.ts.map +1 -1
  7. package/dist/Field/Field.js +22 -0
  8. package/dist/Field/Field.js.map +1 -1
  9. package/dist/Field/FieldGroup.d.ts +28 -3
  10. package/dist/Field/FieldGroup.d.ts.map +1 -1
  11. package/dist/Field/FieldGroup.js +5 -2
  12. package/dist/Field/FieldGroup.js.map +1 -1
  13. package/dist/Stepper/Stepper.d.ts +89 -0
  14. package/dist/Stepper/Stepper.d.ts.map +1 -0
  15. package/dist/Stepper/Stepper.js +87 -0
  16. package/dist/Stepper/Stepper.js.map +1 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/slotProps.d.ts +14 -1
  22. package/dist/slotProps.d.ts.map +1 -1
  23. package/dist/slotProps.js +13 -0
  24. package/dist/slotProps.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/Checkbox/Checkbox.mdx +32 -0
  27. package/src/Checkbox/Checkbox.stories.tsx +74 -0
  28. package/src/Checkbox/Checkbox.test.tsx +110 -2
  29. package/src/Checkbox/Checkbox.tsx +234 -125
  30. package/src/Field/Field.mdx +41 -0
  31. package/src/Field/Field.stories.tsx +79 -0
  32. package/src/Field/Field.test.tsx +137 -0
  33. package/src/Field/Field.tsx +99 -25
  34. package/src/Field/FieldGroup.tsx +60 -19
  35. package/src/Stepper/Stepper.mdx +86 -0
  36. package/src/Stepper/Stepper.stories.tsx +224 -0
  37. package/src/Stepper/Stepper.test.tsx +112 -0
  38. package/src/Stepper/Stepper.tsx +246 -0
  39. package/src/index.ts +7 -0
  40. package/src/slotProps.ts +16 -2
  41. 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,
package/src/slotProps.ts CHANGED
@@ -127,13 +127,27 @@ export type SlotProps = Pick<
127
127
  * inner one won. The outer panel had no anchor and rendered in the
128
128
  * corner of the viewport — no error, no failing test, and it looks
129
129
  * like a CSS bug.
130
+ *
131
+ * ### A wrapping component uses it too, and keeps its own prop type
132
+ *
133
+ * `receivedProps` is generic in `ReceivedProps extends SlotProps`
134
+ * rather than plain `SlotProps`, so the return type carries whatever
135
+ * the caller actually holds instead of narrowing it to the five keys.
136
+ * `FieldGroup` is the reason: it does not clone, its rest props land
137
+ * on its own `<fieldset>`, and it writes an `aria-describedby` that
138
+ * has to **join** a caller's rather than replace it. Narrowing to
139
+ * `SlotProps` on the way out would have dropped `disabled`, `form`,
140
+ * `name` and every `data-*` from the type of the object it spreads
141
+ * onto that element — the props would still arrive at runtime, and
142
+ * only the type would have lied.
130
143
  */
131
144
  export const mergeSlotProps = <
145
+ ReceivedProps extends SlotProps,
132
146
  OwnProps extends Record<string, unknown>,
133
147
  >(
134
- receivedProps: SlotProps,
148
+ receivedProps: ReceivedProps,
135
149
  ownProps: OwnProps,
136
- ): OwnProps & SlotProps => {
150
+ ): OwnProps & ReceivedProps => {
137
151
  const definedOwnProps = Object.fromEntries(
138
152
  Object.entries(ownProps).filter(
139
153
  ([, value]) => value !== undefined,
@@ -236,6 +236,13 @@ test("a container-query component is never storied in a shrink-to-fit cell", ()
236
236
  "Main",
237
237
  "MarkdownEditor",
238
238
  "MediaTile",
239
+ // `Stepper`'s container is the one that decides whether a
240
+ // `horizontal` sequence is actually horizontal. Below `--cq-md`
241
+ // it falls back to the vertical column, because four steps side
242
+ // by side in a narrow container squash into `2. / De- / dupe` —
243
+ // measured in Docket, before any of the steps contained
244
+ // anything.
245
+ "Stepper",
239
246
  ])
240
247
 
241
248
  // `StoryCell`s are never nested, so the lazy match really does
@@ -430,7 +437,7 @@ test("the barrel is the only place components are re-exported", async () => {
430
437
  // `useBoardDrag.ts` are its members and stay out of this count and
431
438
  // the barrel by the `<Name>/<Name>.tsx` rule, exactly as
432
439
  // `ToolbarSlot` and `QueryBuilderRow` do.
433
- expect(componentNames.length).toBe(49)
440
+ expect(componentNames.length).toBe(50)
434
441
 
435
442
  for (const name of componentNames) {
436
443
  expect(barrel).toContain(`export { ${name} }`)