@charcuterie/ui 0.2.0 → 1.0.1
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/dist/Field/Field.d.ts +41 -6
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +50 -8
- package/dist/Field/Field.js.map +1 -1
- 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/LogViewer/LogViewer.d.ts +3 -1
- package/dist/LogViewer/LogViewer.d.ts.map +1 -1
- package/dist/LogViewer/LogViewer.js +62 -1
- package/dist/LogViewer/LogViewer.js.map +1 -1
- package/dist/Select/Select.d.ts +10 -2
- package/dist/Select/Select.d.ts.map +1 -1
- package/dist/Select/Select.js.map +1 -1
- package/dist/Tooltip/Tooltip.d.ts +16 -2
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +23 -3
- package/dist/Tooltip/Tooltip.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/slotProps.d.ts +121 -0
- package/dist/slotProps.d.ts.map +1 -0
- package/dist/slotProps.js +55 -0
- package/dist/slotProps.js.map +1 -0
- package/dist/slotWiring.d.ts +32 -0
- package/dist/slotWiring.d.ts.map +1 -0
- package/dist/slotWiring.js +115 -0
- package/dist/slotWiring.js.map +1 -0
- package/package.json +3 -3
- package/src/Field/Field.mdx +73 -3
- package/src/Field/Field.stories.tsx +136 -0
- package/src/Field/Field.test.tsx +207 -5
- package/src/Field/Field.tsx +70 -21
- package/src/Field/FieldGroup.tsx +138 -0
- package/src/LogViewer/LogViewer.mdx +36 -1
- package/src/LogViewer/LogViewer.stories.tsx +34 -0
- package/src/LogViewer/LogViewer.test.tsx +65 -2
- package/src/LogViewer/LogViewer.tsx +67 -1
- package/src/Menu/Menu.mdx +23 -0
- package/src/Menu/Menu.stories.tsx +67 -0
- package/src/Menu/Menu.test.tsx +113 -2
- package/src/Popover/Popover.mdx +5 -0
- package/src/Select/Select.stories.tsx +10 -2
- package/src/Select/Select.tsx +10 -2
- package/src/Tooltip/Tooltip.tsx +29 -5
- package/src/index.ts +4 -0
- package/src/slotProps.ts +163 -0
- package/src/slotWiring.ts +173 -0
package/src/Field/Field.test.tsx
CHANGED
|
@@ -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`
|
|
20
|
-
// a
|
|
21
|
-
//
|
|
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
|
+
})
|
package/src/Field/Field.tsx
CHANGED
|
@@ -4,9 +4,11 @@ import {
|
|
|
4
4
|
} from "@charcuterie/logic"
|
|
5
5
|
import type { ReactElement, ReactNode } from "react"
|
|
6
6
|
|
|
7
|
+
import type { SlotProps } from "../slotProps.ts"
|
|
8
|
+
import { mergeSlotProps } from "../slotProps.ts"
|
|
7
9
|
import { toClassName } from "../toClassName.ts"
|
|
8
10
|
|
|
9
|
-
export type FieldProps = {
|
|
11
|
+
export type FieldProps = SlotProps & {
|
|
10
12
|
/**
|
|
11
13
|
* The control. **Cloned, not wrapped** — the same slot contract
|
|
12
14
|
* as `Popover`'s trigger, so a `Field` can go around an `<input>`,
|
|
@@ -33,10 +35,32 @@ export type FieldProps = {
|
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* Sixteen files in the fleet spell a label, a control, and a hint,
|
|
36
|
-
* and no two spell the wiring the same way.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* and no two spell the wiring the same way.
|
|
39
|
+
*
|
|
40
|
+
* ### What mux-magic's `FieldLabel` actually gets wrong
|
|
41
|
+
*
|
|
42
|
+
* An earlier version of this docstring said it "renders a `<label>`
|
|
43
|
+
* with no `htmlFor` at all". **That is false** — it renders
|
|
44
|
+
* ``htmlFor={`${stepId}-${field.name}`}``, and has since it was
|
|
45
|
+
* written. The defect is at the other end of the pair: **8 of its 16
|
|
46
|
+
* call sites never render that id on anything**. Measured on
|
|
47
|
+
* `mux-magic@master`, the eight that do are `BooleanField`,
|
|
48
|
+
* `ChapterSplitsField`, `EnumField`, `NumberField`,
|
|
49
|
+
* `NumberWithLookupField`, `PathField`, `StringArrayField` and
|
|
50
|
+
* `StringField`; the other eight either render no id at all or render
|
|
51
|
+
* a different one — `RegexWithFlagsField` mints its own `patternId` /
|
|
52
|
+
* `flagsId` / `sampleId`, and `SubtitleRulesField` spells
|
|
53
|
+
* ``${step.command}-hasDefaultRules`` where the label says
|
|
54
|
+
* ``${step.id}-…``.
|
|
55
|
+
*
|
|
56
|
+
* The outcome is the same unnamed textbox, by a worse route: a
|
|
57
|
+
* `<label>` that points at an id nowhere in the document reads as
|
|
58
|
+
* correct in the label's own file, and is only wrong in the *other*
|
|
59
|
+
* file. Half of them are broken and nothing says which half.
|
|
60
|
+
*
|
|
61
|
+
* That is the argument for a component rather than a convention: here
|
|
62
|
+
* the id is minted and consumed in one place and cannot be spelled
|
|
63
|
+
* twice.
|
|
40
64
|
*
|
|
41
65
|
* ### The ids are static, and that is why `createLinkedIds` is not here
|
|
42
66
|
*
|
|
@@ -72,6 +96,18 @@ export type FieldProps = {
|
|
|
72
96
|
* once, at the top, with the `Alert` this library already ships —
|
|
73
97
|
* and then the user tabs into the field and hears the specific
|
|
74
98
|
* reason.
|
|
99
|
+
*
|
|
100
|
+
* ### A slot is a pass-through
|
|
101
|
+
*
|
|
102
|
+
* A `Tooltip` between the field and its control is the obvious thing
|
|
103
|
+
* to write, and it is what mux-magic's `FieldLabel` does. Both
|
|
104
|
+
* components clone onto their one child, so everything below used to
|
|
105
|
+
* land on the `Tooltip` **component** and be dropped in silence —
|
|
106
|
+
* leaving the `<label htmlFor>` above pointing at nothing, which is
|
|
107
|
+
* precisely the defect this component was built to make impossible.
|
|
108
|
+
* `SlotProps` arriving from an outer slot is forwarded down, and
|
|
109
|
+
* `aria-describedby` is merged rather than replaced.
|
|
110
|
+
* `slotProps.ts` has the reasoning.
|
|
75
111
|
*/
|
|
76
112
|
export const Field = ({
|
|
77
113
|
children,
|
|
@@ -80,10 +116,19 @@ export const Field = ({
|
|
|
80
116
|
error,
|
|
81
117
|
isRequired = false,
|
|
82
118
|
label,
|
|
119
|
+
...receivedSlotProps
|
|
83
120
|
}: FieldProps): ReactNode => {
|
|
84
121
|
const baseId = useUniqueId()
|
|
85
122
|
|
|
86
|
-
|
|
123
|
+
/**
|
|
124
|
+
* A caller's own `id` wins, which is what makes a `Field` usable
|
|
125
|
+
* where the id has to be predictable — an autofill hint, a deep
|
|
126
|
+
* link, a server-rendered error summary linking to the control.
|
|
127
|
+
* `useUniqueId` is still called: hooks are unconditional, and it
|
|
128
|
+
* costs one counter read.
|
|
129
|
+
*/
|
|
130
|
+
const controlId =
|
|
131
|
+
receivedSlotProps.id ?? `${baseId}-control`
|
|
87
132
|
|
|
88
133
|
const descriptionId = `${baseId}-description`
|
|
89
134
|
|
|
@@ -96,21 +141,25 @@ export const Field = ({
|
|
|
96
141
|
.filter(Boolean)
|
|
97
142
|
.join(" ")
|
|
98
143
|
|
|
99
|
-
const clonedControl = useClonedChild(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
144
|
+
const clonedControl = useClonedChild(
|
|
145
|
+
children,
|
|
146
|
+
mergeSlotProps(receivedSlotProps, {
|
|
147
|
+
"aria-describedby":
|
|
148
|
+
describedBy === "" ? undefined : describedBy,
|
|
149
|
+
// `aria-invalid` is derived from the error's presence, never
|
|
150
|
+
// passed. A control marked invalid with no described reason is
|
|
151
|
+
// a screen reader saying "invalid" and stopping.
|
|
152
|
+
"aria-invalid":
|
|
153
|
+
error === undefined ? undefined : true,
|
|
154
|
+
// Both, and they are not redundant: `required` is the
|
|
155
|
+
// constraint the browser validates and `aria-required` is the
|
|
156
|
+
// one it announces on a control the browser does not validate
|
|
157
|
+
// — a `role="combobox"`, or anything composite.
|
|
158
|
+
"aria-required": isRequired || undefined,
|
|
159
|
+
id: controlId,
|
|
160
|
+
required: isRequired || undefined,
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
114
163
|
|
|
115
164
|
return (
|
|
116
165
|
<div
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useUniqueId } from "@charcuterie/logic"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import { toClassName } from "../toClassName.ts"
|
|
5
|
+
|
|
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
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A label over **several** controls.
|
|
24
|
+
*
|
|
25
|
+
* `Field` mints one id and clones it onto one child, which is the
|
|
26
|
+
* right shape for a text input and the wrong shape the moment there
|
|
27
|
+
* are two of them: an `id` can only name one element, and a
|
|
28
|
+
* `<label htmlFor>` can only point at one. Six of mux-magic's sixteen
|
|
29
|
+
* field components are in that position — `RegexWithFlagsField`
|
|
30
|
+
* (pattern, flags, sample, and a checkbox), `NumberWithLookupField`
|
|
31
|
+
* (three inputs), `LanguageCodeField`, `LanguageCodesField`,
|
|
32
|
+
* `SubtitleTypesField` (a tag input beside a dropdown) and
|
|
33
|
+
* `RenameRegexField` (a row per rule) — and every one of them renders
|
|
34
|
+
* a `FieldLabel` whose `htmlFor` names, at best, one of them.
|
|
35
|
+
*
|
|
36
|
+
* ### `<fieldset>` and `<legend>`, not a `<label>`
|
|
37
|
+
*
|
|
38
|
+
* This is the one place in the library where `<fieldset>` is right.
|
|
39
|
+
* `AccordionSection` rejected it for its panel and said why — it is a
|
|
40
|
+
* *form-control grouping*, and dragging `<legend>` semantics onto
|
|
41
|
+
* prose is wrong. Here the content **is** a form-control grouping,
|
|
42
|
+
* so the element means what it says, `<legend>` names the group
|
|
43
|
+
* natively, and no `role="group"` and no `aria-labelledby` are
|
|
44
|
+
* needed.
|
|
45
|
+
*
|
|
46
|
+
* A `<label>` here would be the mux-magic defect deliberately: a
|
|
47
|
+
* label pointing at one control, or at none.
|
|
48
|
+
*
|
|
49
|
+
* ### What a group cannot carry
|
|
50
|
+
*
|
|
51
|
+
* `aria-invalid` has no group form — it belongs on the control that
|
|
52
|
+
* is actually invalid, and this component does not know which one
|
|
53
|
+
* that is. So `error` here is *described*, not *asserted*: it joins
|
|
54
|
+
* `aria-describedby` on the `<fieldset>`, which screen readers read
|
|
55
|
+
* as focus enters the group. A control inside that is itself invalid
|
|
56
|
+
* should say so itself, in its own `Field`.
|
|
57
|
+
*
|
|
58
|
+
* That is a real limitation and it is stated rather than papered
|
|
59
|
+
* over — the alternative was to clone `aria-invalid` onto every
|
|
60
|
+
* child, which marks the valid ones invalid.
|
|
61
|
+
*/
|
|
62
|
+
export const FieldGroup = ({
|
|
63
|
+
children,
|
|
64
|
+
className,
|
|
65
|
+
description,
|
|
66
|
+
error,
|
|
67
|
+
isRequired = false,
|
|
68
|
+
label,
|
|
69
|
+
}: FieldGroupProps): ReactNode => {
|
|
70
|
+
const baseId = useUniqueId()
|
|
71
|
+
|
|
72
|
+
const descriptionId = `${baseId}-description`
|
|
73
|
+
|
|
74
|
+
const errorId = `${baseId}-error`
|
|
75
|
+
|
|
76
|
+
// Description first, error second, for the reason `Field` gives:
|
|
77
|
+
// a screen reader reads the list in order and the two sentences
|
|
78
|
+
// are not interchangeable.
|
|
79
|
+
const describedBy = [
|
|
80
|
+
description === undefined ? null : descriptionId,
|
|
81
|
+
error === undefined ? null : errorId,
|
|
82
|
+
]
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.join(" ")
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<fieldset
|
|
88
|
+
aria-describedby={
|
|
89
|
+
describedBy === "" ? undefined : describedBy
|
|
90
|
+
}
|
|
91
|
+
className={toClassName(
|
|
92
|
+
// A `<fieldset>` arrives with a border, an inline margin, a
|
|
93
|
+
// block padding, and `min-inline-size: min-content` — the
|
|
94
|
+
// last of which makes it refuse to shrink inside a flex or
|
|
95
|
+
// grid parent, which is the reason design systems avoid the
|
|
96
|
+
// element. All four are cleared here, once.
|
|
97
|
+
"m-0 flex min-w-0 flex-col gap-1.5 border-0 p-0",
|
|
98
|
+
className,
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
<legend className="mb-1.5 p-0 font-medium text-content-primary text-sm">
|
|
102
|
+
{label}
|
|
103
|
+
|
|
104
|
+
{isRequired ? (
|
|
105
|
+
<span
|
|
106
|
+
// Decoration, as in `Field`. An asterisk announced as
|
|
107
|
+
// "asterisk" is noise; the controls inside carry their
|
|
108
|
+
// own `required`.
|
|
109
|
+
aria-hidden="true"
|
|
110
|
+
className="ms-1 text-intent-danger-content"
|
|
111
|
+
>
|
|
112
|
+
*
|
|
113
|
+
</span>
|
|
114
|
+
) : null}
|
|
115
|
+
</legend>
|
|
116
|
+
|
|
117
|
+
{children}
|
|
118
|
+
|
|
119
|
+
{description === undefined ? null : (
|
|
120
|
+
<p
|
|
121
|
+
className="text-content-secondary text-xs"
|
|
122
|
+
id={descriptionId}
|
|
123
|
+
>
|
|
124
|
+
{description}
|
|
125
|
+
</p>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{error === undefined ? null : (
|
|
129
|
+
<p
|
|
130
|
+
className="text-intent-danger-content text-xs"
|
|
131
|
+
id={errorId}
|
|
132
|
+
>
|
|
133
|
+
{error}
|
|
134
|
+
</p>
|
|
135
|
+
)}
|
|
136
|
+
</fieldset>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -8,7 +8,8 @@ import * as LogViewerStories from "./LogViewer.stories";
|
|
|
8
8
|
|
|
9
9
|
Three repos render a scrolling log pane — mux-magic's `JobLogsDisclosure` and `StepLogs`,
|
|
10
10
|
rip-deck's `LogModal` — and between them they have four defects this component exists not to
|
|
11
|
-
have.
|
|
11
|
+
have. The fifth below is not theirs: it is defect 1 coming back through the front door, in
|
|
12
|
+
this component, found by the first app to consume it.
|
|
12
13
|
|
|
13
14
|
<Canvas of={LogViewerStories.Default} />
|
|
14
15
|
|
|
@@ -63,6 +64,40 @@ five-line result panel where the lines **are** the outcome.
|
|
|
63
64
|
|
|
64
65
|
<Canvas of={LogViewerStories.AllVariants} />
|
|
65
66
|
|
|
67
|
+
## 5. A pane measured before it has a box
|
|
68
|
+
|
|
69
|
+
Defect 1 again, and the fix for it cannot see this one. `AccordionSection` renders its panel
|
|
70
|
+
with `hidden` rather than unmounting it, deliberately — an unmounted panel loses a scroll
|
|
71
|
+
position, a partially typed form, and any subscription its content opened, and the fleet's
|
|
72
|
+
log panes are exactly that. A `hidden` subtree has **no layout**, so the mount effect reads
|
|
73
|
+
`scrollHeight 0` and writes `scrollTop = 0`. Measured in mux-magic on a 60-line pane:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
while collapsed : scrollTop 0 scrollHeight 0 clientHeight 0
|
|
77
|
+
after expanding : scrollTop 0 scrollHeight 976 clientHeight 254
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Neither `isFollowing` nor `lines` changes when the section is opened, so the effect never
|
|
81
|
+
runs again and the log opens on its **first** line. That is this component's own `}, [])`
|
|
82
|
+
bug, rebuilt out of two components whose individual decisions are both right, and invisible
|
|
83
|
+
to both of their test suites — `LogViewer`'s mounted visible, `Accordion`'s holding content
|
|
84
|
+
that never measures itself. mux-magic hit it as a consumer and worked around it downstream,
|
|
85
|
+
in a `DisclosedLogViewer` that withheld the pane until the section had been opened once.
|
|
86
|
+
That workaround is now deletable.
|
|
87
|
+
|
|
88
|
+
A `ResizeObserver` on the pane, live only while following, is the fix. Not an
|
|
89
|
+
`IntersectionObserver`: that answers "is it on screen", which is a different question with
|
|
90
|
+
two wrong answers here — a pane below the fold on a long page is not intersecting and has
|
|
91
|
+
perfectly good layout, and scrolling the page later would re-fire for no reason.
|
|
92
|
+
`ResizeObserver` answers "does it have a box, and is that box still the same size", which is
|
|
93
|
+
the precondition the measurement actually needs; per spec it does not fire at `observe()`
|
|
94
|
+
time for an element that is not being rendered, so **gaining a box is the first callback**.
|
|
95
|
+
|
|
96
|
+
It pays for itself twice: a window resize re-wraps the lines and changes what is at the
|
|
97
|
+
bottom, and a following pane belongs at the new bottom.
|
|
98
|
+
|
|
99
|
+
<Canvas of={LogViewerStories.InsideDisclosure} />
|
|
100
|
+
|
|
66
101
|
## Not every boolean is a state kind
|
|
67
102
|
|
|
68
103
|
`isFollowing` is `useState`, not `useVisibility`, and this is the first component in the
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
2
|
|
|
3
|
+
import { Accordion } from "../Accordion/Accordion.tsx"
|
|
3
4
|
import {
|
|
4
5
|
StoryCell,
|
|
5
6
|
StoryGrid,
|
|
@@ -133,3 +134,36 @@ export const AllStates: Story = {
|
|
|
133
134
|
export const Interactive: Story = {
|
|
134
135
|
args: { label: "Bay 8 rip log", lines: LONG_LINES },
|
|
135
136
|
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The pane mounted inside a **collapsed** `Accordion` section, which
|
|
140
|
+
* is how the fleet actually renders a log and is the one arrangement
|
|
141
|
+
* where following used to fail.
|
|
142
|
+
*
|
|
143
|
+
* `AccordionSection` renders its panel with `hidden`, deliberately,
|
|
144
|
+
* so the pane keeps its scroll position and its subscriptions across
|
|
145
|
+
* a collapse. A `hidden` subtree has no layout, so a pane that
|
|
146
|
+
* measures itself on mount measures `scrollHeight 0` — and neither
|
|
147
|
+
* `isFollowing` nor `lines` changes when the section is opened.
|
|
148
|
+
*
|
|
149
|
+
* Expand it: the log opens on its **last** line, not its first.
|
|
150
|
+
*/
|
|
151
|
+
export const InsideDisclosure: Story = {
|
|
152
|
+
args: { label: "Bay 9 rip log", lines: LONG_LINES },
|
|
153
|
+
render: () => (
|
|
154
|
+
<Accordion
|
|
155
|
+
items={[
|
|
156
|
+
{
|
|
157
|
+
content: (
|
|
158
|
+
<LogViewer
|
|
159
|
+
label="Bay 9 rip log"
|
|
160
|
+
lines={LONG_LINES}
|
|
161
|
+
/>
|
|
162
|
+
),
|
|
163
|
+
key: "logs",
|
|
164
|
+
label: "Bay 9 output",
|
|
165
|
+
},
|
|
166
|
+
]}
|
|
167
|
+
/>
|
|
168
|
+
),
|
|
169
|
+
}
|
|
@@ -7,8 +7,13 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
7
7
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
8
|
import * as stories from "./LogViewer.stories.tsx"
|
|
9
9
|
|
|
10
|
-
const {
|
|
11
|
-
|
|
10
|
+
const {
|
|
11
|
+
AllStates,
|
|
12
|
+
AllVariants,
|
|
13
|
+
Default,
|
|
14
|
+
InsideDisclosure,
|
|
15
|
+
Interactive,
|
|
16
|
+
} = composeStories(stories)
|
|
12
17
|
|
|
13
18
|
test("the pane is named, and is a log", async () => {
|
|
14
19
|
const { canvas, canvasElement } =
|
|
@@ -62,6 +67,64 @@ test("a full pane is scrolled to the bottom", async () => {
|
|
|
62
67
|
})
|
|
63
68
|
})
|
|
64
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The same defect, rebuilt out of two components that are each
|
|
72
|
+
* individually right — and the reason `LogViewer` watches its own box
|
|
73
|
+
* rather than trusting one mount measurement.
|
|
74
|
+
*
|
|
75
|
+
* `AccordionSection` renders `hidden` rather than unmounting, because
|
|
76
|
+
* the fleet's panels hold log panes and an unmounted panel loses its
|
|
77
|
+
* scroll position and its subscription. A `hidden` subtree has no
|
|
78
|
+
* layout box, so the mount effect reads `scrollHeight 0` and writes
|
|
79
|
+
* `scrollTop = 0` — and the effect's dependencies, `isFollowing` and
|
|
80
|
+
* the lines, are both unchanged by the reveal. Measured in mux-magic
|
|
81
|
+
* on a 60-line pane:
|
|
82
|
+
*
|
|
83
|
+
* ```
|
|
84
|
+
* while collapsed : scrollTop 0 scrollHeight 0 clientHeight 0
|
|
85
|
+
* after expanding : scrollTop 0 scrollHeight 976 clientHeight 254
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* mux-magic worked around it downstream, in `DisclosedLogViewer`, by
|
|
89
|
+
* not mounting the pane until the section had been opened once. This
|
|
90
|
+
* test is that workaround becoming deletable.
|
|
91
|
+
*/
|
|
92
|
+
test("a pane revealed by a disclosure follows the tail", async () => {
|
|
93
|
+
const { canvas } = await mountStory(InsideDisclosure)
|
|
94
|
+
|
|
95
|
+
// `hidden: true` because that is the whole precondition: the pane
|
|
96
|
+
// is in the accessibility tree's shadow, and in the layout tree not
|
|
97
|
+
// at all.
|
|
98
|
+
const pane = canvas.getByRole("log", {
|
|
99
|
+
hidden: true,
|
|
100
|
+
name: "Bay 9 rip log",
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Not a rhetorical assertion. If a future `AccordionSection`
|
|
104
|
+
// unmounts its panel instead, or stops using `hidden`, this test
|
|
105
|
+
// silently stops testing anything — so it fails here instead.
|
|
106
|
+
await expect(pane.scrollHeight).toBe(0)
|
|
107
|
+
|
|
108
|
+
expectAgentDrivable(canvas, {
|
|
109
|
+
name: "Bay 9 output",
|
|
110
|
+
role: "button",
|
|
111
|
+
}).click()
|
|
112
|
+
|
|
113
|
+
await waitFor(() => {
|
|
114
|
+
expect(pane.scrollHeight).toBeGreaterThan(
|
|
115
|
+
pane.clientHeight,
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
expect(
|
|
121
|
+
pane.scrollHeight -
|
|
122
|
+
pane.scrollTop -
|
|
123
|
+
pane.clientHeight,
|
|
124
|
+
).toBeLessThan(4)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
65
128
|
/**
|
|
66
129
|
* Following is pinned to the user's own position. Scrolling back to
|
|
67
130
|
* read the line that mentioned an error must not be undone twice a
|