@charcuterie/ui 0.2.0 → 1.0.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.
@@ -0,0 +1,110 @@
1
+ import type { InputHTMLAttributes } from "react";
2
+ /**
3
+ * The props a **cloning ancestor** injects into the control below it.
4
+ *
5
+ * `Field` and `Tooltip` both take one child and `cloneElement` onto
6
+ * it — "cloned, not wrapped", which is what lets a `Field` go around
7
+ * an `<input>`, a `Select`, or an app's own control without changing
8
+ * that control's layout. The contract has a hole in it the moment two
9
+ * of them are nested:
10
+ *
11
+ * ```tsx
12
+ * <Field label="Pattern" isRequired>
13
+ * <Tooltip label="A JavaScript regular expression">
14
+ * <input />
15
+ * </Tooltip>
16
+ * </Field>
17
+ * ```
18
+ *
19
+ * `Field` clones onto the **`Tooltip` element**, so `id`,
20
+ * `aria-describedby`, `aria-invalid` and `required` are handed to a
21
+ * component that declares none of them — and `cloneElement` does not
22
+ * care. React drops them without a warning, TypeScript never sees
23
+ * them (`Children.only` returns a `ReactElement` with `any` props),
24
+ * every test still passes, and the render is pixel-identical. The
25
+ * `<label>` then points at an `id` that is nowhere in the document.
26
+ *
27
+ * **Silent prop-dropping is the worst version of this**, so the rule
28
+ * is: *a slot component is a pass-through*. Anything in `SlotProps`
29
+ * that arrives from above is forwarded to the child this component
30
+ * clones onto, so a chain of slots behaves like the one control at
31
+ * the bottom of it.
32
+ *
33
+ * ### The list is closed, the forwarding is not
34
+ *
35
+ * Worth being exact, because the two are easy to conflate. This type
36
+ * is a closed list of five keys rather than an index signature: it is
37
+ * exactly the wiring `Field` writes plus the wiring `Tooltip` writes,
38
+ * it is greppable, and it is the set whose **merge semantics are
39
+ * defined** — which for four of them is last-write-wins and for
40
+ * `aria-describedby` is a join.
41
+ *
42
+ * The *runtime* forwards everything. Both components collect their
43
+ * received props with a rest spread and `mergeSlotProps` starts from
44
+ * `...receivedProps`, so an ancestor's `onPointerEnter`, its
45
+ * `onFocus`, its `ref` — none of which are named here — reach the
46
+ * control at the bottom too. That is not an oversight, it is load
47
+ * bearing: a `Tooltip` around a `Field` hands down `useHover`,
48
+ * `useFocus`, `useDismiss` and `refs.setReference`, and a `Field`
49
+ * that forwarded only the five keys below would leave the tip with no
50
+ * trigger and no anchor. A closed *runtime* filter would be
51
+ * mux-magic's `FieldTooltip` defect rebuilt inside the fix for it.
52
+ *
53
+ * The five keys, and who writes them:
54
+ *
55
+ * | Key | Written by |
56
+ * | --- | --- |
57
+ * | `aria-describedby` | `Field` (its description and its error) **and** `Tooltip` (its tip) — the one key that is a list, and the reason `mergeSlotProps` exists |
58
+ * | `aria-invalid` | `Field`, derived from `error` being present |
59
+ * | `aria-required` | `Field`, from `isRequired` — the announced constraint, for a composite control the browser does not validate |
60
+ * | `id` | `Field`, so its `<label htmlFor>` has something to point at. A caller may also pass one directly, which is how a consumer pins a stable id for a deep link or an autofill hint |
61
+ * | `required` | `Field`, from `isRequired` — the constraint the browser validates |
62
+ *
63
+ * ### Why these are `Pick`ed and not written out
64
+ *
65
+ * Three of the five are booleans whose names cannot start with `is`
66
+ * or `has`, because they are not our names — they are the DOM's, and
67
+ * they are spread onto a DOM node verbatim. The house rule has
68
+ * [no carve-out](../../../docs/decisions/2026-07-29-is-has-rule-has-no-external-api-carve-out.md)
69
+ * for that: a *type property* is a shape we own, an object literal
70
+ * key is someone else's contract, and no `eslint-disable` is
71
+ * available because unused disable directives are themselves an
72
+ * error.
73
+ *
74
+ * So this type does not declare a shape. It **selects** one out of
75
+ * React's own `InputHTMLAttributes`, which is the honest description
76
+ * of what it is: five keys of the props React already types for an
77
+ * `<input>`, forwarded unchanged. The same reasoning as the
78
+ * `Record<string, boolean>` in `createReactAdapter` — we are not
79
+ * describing React's shape, we are naming the keys of it we touch —
80
+ * and it has the side benefit that `aria-invalid` gets React's real
81
+ * type (`"grammar"` and `"spelling"` are legal values) rather than
82
+ * the `boolean` a hand-written version would have narrowed it to.
83
+ */
84
+ export type SlotProps = Pick<InputHTMLAttributes<HTMLElement>, "aria-describedby" | "aria-invalid" | "aria-required" | "id" | "required">;
85
+ /**
86
+ * Merge what arrived from above with what this component writes
87
+ * itself.
88
+ *
89
+ * Everything here is a single value and last-write-wins is correct
90
+ * for it — **except `aria-describedby`, which is a list**. Two slot
91
+ * components each writing their own onto one control is the entire
92
+ * nesting problem in one attribute: a `Field` naming its description
93
+ * and its error, and a `Tooltip` naming its tip. A plain spread keeps
94
+ * one of them and loses the other, which is the same silent drop by a
95
+ * shorter route.
96
+ *
97
+ * **Outer first.** A screen reader reads the list in order, and the
98
+ * field's own description and error are the control's primary
99
+ * explanation; a tip is supplementary by construction. The ordering
100
+ * inside a single `Field` — description, then error — is `Field`'s
101
+ * own concern and is unaffected.
102
+ *
103
+ * `undefined` in `ownProps` is *absence*, not an instruction to
104
+ * clear. Both callers spell their optional wiring as
105
+ * `isRequired || undefined`, so a plain spread would let an inner
106
+ * `Field` with no error erase an outer one's `aria-invalid` — the
107
+ * same silent drop, one level down.
108
+ */
109
+ export declare const mergeSlotProps: <OwnProps extends Record<string, unknown>>(receivedProps: SlotProps, ownProps: OwnProps) => OwnProps & SlotProps;
110
+ //# sourceMappingURL=slotProps.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slotProps.d.ts","sourceRoot":"","sources":["../src/slotProps.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,OAAO,CAAA;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiFG;AACH,MAAM,MAAM,SAAS,GAAG,IAAI,CAC1B,mBAAmB,CAAC,WAAW,CAAC,EAC9B,kBAAkB,GAClB,cAAc,GACd,eAAe,GACf,IAAI,GACJ,UAAU,CACb,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,cAAc,GACzB,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAExC,eAAe,SAAS,EACxB,UAAU,QAAQ,KACjB,QAAQ,GAAG,SAoBb,CAAA"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Merge what arrived from above with what this component writes
3
+ * itself.
4
+ *
5
+ * Everything here is a single value and last-write-wins is correct
6
+ * for it — **except `aria-describedby`, which is a list**. Two slot
7
+ * components each writing their own onto one control is the entire
8
+ * nesting problem in one attribute: a `Field` naming its description
9
+ * and its error, and a `Tooltip` naming its tip. A plain spread keeps
10
+ * one of them and loses the other, which is the same silent drop by a
11
+ * shorter route.
12
+ *
13
+ * **Outer first.** A screen reader reads the list in order, and the
14
+ * field's own description and error are the control's primary
15
+ * explanation; a tip is supplementary by construction. The ordering
16
+ * inside a single `Field` — description, then error — is `Field`'s
17
+ * own concern and is unaffected.
18
+ *
19
+ * `undefined` in `ownProps` is *absence*, not an instruction to
20
+ * clear. Both callers spell their optional wiring as
21
+ * `isRequired || undefined`, so a plain spread would let an inner
22
+ * `Field` with no error erase an outer one's `aria-invalid` — the
23
+ * same silent drop, one level down.
24
+ */
25
+ export const mergeSlotProps = (receivedProps, ownProps) => {
26
+ const definedOwnProps = Object.fromEntries(Object.entries(ownProps).filter(([, value]) => value !== undefined));
27
+ const describedBy = [
28
+ receivedProps["aria-describedby"],
29
+ ownProps["aria-describedby"],
30
+ ]
31
+ .filter(Boolean)
32
+ .join(" ");
33
+ return {
34
+ ...receivedProps,
35
+ ...definedOwnProps,
36
+ "aria-describedby": describedBy === "" ? undefined : describedBy,
37
+ };
38
+ };
39
+ //# sourceMappingURL=slotProps.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slotProps.js","sourceRoot":"","sources":["../src/slotProps.ts"],"names":[],"mappings":"AA6FA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAG5B,aAAwB,EACxB,QAAkB,EACI,EAAE;IACxB,MAAM,eAAe,GAAG,MAAM,CAAC,WAAW,CACxC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,MAAM,CAC7B,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,KAAK,KAAK,SAAS,CACnC,CACU,CAAA;IAEb,MAAM,WAAW,GAAG;QAClB,aAAa,CAAC,kBAAkB,CAAC;QACjC,QAAQ,CAAC,kBAAkB,CAAC;KAC7B;SACE,MAAM,CAAC,OAAO,CAAC;SACf,IAAI,CAAC,GAAG,CAAC,CAAA;IAEZ,OAAO;QACL,GAAG,aAAa;QAChB,GAAG,eAAe;QAClB,kBAAkB,EAChB,WAAW,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW;KAC/C,CAAA;AACH,CAAC,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "0.2.0",
3
+ "version": "1.0.0",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,8 +43,8 @@
43
43
  "typecheck": "tsc --noEmit"
44
44
  },
45
45
  "dependencies": {
46
- "@charcuterie/logic": "0.1.0",
47
- "@charcuterie/tokens": "0.2.0",
46
+ "@charcuterie/logic": "1.0.0",
47
+ "@charcuterie/tokens": "1.0.0",
48
48
  "@floating-ui/react": "^0.27.20"
49
49
  },
50
50
  "peerDependencies": {
@@ -7,9 +7,23 @@ import * as FieldStories from "./Field.stories";
7
7
  # Field
8
8
 
9
9
  Sixteen files in the fleet spell a label, a control, and a hint, and no two spell the
10
- wiring the same way. mux-magic's `FieldLabel` renders a `<label>` with **no `htmlFor`** —
11
- which looks correct, reads correctly to a sighted user, and gives a screen reader an
12
- unnamed textbox.
10
+ wiring the same way.
11
+
12
+ An earlier version of this page said mux-magic's `FieldLabel` renders a `<label>` with
13
+ **no `htmlFor`**. That is false, and worth correcting rather than quietly deleting: it
14
+ renders ``htmlFor={`${stepId}-${field.name}`}``, and has since it was written. The defect
15
+ is at the *other* end of the pair — **8 of its 16 call sites never render that id on
16
+ anything**. Measured on `mux-magic@master`, the eight that do are `BooleanField`,
17
+ `ChapterSplitsField`, `EnumField`, `NumberField`, `NumberWithLookupField`, `PathField`,
18
+ `StringArrayField` and `StringField`. Of the rest, `RegexWithFlagsField` mints its own
19
+ `patternId` / `flagsId` / `sampleId` and `SubtitleRulesField` spells
20
+ ``${step.command}-hasDefaultRules`` where the label says ``${step.id}-…``.
21
+
22
+ The outcome is the same unnamed textbox by a worse route: a `<label>` pointing at an id
23
+ that is nowhere in the document reads as correct in the label's own file, and is only
24
+ wrong in the *other* file. Half of them are broken and nothing says which half. That is
25
+ the argument for a component rather than a convention — here the id is minted and consumed
26
+ in one place and cannot be spelled twice.
13
27
 
14
28
  <Canvas of={FieldStories.Default} />
15
29
 
@@ -25,6 +39,62 @@ Cloned, not wrapped — the same contract as `Popover`'s trigger. An `<input>`,
25
39
 
26
40
  <Canvas of={FieldStories.AllVariants} />
27
41
 
42
+ ## A slot is a pass-through
43
+
44
+ Which is a rule this component needed and did not have. `Field` and `Tooltip` both clone
45
+ onto their single child, so the obvious thing to write —
46
+
47
+ ```tsx
48
+ <Field label="Rename pattern" isRequired>
49
+ <Tooltip label="A JavaScript regular expression.">
50
+ <input />
51
+ </Tooltip>
52
+ </Field>
53
+ ```
54
+
55
+ — handed `id`, `aria-describedby`, `aria-invalid` and `required` to the **`Tooltip`
56
+ component**, which declares none of them. `cloneElement` does not care. React drops all
57
+ four without a warning, TypeScript never sees it (`Children.only` returns a `ReactElement`
58
+ whose props are `any`), every test still passed, and the render was pixel-identical. The
59
+ only symptom was a `<label htmlFor>` pointing at an id nowhere in the document — the exact
60
+ defect above, reproduced by the component built to prevent it, and this time by the
61
+ library rather than by a consumer.
62
+
63
+ **Silent prop-dropping is the worst version of this**, so anything a slot receives from
64
+ above is forwarded to the child it clones onto, and a chain of slots behaves like the one
65
+ control at the bottom of it. `aria-describedby` — the one prop that is a *list* — is
66
+ merged rather than overwritten, outer first.
67
+
68
+ It works in both orders, and the second is not symmetrical: a `Tooltip` around a `Field`
69
+ hands down not one attribute but a working component — floating-ui's hover, focus and
70
+ dismiss handlers and `refs.setReference`. Those reach the control too, or the tip is a
71
+ floating node with no trigger and no anchor.
72
+
73
+ <Canvas of={FieldStories.Nested} />
74
+
75
+ ## Several controls are a `FieldGroup`, not a `Field`
76
+
77
+ `Field` mints one id and clones it onto one child. An `id` names one element and a
78
+ `<label htmlFor>` points at one, so the moment there are two controls the label names one
79
+ of them and leaves the rest anonymous. Six of mux-magic's sixteen field components are in
80
+ that position — `RegexWithFlagsField` (pattern, flags, sample, and a checkbox),
81
+ `NumberWithLookupField`, `LanguageCodeField`, `LanguageCodesField`, `SubtitleTypesField`
82
+ and `RenameRegexField`.
83
+
84
+ `FieldGroup` renders `<fieldset>` + `<legend>` and does **not** clone. This is the one
85
+ place in the library where `<fieldset>` is right: `AccordionSection` rejected it for its
86
+ panel precisely because that element means *form-control grouping*, and here the content
87
+ really is one. `<legend>` names the group natively, so no `role="group"` and no
88
+ `aria-labelledby`.
89
+
90
+ What a group cannot carry is `aria-invalid`, which has no group form — it belongs on the
91
+ control that is actually invalid, and a `FieldGroup` does not know which one that is. So
92
+ `error` here is **described, not asserted**: it joins the `<fieldset>`'s
93
+ `aria-describedby`. Cloning `aria-invalid` onto every child would mark the valid ones
94
+ invalid. A control that is itself invalid says so in its own `Field`.
95
+
96
+ <Canvas of={FieldStories.Group} />
97
+
28
98
  ## `createLinkedIds` is *not* here, and that is the finding
29
99
 
30
100
  The plan expected `useLinkedIds` to do this wiring. It does not fit, for the same reason it
@@ -5,7 +5,9 @@ import {
5
5
  StoryGrid,
6
6
  } from "../board.storyHelpers.tsx"
7
7
  import { Select } from "../Select/Select.tsx"
8
+ import { Tooltip } from "../Tooltip/Tooltip.tsx"
8
9
  import { Field } from "./Field.tsx"
10
+ import { FieldGroup } from "./FieldGroup.tsx"
9
11
 
10
12
  const TEXT_INPUT_CLASS =
11
13
  "w-full rounded-md border border-border-default bg-surface-raised px-3 py-2 text-content-primary text-sm focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset) focus-visible:outline-focus-ring"
@@ -142,3 +144,137 @@ export const AllStates: Story = {
142
144
  </StoryGrid>
143
145
  ),
144
146
  }
147
+
148
+ /**
149
+ * Two slot components around one control, in both orders.
150
+ *
151
+ * `Field` and `Tooltip` each clone onto their single child, so
152
+ * nesting them used to hand `Field`'s `id`, `aria-describedby`,
153
+ * `aria-invalid` and `required` to the `Tooltip` **component** —
154
+ * which declares none of them and dropped all four, silently, with an
155
+ * identical render and a `<label>` pointing at an id that was nowhere
156
+ * in the document.
157
+ *
158
+ * A slot is a pass-through: whatever arrives from above is forwarded
159
+ * to the child at the bottom of the chain, and `aria-describedby` —
160
+ * the one prop that is a *list* — is merged rather than overwritten,
161
+ * outer first.
162
+ */
163
+ export const Nested: Story = {
164
+ args: {
165
+ children: (
166
+ <input className={TEXT_INPUT_CLASS} type="text" />
167
+ ),
168
+ label: "Output directory",
169
+ },
170
+ render: () => (
171
+ <StoryGrid columns={2}>
172
+ <StoryCell label="tooltip inside field">
173
+ <Field
174
+ description="Applied to every title."
175
+ isRequired
176
+ label="Rename pattern"
177
+ >
178
+ <Tooltip label="A JavaScript regular expression.">
179
+ <input
180
+ className={TEXT_INPUT_CLASS}
181
+ defaultValue="^(.*)$"
182
+ type="text"
183
+ />
184
+ </Tooltip>
185
+ </Field>
186
+ </StoryCell>
187
+
188
+ <StoryCell label="field inside tooltip">
189
+ <Tooltip label="Where finished rips are moved.">
190
+ <Field error="Not writable." label="Archive path">
191
+ <input
192
+ className={TEXT_INPUT_CLASS}
193
+ defaultValue="/mnt/readonly"
194
+ type="text"
195
+ />
196
+ </Field>
197
+ </Tooltip>
198
+ </StoryCell>
199
+ </StoryGrid>
200
+ ),
201
+ }
202
+
203
+ /**
204
+ * `FieldGroup` — one label over **several** controls, which is where
205
+ * `Field` cannot go: an `id` names one element and a
206
+ * `<label htmlFor>` points at one.
207
+ *
208
+ * Six of mux-magic's sixteen field components are in this position,
209
+ * and every one of them renders a `FieldLabel` whose `htmlFor` names
210
+ * at best one of the controls under it. The element for the job is a
211
+ * `<fieldset>` with a `<legend>` — the one place in this library
212
+ * where `<fieldset>` is right, because here the content really *is* a
213
+ * form-control grouping.
214
+ */
215
+ export const Group: Story = {
216
+ args: {
217
+ children: (
218
+ <input className={TEXT_INPUT_CLASS} type="text" />
219
+ ),
220
+ label: "Output directory",
221
+ },
222
+ render: () => (
223
+ <StoryGrid columns={2}>
224
+ <StoryCell label="several controls">
225
+ <FieldGroup
226
+ description="Applied to every title in the disc."
227
+ isRequired
228
+ label="Rename pattern"
229
+ >
230
+ <div className="flex gap-2">
231
+ <input
232
+ aria-label="Pattern"
233
+ className={TEXT_INPUT_CLASS}
234
+ defaultValue="^(.*)$"
235
+ type="text"
236
+ />
237
+
238
+ <input
239
+ aria-label="Flags"
240
+ className={TEXT_INPUT_CLASS}
241
+ defaultValue="gi"
242
+ type="text"
243
+ />
244
+ </div>
245
+
246
+ <input
247
+ aria-label="Sample"
248
+ className={TEXT_INPUT_CLASS}
249
+ defaultValue="Disc 1 — Title 04"
250
+ type="text"
251
+ />
252
+ </FieldGroup>
253
+ </StoryCell>
254
+
255
+ <StoryCell label="invalid group">
256
+ <FieldGroup
257
+ error="A pattern needs at least one capture group."
258
+ label="Chapter split"
259
+ >
260
+ <div className="flex gap-2">
261
+ <Select
262
+ label="Unit"
263
+ options={[
264
+ { label: "Chapters", value: "chapters" },
265
+ { label: "Minutes", value: "minutes" },
266
+ ]}
267
+ />
268
+
269
+ <input
270
+ aria-label="Every"
271
+ className={TEXT_INPUT_CLASS}
272
+ defaultValue="5"
273
+ type="number"
274
+ />
275
+ </div>
276
+ </FieldGroup>
277
+ </StoryCell>
278
+ </StoryGrid>
279
+ ),
280
+ }
@@ -1,5 +1,5 @@
1
1
  import { composeStories } from "@storybook/react"
2
- import { expect } from "storybook/test"
2
+ import { expect, waitFor } from "storybook/test"
3
3
  import { test } from "vitest"
4
4
 
5
5
  import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
@@ -7,7 +7,7 @@ import { mountStory } from "../mountStory.testHelpers.ts"
7
7
  import { expectAgentDrivable } from "../testing/index.ts"
8
8
  import * as stories from "./Field.stories.tsx"
9
9
 
10
- const { AllStates, AllVariants, Default } =
10
+ const { AllStates, AllVariants, Default, Group, Nested } =
11
11
  composeStories(stories)
12
12
 
13
13
  test("the label names the control it points at", async () => {
@@ -16,9 +16,10 @@ test("the label names the control it points at", async () => {
16
16
 
17
17
  // The whole point of the component in one query: the control is
18
18
  // findable by the *label's* text, which is only true if `htmlFor`
19
- // and the control's `id` agree. mux-magic's `FieldLabel` renders
20
- // a `<label>` with no `htmlFor` at all — it looks right, reads
21
- // right to a sighted user, and gives this query nothing.
19
+ // and the control's `id` agree. mux-magic's `FieldLabel` does
20
+ // render a `htmlFor` — 8 of its 16 call sites just never render
21
+ // that id on anything, so the pair agrees in one file and not in
22
+ // the other, and this query gets nothing.
22
23
  const control = expectAgentDrivable(canvas, {
23
24
  name: "Output directory",
24
25
  role: "textbox",
@@ -114,6 +115,146 @@ test("an error is the only source of invalidity", async () => {
114
115
  await expectNoAxeViolations(canvasElement)
115
116
  })
116
117
 
118
+ /**
119
+ * The nesting defect, and the reason `SlotProps` exists.
120
+ *
121
+ * `Field` clones onto its one child. With a `Tooltip` in between, the
122
+ * child is the **`Tooltip` element**, so `id`, `aria-describedby`,
123
+ * `aria-invalid` and `required` were handed to a component that
124
+ * declares none of them. `cloneElement` does not care, TypeScript
125
+ * never sees it, and the render is pixel-identical — the only
126
+ * symptom is a `<label htmlFor>` pointing at an id that is nowhere in
127
+ * the document, which is exactly the unnamed textbox this component
128
+ * exists to prevent.
129
+ *
130
+ * The query below is the proof: finding the control **by the label's
131
+ * text** is only possible if the `id` reached the `<input>` at the
132
+ * bottom of the chain.
133
+ */
134
+ test("a slot forwards what an outer slot gave it", async () => {
135
+ const { canvas, canvasElement } = await mountStory(Nested)
136
+
137
+ const control = expectAgentDrivable(canvas, {
138
+ name: "Rename pattern",
139
+ role: "textbox",
140
+ })
141
+
142
+ await expect(control).toBeRequired()
143
+
144
+ await expect(control).toHaveAttribute(
145
+ "aria-required",
146
+ "true",
147
+ )
148
+
149
+ await expect(
150
+ document.getElementById(
151
+ control.getAttribute("aria-describedby") ?? "",
152
+ ),
153
+ ).toHaveTextContent("Applied to every title.")
154
+
155
+ await expectNoAxeViolations(canvasElement)
156
+ })
157
+
158
+ /**
159
+ * The same drop in reverse — and the half that nearly shipped
160
+ * untested.
161
+ *
162
+ * The first draft of the test above closed with an `aria-invalid`
163
+ * assertion on the inner control of the *other* cell and called that
164
+ * the reverse case. It is not: `Field` writes `aria-invalid` itself,
165
+ * from its own `error` prop, so that assertion holds whether or not a
166
+ * single thing arrived from the `Tooltip` above it. Reverting
167
+ * `Field`'s half of the fix left the whole file green.
168
+ *
169
+ * What a `Tooltip` hands down is not one attribute, it is a working
170
+ * component: `getReferenceProps()`'s hover, focus and dismiss
171
+ * handlers, `refs.setReference`, and an `aria-describedby` naming the
172
+ * tip. All of it lands on the `Field` **element**, and `Field` has to
173
+ * pass every bit of it through to the control at the bottom or the
174
+ * tip is a floating node with no trigger, no anchor and nobody
175
+ * pointed at it — mux-magic's `FieldTooltip` defect 1, reproduced by
176
+ * a library that shipped the fix for it.
177
+ *
178
+ * So the assertion is the tip *opening from the inner control's own
179
+ * focus* and then being named by it, which needs the handlers, the
180
+ * ref and the attribute to have all three arrived.
181
+ */
182
+ test("a slot forwards what an outer slot gave it, in the other order", async () => {
183
+ const { canvas } = await mountStory(Nested)
184
+
185
+ const control = expectAgentDrivable(canvas, {
186
+ name: "Archive path",
187
+ role: "textbox",
188
+ })
189
+
190
+ // Focus on the control, not on the `Field` — `Field` renders a
191
+ // `<div>`, which cannot take focus, so a tip that opens here can
192
+ // only have been wired to the `<input>`.
193
+ control.focus()
194
+
195
+ await waitFor(() => {
196
+ expect(canvas.getByRole("tooltip")).toHaveTextContent(
197
+ "Where finished rips are moved.",
198
+ )
199
+ })
200
+
201
+ const [tipId, errorId] = (
202
+ control.getAttribute("aria-describedby") ?? ""
203
+ ).split(" ")
204
+
205
+ // Outer first, which here is the tip: the merge order is
206
+ // structural — whoever wrapped whom — and the author of the nesting
207
+ // is the one who chose it.
208
+ await expect(
209
+ document.getElementById(tipId ?? ""),
210
+ ).toHaveTextContent("Where finished rips are moved.")
211
+
212
+ await expect(
213
+ document.getElementById(errorId ?? ""),
214
+ ).toHaveTextContent("Not writable.")
215
+ })
216
+
217
+ /**
218
+ * `aria-describedby` is the one slot prop that is a **list**, so the
219
+ * merge is not last-write-wins. A `Field` names its description and
220
+ * its error; a `Tooltip` names its tip. A plain spread keeps one and
221
+ * loses the other — the same silent drop by a shorter route.
222
+ *
223
+ * Outer first: the field's own explanation before the supplementary
224
+ * tip.
225
+ */
226
+ test("a tip is described alongside the field's own text, not instead of it", async () => {
227
+ const { canvas } = await mountStory(Nested)
228
+
229
+ const control = expectAgentDrivable(canvas, {
230
+ name: "Rename pattern",
231
+ role: "textbox",
232
+ })
233
+
234
+ control.focus()
235
+
236
+ // `useFocus` is what opens it — the line mux-magic's hand-rolled
237
+ // tip is missing, and the reason this is testable without a
238
+ // pointer at all.
239
+ await waitFor(() => {
240
+ expect(canvas.getByRole("tooltip")).toHaveTextContent(
241
+ "A JavaScript regular expression.",
242
+ )
243
+ })
244
+
245
+ const [descriptionId, tipId] = (
246
+ control.getAttribute("aria-describedby") ?? ""
247
+ ).split(" ")
248
+
249
+ await expect(
250
+ document.getElementById(descriptionId ?? ""),
251
+ ).toHaveTextContent("Applied to every title.")
252
+
253
+ await expect(
254
+ document.getElementById(tipId ?? ""),
255
+ ).toHaveTextContent("A JavaScript regular expression.")
256
+ })
257
+
117
258
  /**
118
259
  * Order inside `aria-describedby` is behaviour, not formatting. A
119
260
  * screen reader reads the list in sequence, and "absolute paths only
@@ -140,3 +281,64 @@ test("description is described before error", async () => {
140
281
  document.getElementById(errorId ?? ""),
141
282
  ).toHaveTextContent("That path is not writable.")
142
283
  })
284
+
285
+ /**
286
+ * `Field` cannot go here and the failure is quiet: an `id` names one
287
+ * element and a `<label htmlFor>` points at one, so a label over three
288
+ * inputs names one of them and leaves the other two anonymous. Six of
289
+ * mux-magic's sixteen field components are in exactly that state.
290
+ *
291
+ * `<fieldset>` + `<legend>` is the platform's answer, and it is the
292
+ * one place in this library where the element is right — the content
293
+ * really is a form-control grouping.
294
+ */
295
+ test("a group is named by its legend, and its controls keep their own names", async () => {
296
+ const { canvas, canvasElement } = await mountStory(Group)
297
+
298
+ const group = expectAgentDrivable(canvas, {
299
+ // "Rename pattern", not "Rename pattern *" — the asterisk is
300
+ // `aria-hidden`, so it is decoration and stays out of the name.
301
+ name: "Rename pattern",
302
+ role: "group",
303
+ })
304
+
305
+ // Every control inside is still individually findable. A group
306
+ // that swallows its members' names is the same defect one level up.
307
+ for (const name of ["Pattern", "Flags", "Sample"]) {
308
+ expectAgentDrivable(canvas, { name, role: "textbox" })
309
+ }
310
+
311
+ await expect(
312
+ document.getElementById(
313
+ group.getAttribute("aria-describedby") ?? "",
314
+ ),
315
+ ).toHaveTextContent("Applied to every title in the disc.")
316
+
317
+ await expectNoAxeViolations(canvasElement)
318
+ })
319
+
320
+ /**
321
+ * `aria-invalid` has no group form — it belongs on the control that
322
+ * is actually invalid, and a `FieldGroup` does not know which one
323
+ * that is. So the error is *described*, not *asserted*, and the
324
+ * alternative — cloning `aria-invalid` onto every child — would mark
325
+ * the valid ones invalid.
326
+ */
327
+ test("a group describes its error rather than asserting invalidity", async () => {
328
+ const { canvas } = await mountStory(Group)
329
+
330
+ const group = expectAgentDrivable(canvas, {
331
+ name: "Chapter split",
332
+ role: "group",
333
+ })
334
+
335
+ await expect(group).not.toHaveAttribute("aria-invalid")
336
+
337
+ await expect(
338
+ document.getElementById(
339
+ group.getAttribute("aria-describedby") ?? "",
340
+ ),
341
+ ).toHaveTextContent(
342
+ "A pattern needs at least one capture group.",
343
+ )
344
+ })