@charcuterie/ui 0.1.1 → 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.
Files changed (115) hide show
  1. package/README.md +9 -0
  2. package/dist/Accordion/Accordion.d.ts +82 -0
  3. package/dist/Accordion/Accordion.d.ts.map +1 -0
  4. package/dist/Accordion/Accordion.js +108 -0
  5. package/dist/Accordion/Accordion.js.map +1 -0
  6. package/dist/Accordion/AccordionSection.d.ts +28 -0
  7. package/dist/Accordion/AccordionSection.d.ts.map +1 -0
  8. package/dist/Accordion/AccordionSection.js +53 -0
  9. package/dist/Accordion/AccordionSection.js.map +1 -0
  10. package/dist/Field/Field.d.ts +104 -0
  11. package/dist/Field/Field.d.ts.map +1 -0
  12. package/dist/Field/Field.js +115 -0
  13. package/dist/Field/Field.js.map +1 -0
  14. package/dist/Field/FieldGroup.d.ts +58 -0
  15. package/dist/Field/FieldGroup.d.ts.map +1 -0
  16. package/dist/Field/FieldGroup.js +65 -0
  17. package/dist/Field/FieldGroup.js.map +1 -0
  18. package/dist/FileDropZone/FileDropZone.d.ts +63 -0
  19. package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
  20. package/dist/FileDropZone/FileDropZone.js +122 -0
  21. package/dist/FileDropZone/FileDropZone.js.map +1 -0
  22. package/dist/LogViewer/LogViewer.d.ts +90 -0
  23. package/dist/LogViewer/LogViewer.d.ts.map +1 -0
  24. package/dist/LogViewer/LogViewer.js +165 -0
  25. package/dist/LogViewer/LogViewer.js.map +1 -0
  26. package/dist/Menu/Menu.d.ts +81 -0
  27. package/dist/Menu/Menu.d.ts.map +1 -0
  28. package/dist/Menu/Menu.js +199 -0
  29. package/dist/Menu/Menu.js.map +1 -0
  30. package/dist/Menu/MenuAction.d.ts +24 -0
  31. package/dist/Menu/MenuAction.d.ts.map +1 -0
  32. package/dist/Menu/MenuAction.js +37 -0
  33. package/dist/Menu/MenuAction.js.map +1 -0
  34. package/dist/Select/Select.d.ts +87 -0
  35. package/dist/Select/Select.d.ts.map +1 -0
  36. package/dist/Select/Select.js +51 -0
  37. package/dist/Select/Select.js.map +1 -0
  38. package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
  39. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
  40. package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
  41. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
  42. package/dist/Toast/Toast.d.ts +54 -0
  43. package/dist/Toast/Toast.d.ts.map +1 -0
  44. package/dist/Toast/Toast.js +121 -0
  45. package/dist/Toast/Toast.js.map +1 -0
  46. package/dist/Toast/ToastRegion.d.ts +57 -0
  47. package/dist/Toast/ToastRegion.d.ts.map +1 -0
  48. package/dist/Toast/ToastRegion.js +41 -0
  49. package/dist/Toast/ToastRegion.js.map +1 -0
  50. package/dist/Toast/toastLifecycle.d.ts +34 -0
  51. package/dist/Toast/toastLifecycle.d.ts.map +1 -0
  52. package/dist/Toast/toastLifecycle.js +25 -0
  53. package/dist/Toast/toastLifecycle.js.map +1 -0
  54. package/dist/Tooltip/Tooltip.d.ts +82 -0
  55. package/dist/Tooltip/Tooltip.d.ts.map +1 -0
  56. package/dist/Tooltip/Tooltip.js +124 -0
  57. package/dist/Tooltip/Tooltip.js.map +1 -0
  58. package/dist/index.d.ts +26 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +13 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/mountStory.testHelpers.d.ts +0 -11
  63. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  64. package/dist/mountStory.testHelpers.js +27 -0
  65. package/dist/mountStory.testHelpers.js.map +1 -1
  66. package/dist/slotProps.d.ts +110 -0
  67. package/dist/slotProps.d.ts.map +1 -0
  68. package/dist/slotProps.js +39 -0
  69. package/dist/slotProps.js.map +1 -0
  70. package/package.json +3 -3
  71. package/src/Accordion/Accordion.mdx +83 -0
  72. package/src/Accordion/Accordion.stories.tsx +146 -0
  73. package/src/Accordion/Accordion.test.tsx +163 -0
  74. package/src/Accordion/Accordion.tsx +182 -0
  75. package/src/Accordion/AccordionSection.tsx +163 -0
  76. package/src/Field/Field.mdx +144 -0
  77. package/src/Field/Field.stories.tsx +280 -0
  78. package/src/Field/Field.test.tsx +344 -0
  79. package/src/Field/Field.tsx +212 -0
  80. package/src/Field/FieldGroup.tsx +138 -0
  81. package/src/FileDropZone/FileDropZone.mdx +78 -0
  82. package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
  83. package/src/FileDropZone/FileDropZone.test.tsx +230 -0
  84. package/src/FileDropZone/FileDropZone.tsx +234 -0
  85. package/src/LogViewer/LogViewer.mdx +123 -0
  86. package/src/LogViewer/LogViewer.stories.tsx +169 -0
  87. package/src/LogViewer/LogViewer.test.tsx +236 -0
  88. package/src/LogViewer/LogViewer.tsx +261 -0
  89. package/src/Menu/Menu.mdx +103 -0
  90. package/src/Menu/Menu.stories.tsx +161 -0
  91. package/src/Menu/Menu.test.tsx +202 -0
  92. package/src/Menu/Menu.tsx +300 -0
  93. package/src/Menu/MenuAction.tsx +95 -0
  94. package/src/Select/Select.mdx +74 -0
  95. package/src/Select/Select.stories.tsx +126 -0
  96. package/src/Select/Select.test.tsx +129 -0
  97. package/src/Select/Select.tsx +200 -0
  98. package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
  99. package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
  100. package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
  101. package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
  102. package/src/Toast/Toast.mdx +84 -0
  103. package/src/Toast/Toast.stories.tsx +192 -0
  104. package/src/Toast/Toast.test.tsx +217 -0
  105. package/src/Toast/Toast.tsx +224 -0
  106. package/src/Toast/ToastRegion.tsx +98 -0
  107. package/src/Toast/toastLifecycle.ts +45 -0
  108. package/src/Tooltip/Tooltip.mdx +69 -0
  109. package/src/Tooltip/Tooltip.stories.tsx +104 -0
  110. package/src/Tooltip/Tooltip.test.tsx +138 -0
  111. package/src/Tooltip/Tooltip.tsx +193 -0
  112. package/src/index.ts +47 -0
  113. package/src/mountStory.testHelpers.ts +30 -0
  114. package/src/slotProps.ts +143 -0
  115. package/src/sourceRules.test.ts +221 -30
@@ -0,0 +1,280 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import {
4
+ StoryCell,
5
+ StoryGrid,
6
+ } from "../board.storyHelpers.tsx"
7
+ import { Select } from "../Select/Select.tsx"
8
+ import { Tooltip } from "../Tooltip/Tooltip.tsx"
9
+ import { Field } from "./Field.tsx"
10
+ import { FieldGroup } from "./FieldGroup.tsx"
11
+
12
+ const TEXT_INPUT_CLASS =
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"
14
+
15
+ const meta = {
16
+ title: "Components/Field",
17
+ component: Field,
18
+ parameters: { layout: "padded" },
19
+ args: {
20
+ isRequired: false,
21
+ label: "Output directory",
22
+ },
23
+ } satisfies Meta<typeof Field>
24
+
25
+ export default meta
26
+
27
+ type Story = StoryObj<typeof meta>
28
+
29
+ export const Default: Story = {
30
+ args: {
31
+ children: (
32
+ <input className={TEXT_INPUT_CLASS} type="text" />
33
+ ),
34
+ description: "Where finished rips are moved.",
35
+ label: "Output directory",
36
+ },
37
+ }
38
+
39
+ /**
40
+ * The control is a slot, not a hardcoded `<input>` — `Select` goes
41
+ * in the same hole, and so does an app's own control. The `id`,
42
+ * `aria-describedby`, `aria-invalid`, and `required` are cloned onto
43
+ * whatever is passed.
44
+ */
45
+ export const AllVariants: Story = {
46
+ args: {
47
+ children: (
48
+ <input className={TEXT_INPUT_CLASS} type="text" />
49
+ ),
50
+ label: "Output directory",
51
+ },
52
+ render: () => (
53
+ <StoryGrid columns={2}>
54
+ <StoryCell label="text input">
55
+ <Field label="Output directory">
56
+ <input className={TEXT_INPUT_CLASS} type="text" />
57
+ </Field>
58
+ </StoryCell>
59
+
60
+ <StoryCell label="select">
61
+ <Field label="Rip profile">
62
+ <Select
63
+ options={[
64
+ { label: "Lossless", value: "lossless" },
65
+ { label: "Compressed", value: "compressed" },
66
+ ]}
67
+ />
68
+ </Field>
69
+ </StoryCell>
70
+
71
+ <StoryCell label="with description">
72
+ <Field
73
+ description="Where finished rips are moved."
74
+ label="Archive path"
75
+ >
76
+ <input className={TEXT_INPUT_CLASS} type="text" />
77
+ </Field>
78
+ </StoryCell>
79
+
80
+ <StoryCell label="required">
81
+ <Field isRequired label="Disc label">
82
+ <input className={TEXT_INPUT_CLASS} type="text" />
83
+ </Field>
84
+ </StoryCell>
85
+ </StoryGrid>
86
+ ),
87
+ }
88
+
89
+ /**
90
+ * `error` is the only source of invalidity — there is no separate
91
+ * `isInvalid`, because two sources for one fact is how a control
92
+ * ends up `aria-invalid="true"` with nothing saying why.
93
+ *
94
+ * With both a description and an error, `aria-describedby` names
95
+ * them in that order. A screen reader reads the list in sequence,
96
+ * and the two sentences are not interchangeable.
97
+ */
98
+ export const AllStates: Story = {
99
+ args: {
100
+ children: (
101
+ <input className={TEXT_INPUT_CLASS} type="text" />
102
+ ),
103
+ label: "Output directory",
104
+ },
105
+ render: () => (
106
+ <StoryGrid columns={3}>
107
+ <StoryCell label="valid">
108
+ <Field label="Bay name">
109
+ <input
110
+ className={TEXT_INPUT_CLASS}
111
+ defaultValue="Bay 3"
112
+ type="text"
113
+ />
114
+ </Field>
115
+ </StoryCell>
116
+
117
+ <StoryCell label="invalid">
118
+ <Field
119
+ error="That path is not writable."
120
+ label="Scratch path"
121
+ >
122
+ <input
123
+ className={TEXT_INPUT_CLASS}
124
+ defaultValue="/mnt/readonly"
125
+ type="text"
126
+ />
127
+ </Field>
128
+ </StoryCell>
129
+
130
+ <StoryCell label="described and invalid">
131
+ <Field
132
+ description="Absolute paths only."
133
+ error="That path is not writable."
134
+ isRequired
135
+ label="Archive path"
136
+ >
137
+ <input
138
+ className={TEXT_INPUT_CLASS}
139
+ defaultValue="mnt/archive"
140
+ type="text"
141
+ />
142
+ </Field>
143
+ </StoryCell>
144
+ </StoryGrid>
145
+ ),
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
+ }
@@ -0,0 +1,344 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, waitFor } 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 { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./Field.stories.tsx"
9
+
10
+ const { AllStates, AllVariants, Default, Group, Nested } =
11
+ composeStories(stories)
12
+
13
+ test("the label names the control it points at", async () => {
14
+ const { canvas, canvasElement } =
15
+ await mountStory(Default)
16
+
17
+ // The whole point of the component in one query: the control is
18
+ // findable by the *label's* text, which is only true if `htmlFor`
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.
23
+ const control = expectAgentDrivable(canvas, {
24
+ name: "Output directory",
25
+ role: "textbox",
26
+ })
27
+
28
+ await expect(control).toHaveAttribute("id")
29
+
30
+ await expectNoAxeViolations(canvasElement)
31
+ })
32
+
33
+ test("a description is announced with the control", async () => {
34
+ const { canvas } = await mountStory(Default)
35
+
36
+ const control = expectAgentDrivable(canvas, {
37
+ name: "Output directory",
38
+ role: "textbox",
39
+ })
40
+
41
+ const describedBy = control.getAttribute(
42
+ "aria-describedby",
43
+ )
44
+
45
+ await expect(describedBy).not.toBeNull()
46
+
47
+ await expect(
48
+ document.getElementById(describedBy ?? ""),
49
+ ).toHaveTextContent("Where finished rips are moved.")
50
+ })
51
+
52
+ test("the slot takes a Select as readily as an input", async () => {
53
+ const { canvas, canvasElement } =
54
+ await mountStory(AllVariants)
55
+
56
+ // A native `<select>` reports `role="combobox"`, and it is named
57
+ // by the same cloned `id`. The slot does not know what it is
58
+ // holding, which is the contract.
59
+ expectAgentDrivable(canvas, {
60
+ name: "Rip profile",
61
+ role: "combobox",
62
+ })
63
+
64
+ await expectNoAxeViolations(canvasElement)
65
+ })
66
+
67
+ test("`required` is spelled twice, on purpose", async () => {
68
+ const { canvas } = await mountStory(AllVariants)
69
+
70
+ const control = expectAgentDrivable(canvas, {
71
+ name: "Disc label",
72
+ role: "textbox",
73
+ })
74
+
75
+ // `required` is the constraint the browser validates;
76
+ // `aria-required` is the one it announces on a control the browser
77
+ // does not validate. They are not redundant, and a component that
78
+ // sets only the first is silent on a composite widget.
79
+ await expect(control).toBeRequired()
80
+
81
+ await expect(control).toHaveAttribute(
82
+ "aria-required",
83
+ "true",
84
+ )
85
+ })
86
+
87
+ test("an error is the only source of invalidity", async () => {
88
+ const { canvas, canvasElement } =
89
+ await mountStory(AllStates)
90
+
91
+ // Valid: nothing claims otherwise.
92
+ await expect(
93
+ expectAgentDrivable(canvas, {
94
+ name: "Bay name",
95
+ role: "textbox",
96
+ }),
97
+ ).not.toHaveAttribute("aria-invalid")
98
+
99
+ const invalid = expectAgentDrivable(canvas, {
100
+ name: "Scratch path",
101
+ role: "textbox",
102
+ })
103
+
104
+ await expect(invalid).toHaveAttribute(
105
+ "aria-invalid",
106
+ "true",
107
+ )
108
+
109
+ await expect(
110
+ document.getElementById(
111
+ invalid.getAttribute("aria-describedby") ?? "",
112
+ ),
113
+ ).toHaveTextContent("That path is not writable.")
114
+
115
+ await expectNoAxeViolations(canvasElement)
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
+
258
+ /**
259
+ * Order inside `aria-describedby` is behaviour, not formatting. A
260
+ * screen reader reads the list in sequence, and "absolute paths only
261
+ * — that path is not writable" is a different sentence from its
262
+ * reverse. Nothing but the order of one array enforces it.
263
+ */
264
+ test("description is described before error", async () => {
265
+ const { canvas } = await mountStory(AllStates)
266
+
267
+ const control = expectAgentDrivable(canvas, {
268
+ name: "Archive path",
269
+ role: "textbox",
270
+ })
271
+
272
+ const [descriptionId, errorId] = (
273
+ control.getAttribute("aria-describedby") ?? ""
274
+ ).split(" ")
275
+
276
+ await expect(
277
+ document.getElementById(descriptionId ?? ""),
278
+ ).toHaveTextContent("Absolute paths only.")
279
+
280
+ await expect(
281
+ document.getElementById(errorId ?? ""),
282
+ ).toHaveTextContent("That path is not writable.")
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
+ })