@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.
- package/README.md +9 -0
- package/dist/Accordion/Accordion.d.ts +82 -0
- package/dist/Accordion/Accordion.d.ts.map +1 -0
- package/dist/Accordion/Accordion.js +108 -0
- package/dist/Accordion/Accordion.js.map +1 -0
- package/dist/Accordion/AccordionSection.d.ts +28 -0
- package/dist/Accordion/AccordionSection.d.ts.map +1 -0
- package/dist/Accordion/AccordionSection.js +53 -0
- package/dist/Accordion/AccordionSection.js.map +1 -0
- package/dist/Field/Field.d.ts +104 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +115 -0
- package/dist/Field/Field.js.map +1 -0
- package/dist/Field/FieldGroup.d.ts +58 -0
- package/dist/Field/FieldGroup.d.ts.map +1 -0
- package/dist/Field/FieldGroup.js +65 -0
- package/dist/Field/FieldGroup.js.map +1 -0
- package/dist/FileDropZone/FileDropZone.d.ts +63 -0
- package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
- package/dist/FileDropZone/FileDropZone.js +122 -0
- package/dist/FileDropZone/FileDropZone.js.map +1 -0
- package/dist/LogViewer/LogViewer.d.ts +90 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +165 -0
- package/dist/LogViewer/LogViewer.js.map +1 -0
- package/dist/Menu/Menu.d.ts +81 -0
- package/dist/Menu/Menu.d.ts.map +1 -0
- package/dist/Menu/Menu.js +199 -0
- package/dist/Menu/Menu.js.map +1 -0
- package/dist/Menu/MenuAction.d.ts +24 -0
- package/dist/Menu/MenuAction.d.ts.map +1 -0
- package/dist/Menu/MenuAction.js +37 -0
- package/dist/Menu/MenuAction.js.map +1 -0
- package/dist/Select/Select.d.ts +87 -0
- package/dist/Select/Select.d.ts.map +1 -0
- package/dist/Select/Select.js +51 -0
- package/dist/Select/Select.js.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
- package/dist/Toast/Toast.d.ts +54 -0
- package/dist/Toast/Toast.d.ts.map +1 -0
- package/dist/Toast/Toast.js +121 -0
- package/dist/Toast/Toast.js.map +1 -0
- package/dist/Toast/ToastRegion.d.ts +57 -0
- package/dist/Toast/ToastRegion.d.ts.map +1 -0
- package/dist/Toast/ToastRegion.js +41 -0
- package/dist/Toast/ToastRegion.js.map +1 -0
- package/dist/Toast/toastLifecycle.d.ts +34 -0
- package/dist/Toast/toastLifecycle.d.ts.map +1 -0
- package/dist/Toast/toastLifecycle.js +25 -0
- package/dist/Toast/toastLifecycle.js.map +1 -0
- package/dist/Tooltip/Tooltip.d.ts +82 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +124 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +0 -11
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +27 -0
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/dist/slotProps.d.ts +110 -0
- package/dist/slotProps.d.ts.map +1 -0
- package/dist/slotProps.js +39 -0
- package/dist/slotProps.js.map +1 -0
- package/package.json +3 -3
- package/src/Accordion/Accordion.mdx +83 -0
- package/src/Accordion/Accordion.stories.tsx +146 -0
- package/src/Accordion/Accordion.test.tsx +163 -0
- package/src/Accordion/Accordion.tsx +182 -0
- package/src/Accordion/AccordionSection.tsx +163 -0
- package/src/Field/Field.mdx +144 -0
- package/src/Field/Field.stories.tsx +280 -0
- package/src/Field/Field.test.tsx +344 -0
- package/src/Field/Field.tsx +212 -0
- package/src/Field/FieldGroup.tsx +138 -0
- package/src/FileDropZone/FileDropZone.mdx +78 -0
- package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
- package/src/FileDropZone/FileDropZone.test.tsx +230 -0
- package/src/FileDropZone/FileDropZone.tsx +234 -0
- package/src/LogViewer/LogViewer.mdx +123 -0
- package/src/LogViewer/LogViewer.stories.tsx +169 -0
- package/src/LogViewer/LogViewer.test.tsx +236 -0
- package/src/LogViewer/LogViewer.tsx +261 -0
- package/src/Menu/Menu.mdx +103 -0
- package/src/Menu/Menu.stories.tsx +161 -0
- package/src/Menu/Menu.test.tsx +202 -0
- package/src/Menu/Menu.tsx +300 -0
- package/src/Menu/MenuAction.tsx +95 -0
- package/src/Select/Select.mdx +74 -0
- package/src/Select/Select.stories.tsx +126 -0
- package/src/Select/Select.test.tsx +129 -0
- package/src/Select/Select.tsx +200 -0
- package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
- package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
- package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
- package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
- package/src/Toast/Toast.mdx +84 -0
- package/src/Toast/Toast.stories.tsx +192 -0
- package/src/Toast/Toast.test.tsx +217 -0
- package/src/Toast/Toast.tsx +224 -0
- package/src/Toast/ToastRegion.tsx +98 -0
- package/src/Toast/toastLifecycle.ts +45 -0
- package/src/Tooltip/Tooltip.mdx +69 -0
- package/src/Tooltip/Tooltip.stories.tsx +104 -0
- package/src/Tooltip/Tooltip.test.tsx +138 -0
- package/src/Tooltip/Tooltip.tsx +193 -0
- package/src/index.ts +47 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/slotProps.ts +143 -0
- 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
|
+
})
|