@charcuterie/ui 3.6.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.
@@ -78,6 +78,47 @@ floating node with no trigger and no anchor.
78
78
 
79
79
  <Canvas of={FieldStories.Nested} />
80
80
 
81
+ ## Where a prop lands
82
+
83
+ `FieldProps` and `FieldGroupProps` were **closed** types until 2026-08-21: the only prop a
84
+ caller could legally pass was `className`. That is the sharpest argument for opening them —
85
+ a component whose whole purpose is *configured by props, not a borrowed class* accepted only
86
+ the escape hatch that rule exists to close, so it forced the very thing the rule forbids.
87
+ queuepilot paid for it twice in one modal: a `FieldGroup` that gave up the `id` its
88
+ screenshot suite drives by, and a box beside it that stayed a hand-rolled `<fieldset>`
89
+ because it needed `hidden`.
90
+
91
+ The rule that opens them is one sentence — **a component's props land on the element it
92
+ owns** — and it reads differently in each component because they own different elements:
93
+
94
+ | | `Field` | `FieldGroup` |
95
+ | --- | --- | --- |
96
+ | Renders | `<div>` around a **cloned** child | a `<fieldset>` around children rendered as-is |
97
+ | Rest props land on | the **control** | the **`<fieldset>`** |
98
+ | `className` lands on | the `<div>` | the `<fieldset>` |
99
+ | So you can pass | `name`, `placeholder`, `autoFocus`, `aria-*`, `ref` | `id`, `hidden`, `ref`, and `disabled` / `form` / `name` |
100
+
101
+ `Field`'s destination is not a new one, it is the existing one finally declared: the runtime
102
+ already spread everything it received into the clone, and `id` has named the control since
103
+ the component shipped. Routing the rest to the `<div>` would have moved that `id`, silently,
104
+ in every consumer.
105
+
106
+ It also could not have been done safely. A slot is a pass-through and **cannot tell who
107
+ wrote a prop** — a `Tooltip` above hands down `getReferenceProps()`, and the author types on
108
+ the same object. Holding the author's props back at the `<div>` needs a closed runtime
109
+ allow-list, and an allow-list is `FieldTooltip`'s silent drop rebuilt inside the fix for it:
110
+ the first slot that writes an `aria-expanded` breaks, and nothing says so.
111
+
112
+ So a caller who wants a handle on the **box** — an `id` for a screenshot, a `hidden` that
113
+ takes the label with it — wants `FieldGroup`. It wraps rather than clones, so its props have
114
+ nowhere else to go.
115
+
116
+ `disabled` on a `<fieldset>` is worth knowing before reaching for it: the platform disables
117
+ every control inside the element, not just the box. That is usually what a caller means, and
118
+ it is something a `<div>`-based group could not have offered at all.
119
+
120
+ <Canvas of={FieldStories.ForwardsRestProps} />
121
+
81
122
  ## Several controls are a `FieldGroup`, not a `Field`
82
123
 
83
124
  `Field` mints one id and clones it onto one child. An `id` names one element and a
@@ -329,3 +329,82 @@ export const Group: Story = {
329
329
  </StoryGrid>
330
330
  ),
331
331
  }
332
+
333
+ /**
334
+ * Where a prop lands, in both components, side by side.
335
+ *
336
+ * `FieldProps` and `FieldGroupProps` were **closed** types until
337
+ * 2026-08-21: the only prop a caller could legally pass was
338
+ * `className`, so a component whose whole purpose is "configured by
339
+ * props, not a borrowed class" forced the borrowed class. queuepilot's
340
+ * `#dyn-lineup` gave up an `id` its screenshot suite drives by, and
341
+ * the box beside it stayed a hand-rolled `<fieldset>` because it
342
+ * needed `hidden`.
343
+ *
344
+ * The rule that opens them is one sentence with two readings, because
345
+ * the two components own different elements:
346
+ *
347
+ * - **`Field` clones its child**, so its props are that **control's**
348
+ * props. `name`, `placeholder`, `autoFocus`, `aria-describedby` and
349
+ * `id` all reach the `<input>` — `id` always did, which is why the
350
+ * rest go with it rather than moving it.
351
+ * - **`FieldGroup` wraps its children**, so its props are the
352
+ * **`<fieldset>`'s**. `id`, `hidden`, `ref`, and the element's own
353
+ * `disabled`, `form` and `name`.
354
+ *
355
+ * `className` is the exception on `Field` alone: a class describes the
356
+ * box a parent lays out, so it stays on the wrapping `<div>`.
357
+ */
358
+ export const ForwardsRestProps: Story = {
359
+ args: {
360
+ children: (
361
+ <input className={TEXT_INPUT_CLASS} type="text" />
362
+ ),
363
+ label: "Output directory",
364
+ },
365
+ render: () => (
366
+ <StoryGrid columns={2}>
367
+ <StoryCell label="field: the props are the control's">
368
+ <Field
369
+ // Composed with the field's own description and error, not
370
+ // replaced by them — `aria-describedby` is the one prop in
371
+ // the set that is a list.
372
+ aria-describedby="scratch-path-policy"
373
+ description="Absolute paths only."
374
+ error="That path is not writable."
375
+ label="Scratch path"
376
+ name="scratchPath"
377
+ placeholder="/mnt/scratch"
378
+ >
379
+ <input className={TEXT_INPUT_CLASS} type="text" />
380
+ </Field>
381
+
382
+ <p
383
+ className="mt-1.5 text-content-secondary text-sm"
384
+ id="scratch-path-policy"
385
+ >
386
+ Cleared between runs.
387
+ </p>
388
+ </StoryCell>
389
+
390
+ <StoryCell label="group: the props are the fieldset's">
391
+ <FieldGroup
392
+ description="The whole group is turned off by the element itself."
393
+ // `disabled` on a `<fieldset>` is not decoration: the
394
+ // platform disables every control inside it. A `<div>`-based
395
+ // group could not have offered this at all.
396
+ disabled
397
+ id="playback-lineup"
398
+ label="Playback"
399
+ >
400
+ <input
401
+ aria-label="Episodes"
402
+ className={TEXT_INPUT_CLASS}
403
+ defaultValue="12"
404
+ type="number"
405
+ />
406
+ </FieldGroup>
407
+ </StoryCell>
408
+ </StoryGrid>
409
+ ),
410
+ }
@@ -13,6 +13,7 @@ const {
13
13
  AllStates,
14
14
  AllVariants,
15
15
  Default,
16
+ ForwardsRestProps,
16
17
  Group,
17
18
  Nested,
18
19
  } = composeStories(stories)
@@ -447,3 +448,139 @@ test("a group describes its error rather than asserting invalidity", async () =>
447
448
  "A pattern needs at least one capture group.",
448
449
  )
449
450
  })
451
+
452
+ /**
453
+ * The gap this closes, stated as the two things that were impossible.
454
+ *
455
+ * `FieldProps` and `FieldGroupProps` were closed types, so the only
456
+ * prop that reached an element was `className` — which is the escape
457
+ * hatch the "configured by props, not a borrowed class" rule exists to
458
+ * close. A component that accepts only `className` forces the very
459
+ * thing the rule forbids, and queuepilot paid for it twice: a
460
+ * `FieldGroup` that had to give up the `id` its screenshot suite
461
+ * drives by, and a box beside it that stayed a hand-rolled
462
+ * `<fieldset>` because it needed `hidden`.
463
+ *
464
+ * `Field` clones its child, so its props are the **control's**. The
465
+ * assertion is which element they landed on, because that is the half
466
+ * that could have gone silently wrong: routing them to the wrapper
467
+ * would have moved every consumer's `id` off the control the
468
+ * `<label htmlFor>` points at.
469
+ */
470
+ test("a rest prop reaches the control, not the box around it", async () => {
471
+ const { canvas, canvasElement } = await mountStory(
472
+ ForwardsRestProps,
473
+ )
474
+
475
+ const control = expectAgentDrivable(canvas, {
476
+ name: "Scratch path",
477
+ role: "textbox",
478
+ })
479
+
480
+ await expect(control).toHaveAttribute(
481
+ "name",
482
+ "scratchPath",
483
+ )
484
+
485
+ await expect(control).toHaveAttribute(
486
+ "placeholder",
487
+ "/mnt/scratch",
488
+ )
489
+
490
+ // …and the `<div>` did not take them. `Field`'s own `className` is
491
+ // the one prop that stays on the box, so the two destinations have
492
+ // to be distinguishable in the DOM rather than merely documented.
493
+ await expect(
494
+ canvasElement.querySelector("[name='scratchPath']"),
495
+ ).toBe(control)
496
+
497
+ await expectNoAxeViolations(canvasElement)
498
+ })
499
+
500
+ /**
501
+ * `aria-describedby` is a **list**, and opening the type is what makes
502
+ * a caller able to write one at all. A plain spread would keep one
503
+ * side and drop the other — the same silent drop `mergeSlotProps`
504
+ * exists to prevent between two nested slots, arriving by a shorter
505
+ * route now that the outer writer can be the author.
506
+ *
507
+ * Order is behaviour, not formatting: what the caller passed is the
508
+ * outer declaration and reads first, then the field's own description,
509
+ * then its error.
510
+ */
511
+ test("a caller's aria-describedby composes with the description and the error", async () => {
512
+ const { canvas } = await mountStory(ForwardsRestProps)
513
+
514
+ const control = expectAgentDrivable(canvas, {
515
+ name: "Scratch path",
516
+ role: "textbox",
517
+ })
518
+
519
+ const [policyId, descriptionId, errorId] = (
520
+ control.getAttribute("aria-describedby") ?? ""
521
+ ).split(" ")
522
+
523
+ await expect(
524
+ document.getElementById(policyId ?? ""),
525
+ ).toHaveTextContent("Cleared between runs.")
526
+
527
+ await expect(
528
+ document.getElementById(descriptionId ?? ""),
529
+ ).toHaveTextContent("Absolute paths only.")
530
+
531
+ await expect(
532
+ document.getElementById(errorId ?? ""),
533
+ ).toHaveTextContent("That path is not writable.")
534
+
535
+ // The error is still the only source of invalidity, and a caller's
536
+ // own description did not disturb it.
537
+ await expect(control).toHaveAttribute(
538
+ "aria-invalid",
539
+ "true",
540
+ )
541
+ })
542
+
543
+ /**
544
+ * The other reading of the same rule. `FieldGroup` renders its
545
+ * children as-is, so the only element it owns is the `<fieldset>` and
546
+ * a prop has nowhere else to go — including `disabled`, which on this
547
+ * element is not decoration: the platform disables every control
548
+ * inside it.
549
+ *
550
+ * This is the case queuepilot's `#dyn-lineup` needed and could not
551
+ * have: an `id` on the group's own element.
552
+ */
553
+ test("a rest prop reaches the fieldset, and the fieldset disables what is inside it", async () => {
554
+ const { canvas, canvasElement } = await mountStory(
555
+ ForwardsRestProps,
556
+ )
557
+
558
+ const group = expectAgentDrivable(canvas, {
559
+ name: "Playback",
560
+ role: "group",
561
+ })
562
+
563
+ await expect(group).toHaveAttribute(
564
+ "id",
565
+ "playback-lineup",
566
+ )
567
+
568
+ await expect(group.tagName).toBe("FIELDSET")
569
+
570
+ // The `<legend>` still names the group, and the group's own
571
+ // description still reaches it, so the rest props did not overwrite
572
+ // the wiring they were spread beside.
573
+ await expect(
574
+ document.getElementById(
575
+ group.getAttribute("aria-describedby") ?? "",
576
+ ),
577
+ ).toHaveTextContent(
578
+ "The whole group is turned off by the element itself.",
579
+ )
580
+
581
+ await expect(
582
+ canvasElement.querySelector("input[type='number']"),
583
+ ).toBeDisabled()
584
+
585
+ await expectNoAxeViolations(canvasElement)
586
+ })
@@ -2,36 +2,88 @@ import {
2
2
  useClonedChild,
3
3
  useUniqueId,
4
4
  } from "@charcuterie/logic"
5
- import type { ReactElement, ReactNode } from "react"
5
+ import type {
6
+ ComponentPropsWithRef,
7
+ ReactElement,
8
+ ReactNode,
9
+ Ref,
10
+ } from "react"
6
11
 
7
12
  import type { SlotProps } from "../slotProps.ts"
8
13
  import { mergeSlotProps } from "../slotProps.ts"
9
14
  import { toClassName } from "../toClassName.ts"
10
15
 
11
- export type FieldProps = SlotProps & {
12
- /**
13
- * The control. **Cloned, not wrapped** — the same slot contract
14
- * as `Popover`'s trigger, so a `Field` can go around an `<input>`,
15
- * a `<textarea>`, a `Picker`, or an app's own control without
16
- * changing that control's layout.
17
- */
18
- children: ReactElement
19
- className?: string
20
- /**
21
- * Standing help — units, a format, a warning about what the value
22
- * does. Announced with the control, before any error.
23
- */
24
- description?: ReactNode
25
- /**
26
- * Present means invalid. There is no separate `isInvalid`, because
27
- * two sources for one fact is how a control ends up
28
- * `aria-invalid="true"` with nothing saying why — which is what
29
- * `RegexWithFlagsField` does in mux-magic today.
30
- */
31
- error?: ReactNode
32
- isRequired?: boolean
33
- label: ReactNode
34
- }
16
+ /**
17
+ * Everything else a caller writes on a `Field` reaches **the control
18
+ * it clones onto**, not the `<div>` around it.
19
+ *
20
+ * That is not a new destination, it is the existing one finally
21
+ * declared. `SlotProps` was a *closed* five-key type over a runtime
22
+ * that already spread `...receivedSlotProps` into the clone, so
23
+ * `name`, `placeholder`, `autoFocus`, `onBlur` and every `aria-*` all
24
+ * arrived at the `<input>` and none of them type-checked. The only
25
+ * prop a consumer could legally pass was `className` — which is the
26
+ * one escape hatch the fleet's "configured by props, not a borrowed
27
+ * class" rule is trying to close, so the closed type forced the very
28
+ * thing the rule forbids
29
+ * ([decision](../../../../docs/decisions/2026-08-21-a-slot-components-rest-props-are-the-controls-props.md)).
30
+ *
31
+ * `className` stays on the `<div>`, alone, because a class is a
32
+ * statement about the box a **parent** lays out
33
+ * ([decision](../../../../docs/decisions/2026-08-19-classname-is-the-outermost-box-a-component-renders.md)).
34
+ * Every other prop is a statement about the control, and `id` is the
35
+ * proof: it has named the control since the component shipped, and a
36
+ * dated record settles its precedence
37
+ * ([decision](../../../../docs/decisions/2026-08-05-field-adopts-the-childs-own-id.md)).
38
+ * Routing the rest to the `<div>` would have moved it, silently, in
39
+ * every consumer.
40
+ *
41
+ * `"input"` because `SlotProps` already picks from
42
+ * `InputHTMLAttributes` and this is the same set widened, not a
43
+ * second opinion about what a control is. `ref` is re-declared over
44
+ * `HTMLElement`: the child may be a `Picker`'s `<button>` or a
45
+ * `contenteditable` `<div>`, so `Ref<HTMLInputElement>` would be a
46
+ * narrower claim than the slot makes.
47
+ */
48
+ type ForwardedControlProps = Omit<
49
+ ComponentPropsWithRef<"input">,
50
+ "children" | "className" | "ref"
51
+ >
52
+
53
+ export type FieldProps = ForwardedControlProps &
54
+ SlotProps & {
55
+ /**
56
+ * The control. **Cloned, not wrapped** — the same slot contract
57
+ * as `Popover`'s trigger, so a `Field` can go around an `<input>`,
58
+ * a `<textarea>`, a `Picker`, or an app's own control without
59
+ * changing that control's layout.
60
+ */
61
+ children: ReactElement
62
+ /**
63
+ * The `<div>` around the label, the control and the messages —
64
+ * the one prop that does **not** reach the control.
65
+ */
66
+ className?: string
67
+ /**
68
+ * Standing help — units, a format, a warning about what the value
69
+ * does. Announced with the control, before any error.
70
+ */
71
+ description?: ReactNode
72
+ /**
73
+ * Present means invalid. There is no separate `isInvalid`, because
74
+ * two sources for one fact is how a control ends up
75
+ * `aria-invalid="true"` with nothing saying why — which is what
76
+ * `RegexWithFlagsField` does in mux-magic today.
77
+ */
78
+ error?: ReactNode
79
+ isRequired?: boolean
80
+ label: ReactNode
81
+ /**
82
+ * Merged onto the control's own, never replacing it — the wiring
83
+ * rule `mergeSlotWiring` states for every slot.
84
+ */
85
+ ref?: Ref<HTMLElement>
86
+ }
35
87
 
36
88
  /**
37
89
  * Sixteen files in the fleet spell a label, a control, and a hint,
@@ -108,6 +160,28 @@ export type FieldProps = SlotProps & {
108
160
  * `SlotProps` arriving from an outer slot is forwarded down, and
109
161
  * `aria-describedby` is merged rather than replaced.
110
162
  * `slotProps.ts` has the reasoning.
163
+ *
164
+ * ### …and a pass-through cannot tell who wrote the prop
165
+ *
166
+ * Which is why the rest props are the **control's** props and not the
167
+ * `<div>`'s. `receivedSlotProps` below is one object holding two
168
+ * things that are indistinguishable at runtime: what a cloning
169
+ * ancestor injected, and what the author typed on the `Field` itself.
170
+ * A `Tooltip` above hands down `getReferenceProps()` — hover, focus
171
+ * and dismiss handlers, `refs.setReference`, and an
172
+ * `aria-describedby` naming the tip — and every bit of it has to
173
+ * reach the `<input>` at the bottom. Sending the rest to the wrapper
174
+ * would need a closed runtime allow-list to hold that case back, and
175
+ * an allow-list is `FieldTooltip`'s silent drop rebuilt inside the
176
+ * fix for it: the first slot that writes an `aria-expanded` is broken
177
+ * and nothing says so.
178
+ *
179
+ * So there is one rule, and it has no exceptions to remember:
180
+ * `className` is the box, everything else is the control. A caller
181
+ * that needs a handle on the **box** — an `id` for a screenshot, a
182
+ * `hidden` that takes the label with it — wants `FieldGroup`, which
183
+ * wraps rather than clones and whose rest props land on its own
184
+ * `<fieldset>`.
111
185
  */
112
186
  export const Field = ({
113
187
  children,
@@ -1,23 +1,53 @@
1
1
  import { useUniqueId } from "@charcuterie/logic"
2
- import type { ReactNode } from "react"
2
+ import type {
3
+ ComponentPropsWithRef,
4
+ ReactNode,
5
+ } from "react"
3
6
 
7
+ import { mergeSlotProps } from "../slotProps.ts"
4
8
  import { toClassName } from "../toClassName.ts"
5
9
 
6
- export type FieldGroupProps = {
7
- /**
8
- * The controls. **Rendered as-is, not cloned** — that is the whole
9
- * difference from `Field`, and it is why this is a separate
10
- * component rather than a boolean on that one.
11
- */
12
- children: ReactNode
13
- className?: string
14
- /** Standing help for the group as a whole. */
15
- description?: ReactNode
16
- /** Present means the group is invalid. */
17
- error?: ReactNode
18
- isRequired?: boolean
19
- label: ReactNode
20
- }
10
+ /**
11
+ * Everything a caller writes on a `FieldGroup` reaches the
12
+ * `<fieldset>` — `id`, `hidden`, `data-*`, `aria-*`, `ref`, and the
13
+ * three attributes that are the element's own: `disabled`, `form`
14
+ * and `name`.
15
+ *
16
+ * This is the **opposite** destination from `Field`'s, and the
17
+ * difference is not a preference. `Field` clones onto its child, so
18
+ * its props are that control's props; `FieldGroup` renders its
19
+ * children as-is, so the only element it owns is the `<fieldset>` and
20
+ * a prop has nowhere else to go. One rule reads both:
21
+ * ***a component's props land on the element it owns***
22
+ * ([decision](../../../../docs/decisions/2026-08-21-a-slot-components-rest-props-are-the-controls-props.md)).
23
+ *
24
+ * `disabled` is worth knowing about before reaching for it. On a
25
+ * `<fieldset>` it is not decoration — the platform disables every
26
+ * form control inside the element, not just the box itself. That is
27
+ * usually what a caller wants and occasionally a surprise, and it is
28
+ * a thing a `<div>`-based group could not have offered at all.
29
+ */
30
+ export type FieldGroupProps =
31
+ ComponentPropsWithRef<"fieldset"> & {
32
+ /**
33
+ * The controls. **Rendered as-is, not cloned** — that is the whole
34
+ * difference from `Field`, and it is why this is a separate
35
+ * component rather than a boolean on that one.
36
+ */
37
+ children: ReactNode
38
+ /**
39
+ * The `<fieldset>`, which is also the outermost element this
40
+ * component renders — so `className` is not the exception here that
41
+ * it is on `Field`.
42
+ */
43
+ className?: string
44
+ /** Standing help for the group as a whole. */
45
+ description?: ReactNode
46
+ /** Present means the group is invalid. */
47
+ error?: ReactNode
48
+ isRequired?: boolean
49
+ label: ReactNode
50
+ }
21
51
 
22
52
  /**
23
53
  * A label over **several** controls.
@@ -66,6 +96,7 @@ export const FieldGroup = ({
66
96
  error,
67
97
  isRequired = false,
68
98
  label,
99
+ ...receivedFieldsetProps
69
100
  }: FieldGroupProps): ReactNode => {
70
101
  const baseId = useUniqueId()
71
102
 
@@ -85,9 +116,19 @@ export const FieldGroup = ({
85
116
 
86
117
  return (
87
118
  <fieldset
88
- aria-describedby={
89
- describedBy === "" ? undefined : describedBy
90
- }
119
+ /*
120
+ Through `mergeSlotProps`, not around it. A caller may now pass
121
+ an `aria-describedby` of its own, and that attribute is the
122
+ one prop in the set that is a **list** — a plain spread keeps
123
+ whichever side is written last and drops the other, silently,
124
+ which is the defect `mergeSlotProps` exists to prevent one
125
+ level down. Received first, then this group's description and
126
+ error, for the reason the ordering array above gives.
127
+ */
128
+ {...mergeSlotProps(receivedFieldsetProps, {
129
+ "aria-describedby":
130
+ describedBy === "" ? undefined : describedBy,
131
+ })}
91
132
  className={toClassName(
92
133
  // A `<fieldset>` arrives with a border, an inline margin, a
93
134
  // block padding, and `min-inline-size: min-content` — the
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,