@charcuterie/ui 3.6.0 → 3.8.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/dist/Checkbox/Checkbox.d.ts +39 -1
- package/dist/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/Checkbox/Checkbox.js +131 -71
- package/dist/Checkbox/Checkbox.js.map +1 -1
- package/dist/Field/Field.d.ts +67 -2
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +22 -0
- package/dist/Field/Field.js.map +1 -1
- package/dist/Field/FieldGroup.d.ts +28 -3
- package/dist/Field/FieldGroup.d.ts.map +1 -1
- package/dist/Field/FieldGroup.js +5 -2
- package/dist/Field/FieldGroup.js.map +1 -1
- package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +147 -0
- package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -0
- package/dist/VirtualizedGrid/VirtualizedGrid.js +177 -0
- package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -0
- package/dist/VirtualizedGrid/gridGap.d.ts +20 -0
- package/dist/VirtualizedGrid/gridGap.d.ts.map +1 -0
- package/dist/VirtualizedGrid/gridGap.js +20 -0
- package/dist/VirtualizedGrid/gridGap.js.map +1 -0
- package/dist/index.d.ts +3 -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 +14 -1
- package/dist/slotProps.d.ts.map +1 -1
- package/dist/slotProps.js +13 -0
- package/dist/slotProps.js.map +1 -1
- package/package.json +1 -1
- package/src/Checkbox/Checkbox.mdx +32 -0
- package/src/Checkbox/Checkbox.stories.tsx +74 -0
- package/src/Checkbox/Checkbox.test.tsx +110 -2
- package/src/Checkbox/Checkbox.tsx +234 -125
- package/src/Field/Field.mdx +41 -0
- package/src/Field/Field.stories.tsx +79 -0
- package/src/Field/Field.test.tsx +137 -0
- package/src/Field/Field.tsx +99 -25
- package/src/Field/FieldGroup.tsx +60 -19
- package/src/VirtualizedGrid/VirtualizedGrid.mdx +110 -0
- package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +258 -0
- package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +182 -0
- package/src/VirtualizedGrid/VirtualizedGrid.tsx +340 -0
- package/src/VirtualizedGrid/gridGap.ts +19 -0
- package/src/index.ts +3 -0
- package/src/slotProps.ts +16 -2
- package/src/sourceRules.test.ts +10 -1
|
@@ -329,3 +329,82 @@ export const Group: Story = {
|
|
|
329
329
|
</StoryGrid>
|
|
330
330
|
),
|
|
331
331
|
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Where a prop lands, in both components, side by side.
|
|
335
|
+
*
|
|
336
|
+
* `FieldProps` and `FieldGroupProps` were **closed** types until
|
|
337
|
+
* 2026-08-21: the only prop a caller could legally pass was
|
|
338
|
+
* `className`, so a component whose whole purpose is "configured by
|
|
339
|
+
* props, not a borrowed class" forced the borrowed class. queuepilot's
|
|
340
|
+
* `#dyn-lineup` gave up an `id` its screenshot suite drives by, and
|
|
341
|
+
* the box beside it stayed a hand-rolled `<fieldset>` because it
|
|
342
|
+
* needed `hidden`.
|
|
343
|
+
*
|
|
344
|
+
* The rule that opens them is one sentence with two readings, because
|
|
345
|
+
* the two components own different elements:
|
|
346
|
+
*
|
|
347
|
+
* - **`Field` clones its child**, so its props are that **control's**
|
|
348
|
+
* props. `name`, `placeholder`, `autoFocus`, `aria-describedby` and
|
|
349
|
+
* `id` all reach the `<input>` — `id` always did, which is why the
|
|
350
|
+
* rest go with it rather than moving it.
|
|
351
|
+
* - **`FieldGroup` wraps its children**, so its props are the
|
|
352
|
+
* **`<fieldset>`'s**. `id`, `hidden`, `ref`, and the element's own
|
|
353
|
+
* `disabled`, `form` and `name`.
|
|
354
|
+
*
|
|
355
|
+
* `className` is the exception on `Field` alone: a class describes the
|
|
356
|
+
* box a parent lays out, so it stays on the wrapping `<div>`.
|
|
357
|
+
*/
|
|
358
|
+
export const ForwardsRestProps: Story = {
|
|
359
|
+
args: {
|
|
360
|
+
children: (
|
|
361
|
+
<input className={TEXT_INPUT_CLASS} type="text" />
|
|
362
|
+
),
|
|
363
|
+
label: "Output directory",
|
|
364
|
+
},
|
|
365
|
+
render: () => (
|
|
366
|
+
<StoryGrid columns={2}>
|
|
367
|
+
<StoryCell label="field: the props are the control's">
|
|
368
|
+
<Field
|
|
369
|
+
// Composed with the field's own description and error, not
|
|
370
|
+
// replaced by them — `aria-describedby` is the one prop in
|
|
371
|
+
// the set that is a list.
|
|
372
|
+
aria-describedby="scratch-path-policy"
|
|
373
|
+
description="Absolute paths only."
|
|
374
|
+
error="That path is not writable."
|
|
375
|
+
label="Scratch path"
|
|
376
|
+
name="scratchPath"
|
|
377
|
+
placeholder="/mnt/scratch"
|
|
378
|
+
>
|
|
379
|
+
<input className={TEXT_INPUT_CLASS} type="text" />
|
|
380
|
+
</Field>
|
|
381
|
+
|
|
382
|
+
<p
|
|
383
|
+
className="mt-1.5 text-content-secondary text-sm"
|
|
384
|
+
id="scratch-path-policy"
|
|
385
|
+
>
|
|
386
|
+
Cleared between runs.
|
|
387
|
+
</p>
|
|
388
|
+
</StoryCell>
|
|
389
|
+
|
|
390
|
+
<StoryCell label="group: the props are the fieldset's">
|
|
391
|
+
<FieldGroup
|
|
392
|
+
description="The whole group is turned off by the element itself."
|
|
393
|
+
// `disabled` on a `<fieldset>` is not decoration: the
|
|
394
|
+
// platform disables every control inside it. A `<div>`-based
|
|
395
|
+
// group could not have offered this at all.
|
|
396
|
+
disabled
|
|
397
|
+
id="playback-lineup"
|
|
398
|
+
label="Playback"
|
|
399
|
+
>
|
|
400
|
+
<input
|
|
401
|
+
aria-label="Episodes"
|
|
402
|
+
className={TEXT_INPUT_CLASS}
|
|
403
|
+
defaultValue="12"
|
|
404
|
+
type="number"
|
|
405
|
+
/>
|
|
406
|
+
</FieldGroup>
|
|
407
|
+
</StoryCell>
|
|
408
|
+
</StoryGrid>
|
|
409
|
+
),
|
|
410
|
+
}
|
package/src/Field/Field.test.tsx
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
AllStates,
|
|
14
14
|
AllVariants,
|
|
15
15
|
Default,
|
|
16
|
+
ForwardsRestProps,
|
|
16
17
|
Group,
|
|
17
18
|
Nested,
|
|
18
19
|
} = composeStories(stories)
|
|
@@ -447,3 +448,139 @@ test("a group describes its error rather than asserting invalidity", async () =>
|
|
|
447
448
|
"A pattern needs at least one capture group.",
|
|
448
449
|
)
|
|
449
450
|
})
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The gap this closes, stated as the two things that were impossible.
|
|
454
|
+
*
|
|
455
|
+
* `FieldProps` and `FieldGroupProps` were closed types, so the only
|
|
456
|
+
* prop that reached an element was `className` — which is the escape
|
|
457
|
+
* hatch the "configured by props, not a borrowed class" rule exists to
|
|
458
|
+
* close. A component that accepts only `className` forces the very
|
|
459
|
+
* thing the rule forbids, and queuepilot paid for it twice: a
|
|
460
|
+
* `FieldGroup` that had to give up the `id` its screenshot suite
|
|
461
|
+
* drives by, and a box beside it that stayed a hand-rolled
|
|
462
|
+
* `<fieldset>` because it needed `hidden`.
|
|
463
|
+
*
|
|
464
|
+
* `Field` clones its child, so its props are the **control's**. The
|
|
465
|
+
* assertion is which element they landed on, because that is the half
|
|
466
|
+
* that could have gone silently wrong: routing them to the wrapper
|
|
467
|
+
* would have moved every consumer's `id` off the control the
|
|
468
|
+
* `<label htmlFor>` points at.
|
|
469
|
+
*/
|
|
470
|
+
test("a rest prop reaches the control, not the box around it", async () => {
|
|
471
|
+
const { canvas, canvasElement } = await mountStory(
|
|
472
|
+
ForwardsRestProps,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
const control = expectAgentDrivable(canvas, {
|
|
476
|
+
name: "Scratch path",
|
|
477
|
+
role: "textbox",
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
await expect(control).toHaveAttribute(
|
|
481
|
+
"name",
|
|
482
|
+
"scratchPath",
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
await expect(control).toHaveAttribute(
|
|
486
|
+
"placeholder",
|
|
487
|
+
"/mnt/scratch",
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
// …and the `<div>` did not take them. `Field`'s own `className` is
|
|
491
|
+
// the one prop that stays on the box, so the two destinations have
|
|
492
|
+
// to be distinguishable in the DOM rather than merely documented.
|
|
493
|
+
await expect(
|
|
494
|
+
canvasElement.querySelector("[name='scratchPath']"),
|
|
495
|
+
).toBe(control)
|
|
496
|
+
|
|
497
|
+
await expectNoAxeViolations(canvasElement)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* `aria-describedby` is a **list**, and opening the type is what makes
|
|
502
|
+
* a caller able to write one at all. A plain spread would keep one
|
|
503
|
+
* side and drop the other — the same silent drop `mergeSlotProps`
|
|
504
|
+
* exists to prevent between two nested slots, arriving by a shorter
|
|
505
|
+
* route now that the outer writer can be the author.
|
|
506
|
+
*
|
|
507
|
+
* Order is behaviour, not formatting: what the caller passed is the
|
|
508
|
+
* outer declaration and reads first, then the field's own description,
|
|
509
|
+
* then its error.
|
|
510
|
+
*/
|
|
511
|
+
test("a caller's aria-describedby composes with the description and the error", async () => {
|
|
512
|
+
const { canvas } = await mountStory(ForwardsRestProps)
|
|
513
|
+
|
|
514
|
+
const control = expectAgentDrivable(canvas, {
|
|
515
|
+
name: "Scratch path",
|
|
516
|
+
role: "textbox",
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
const [policyId, descriptionId, errorId] = (
|
|
520
|
+
control.getAttribute("aria-describedby") ?? ""
|
|
521
|
+
).split(" ")
|
|
522
|
+
|
|
523
|
+
await expect(
|
|
524
|
+
document.getElementById(policyId ?? ""),
|
|
525
|
+
).toHaveTextContent("Cleared between runs.")
|
|
526
|
+
|
|
527
|
+
await expect(
|
|
528
|
+
document.getElementById(descriptionId ?? ""),
|
|
529
|
+
).toHaveTextContent("Absolute paths only.")
|
|
530
|
+
|
|
531
|
+
await expect(
|
|
532
|
+
document.getElementById(errorId ?? ""),
|
|
533
|
+
).toHaveTextContent("That path is not writable.")
|
|
534
|
+
|
|
535
|
+
// The error is still the only source of invalidity, and a caller's
|
|
536
|
+
// own description did not disturb it.
|
|
537
|
+
await expect(control).toHaveAttribute(
|
|
538
|
+
"aria-invalid",
|
|
539
|
+
"true",
|
|
540
|
+
)
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* The other reading of the same rule. `FieldGroup` renders its
|
|
545
|
+
* children as-is, so the only element it owns is the `<fieldset>` and
|
|
546
|
+
* a prop has nowhere else to go — including `disabled`, which on this
|
|
547
|
+
* element is not decoration: the platform disables every control
|
|
548
|
+
* inside it.
|
|
549
|
+
*
|
|
550
|
+
* This is the case queuepilot's `#dyn-lineup` needed and could not
|
|
551
|
+
* have: an `id` on the group's own element.
|
|
552
|
+
*/
|
|
553
|
+
test("a rest prop reaches the fieldset, and the fieldset disables what is inside it", async () => {
|
|
554
|
+
const { canvas, canvasElement } = await mountStory(
|
|
555
|
+
ForwardsRestProps,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
const group = expectAgentDrivable(canvas, {
|
|
559
|
+
name: "Playback",
|
|
560
|
+
role: "group",
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
await expect(group).toHaveAttribute(
|
|
564
|
+
"id",
|
|
565
|
+
"playback-lineup",
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
await expect(group.tagName).toBe("FIELDSET")
|
|
569
|
+
|
|
570
|
+
// The `<legend>` still names the group, and the group's own
|
|
571
|
+
// description still reaches it, so the rest props did not overwrite
|
|
572
|
+
// the wiring they were spread beside.
|
|
573
|
+
await expect(
|
|
574
|
+
document.getElementById(
|
|
575
|
+
group.getAttribute("aria-describedby") ?? "",
|
|
576
|
+
),
|
|
577
|
+
).toHaveTextContent(
|
|
578
|
+
"The whole group is turned off by the element itself.",
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
await expect(
|
|
582
|
+
canvasElement.querySelector("input[type='number']"),
|
|
583
|
+
).toBeDisabled()
|
|
584
|
+
|
|
585
|
+
await expectNoAxeViolations(canvasElement)
|
|
586
|
+
})
|
package/src/Field/Field.tsx
CHANGED
|
@@ -2,36 +2,88 @@ import {
|
|
|
2
2
|
useClonedChild,
|
|
3
3
|
useUniqueId,
|
|
4
4
|
} from "@charcuterie/logic"
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
ComponentPropsWithRef,
|
|
7
|
+
ReactElement,
|
|
8
|
+
ReactNode,
|
|
9
|
+
Ref,
|
|
10
|
+
} from "react"
|
|
6
11
|
|
|
7
12
|
import type { SlotProps } from "../slotProps.ts"
|
|
8
13
|
import { mergeSlotProps } from "../slotProps.ts"
|
|
9
14
|
import { toClassName } from "../toClassName.ts"
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Everything else a caller writes on a `Field` reaches **the control
|
|
18
|
+
* it clones onto**, not the `<div>` around it.
|
|
19
|
+
*
|
|
20
|
+
* That is not a new destination, it is the existing one finally
|
|
21
|
+
* declared. `SlotProps` was a *closed* five-key type over a runtime
|
|
22
|
+
* that already spread `...receivedSlotProps` into the clone, so
|
|
23
|
+
* `name`, `placeholder`, `autoFocus`, `onBlur` and every `aria-*` all
|
|
24
|
+
* arrived at the `<input>` and none of them type-checked. The only
|
|
25
|
+
* prop a consumer could legally pass was `className` — which is the
|
|
26
|
+
* one escape hatch the fleet's "configured by props, not a borrowed
|
|
27
|
+
* class" rule is trying to close, so the closed type forced the very
|
|
28
|
+
* thing the rule forbids
|
|
29
|
+
* ([decision](../../../../docs/decisions/2026-08-21-a-slot-components-rest-props-are-the-controls-props.md)).
|
|
30
|
+
*
|
|
31
|
+
* `className` stays on the `<div>`, alone, because a class is a
|
|
32
|
+
* statement about the box a **parent** lays out
|
|
33
|
+
* ([decision](../../../../docs/decisions/2026-08-19-classname-is-the-outermost-box-a-component-renders.md)).
|
|
34
|
+
* Every other prop is a statement about the control, and `id` is the
|
|
35
|
+
* proof: it has named the control since the component shipped, and a
|
|
36
|
+
* dated record settles its precedence
|
|
37
|
+
* ([decision](../../../../docs/decisions/2026-08-05-field-adopts-the-childs-own-id.md)).
|
|
38
|
+
* Routing the rest to the `<div>` would have moved it, silently, in
|
|
39
|
+
* every consumer.
|
|
40
|
+
*
|
|
41
|
+
* `"input"` because `SlotProps` already picks from
|
|
42
|
+
* `InputHTMLAttributes` and this is the same set widened, not a
|
|
43
|
+
* second opinion about what a control is. `ref` is re-declared over
|
|
44
|
+
* `HTMLElement`: the child may be a `Picker`'s `<button>` or a
|
|
45
|
+
* `contenteditable` `<div>`, so `Ref<HTMLInputElement>` would be a
|
|
46
|
+
* narrower claim than the slot makes.
|
|
47
|
+
*/
|
|
48
|
+
type ForwardedControlProps = Omit<
|
|
49
|
+
ComponentPropsWithRef<"input">,
|
|
50
|
+
"children" | "className" | "ref"
|
|
51
|
+
>
|
|
52
|
+
|
|
53
|
+
export type FieldProps = ForwardedControlProps &
|
|
54
|
+
SlotProps & {
|
|
55
|
+
/**
|
|
56
|
+
* The control. **Cloned, not wrapped** — the same slot contract
|
|
57
|
+
* as `Popover`'s trigger, so a `Field` can go around an `<input>`,
|
|
58
|
+
* a `<textarea>`, a `Picker`, or an app's own control without
|
|
59
|
+
* changing that control's layout.
|
|
60
|
+
*/
|
|
61
|
+
children: ReactElement
|
|
62
|
+
/**
|
|
63
|
+
* The `<div>` around the label, the control and the messages —
|
|
64
|
+
* the one prop that does **not** reach the control.
|
|
65
|
+
*/
|
|
66
|
+
className?: string
|
|
67
|
+
/**
|
|
68
|
+
* Standing help — units, a format, a warning about what the value
|
|
69
|
+
* does. Announced with the control, before any error.
|
|
70
|
+
*/
|
|
71
|
+
description?: ReactNode
|
|
72
|
+
/**
|
|
73
|
+
* Present means invalid. There is no separate `isInvalid`, because
|
|
74
|
+
* two sources for one fact is how a control ends up
|
|
75
|
+
* `aria-invalid="true"` with nothing saying why — which is what
|
|
76
|
+
* `RegexWithFlagsField` does in mux-magic today.
|
|
77
|
+
*/
|
|
78
|
+
error?: ReactNode
|
|
79
|
+
isRequired?: boolean
|
|
80
|
+
label: ReactNode
|
|
81
|
+
/**
|
|
82
|
+
* Merged onto the control's own, never replacing it — the wiring
|
|
83
|
+
* rule `mergeSlotWiring` states for every slot.
|
|
84
|
+
*/
|
|
85
|
+
ref?: Ref<HTMLElement>
|
|
86
|
+
}
|
|
35
87
|
|
|
36
88
|
/**
|
|
37
89
|
* Sixteen files in the fleet spell a label, a control, and a hint,
|
|
@@ -108,6 +160,28 @@ export type FieldProps = SlotProps & {
|
|
|
108
160
|
* `SlotProps` arriving from an outer slot is forwarded down, and
|
|
109
161
|
* `aria-describedby` is merged rather than replaced.
|
|
110
162
|
* `slotProps.ts` has the reasoning.
|
|
163
|
+
*
|
|
164
|
+
* ### …and a pass-through cannot tell who wrote the prop
|
|
165
|
+
*
|
|
166
|
+
* Which is why the rest props are the **control's** props and not the
|
|
167
|
+
* `<div>`'s. `receivedSlotProps` below is one object holding two
|
|
168
|
+
* things that are indistinguishable at runtime: what a cloning
|
|
169
|
+
* ancestor injected, and what the author typed on the `Field` itself.
|
|
170
|
+
* A `Tooltip` above hands down `getReferenceProps()` — hover, focus
|
|
171
|
+
* and dismiss handlers, `refs.setReference`, and an
|
|
172
|
+
* `aria-describedby` naming the tip — and every bit of it has to
|
|
173
|
+
* reach the `<input>` at the bottom. Sending the rest to the wrapper
|
|
174
|
+
* would need a closed runtime allow-list to hold that case back, and
|
|
175
|
+
* an allow-list is `FieldTooltip`'s silent drop rebuilt inside the
|
|
176
|
+
* fix for it: the first slot that writes an `aria-expanded` is broken
|
|
177
|
+
* and nothing says so.
|
|
178
|
+
*
|
|
179
|
+
* So there is one rule, and it has no exceptions to remember:
|
|
180
|
+
* `className` is the box, everything else is the control. A caller
|
|
181
|
+
* that needs a handle on the **box** — an `id` for a screenshot, a
|
|
182
|
+
* `hidden` that takes the label with it — wants `FieldGroup`, which
|
|
183
|
+
* wraps rather than clones and whose rest props land on its own
|
|
184
|
+
* `<fieldset>`.
|
|
111
185
|
*/
|
|
112
186
|
export const Field = ({
|
|
113
187
|
children,
|
package/src/Field/FieldGroup.tsx
CHANGED
|
@@ -1,23 +1,53 @@
|
|
|
1
1
|
import { useUniqueId } from "@charcuterie/logic"
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ComponentPropsWithRef,
|
|
4
|
+
ReactNode,
|
|
5
|
+
} from "react"
|
|
3
6
|
|
|
7
|
+
import { mergeSlotProps } from "../slotProps.ts"
|
|
4
8
|
import { toClassName } from "../toClassName.ts"
|
|
5
9
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Everything a caller writes on a `FieldGroup` reaches the
|
|
12
|
+
* `<fieldset>` — `id`, `hidden`, `data-*`, `aria-*`, `ref`, and the
|
|
13
|
+
* three attributes that are the element's own: `disabled`, `form`
|
|
14
|
+
* and `name`.
|
|
15
|
+
*
|
|
16
|
+
* This is the **opposite** destination from `Field`'s, and the
|
|
17
|
+
* difference is not a preference. `Field` clones onto its child, so
|
|
18
|
+
* its props are that control's props; `FieldGroup` renders its
|
|
19
|
+
* children as-is, so the only element it owns is the `<fieldset>` and
|
|
20
|
+
* a prop has nowhere else to go. One rule reads both:
|
|
21
|
+
* ***a component's props land on the element it owns***
|
|
22
|
+
* ([decision](../../../../docs/decisions/2026-08-21-a-slot-components-rest-props-are-the-controls-props.md)).
|
|
23
|
+
*
|
|
24
|
+
* `disabled` is worth knowing about before reaching for it. On a
|
|
25
|
+
* `<fieldset>` it is not decoration — the platform disables every
|
|
26
|
+
* form control inside the element, not just the box itself. That is
|
|
27
|
+
* usually what a caller wants and occasionally a surprise, and it is
|
|
28
|
+
* a thing a `<div>`-based group could not have offered at all.
|
|
29
|
+
*/
|
|
30
|
+
export type FieldGroupProps =
|
|
31
|
+
ComponentPropsWithRef<"fieldset"> & {
|
|
32
|
+
/**
|
|
33
|
+
* The controls. **Rendered as-is, not cloned** — that is the whole
|
|
34
|
+
* difference from `Field`, and it is why this is a separate
|
|
35
|
+
* component rather than a boolean on that one.
|
|
36
|
+
*/
|
|
37
|
+
children: ReactNode
|
|
38
|
+
/**
|
|
39
|
+
* The `<fieldset>`, which is also the outermost element this
|
|
40
|
+
* component renders — so `className` is not the exception here that
|
|
41
|
+
* it is on `Field`.
|
|
42
|
+
*/
|
|
43
|
+
className?: string
|
|
44
|
+
/** Standing help for the group as a whole. */
|
|
45
|
+
description?: ReactNode
|
|
46
|
+
/** Present means the group is invalid. */
|
|
47
|
+
error?: ReactNode
|
|
48
|
+
isRequired?: boolean
|
|
49
|
+
label: ReactNode
|
|
50
|
+
}
|
|
21
51
|
|
|
22
52
|
/**
|
|
23
53
|
* A label over **several** controls.
|
|
@@ -66,6 +96,7 @@ export const FieldGroup = ({
|
|
|
66
96
|
error,
|
|
67
97
|
isRequired = false,
|
|
68
98
|
label,
|
|
99
|
+
...receivedFieldsetProps
|
|
69
100
|
}: FieldGroupProps): ReactNode => {
|
|
70
101
|
const baseId = useUniqueId()
|
|
71
102
|
|
|
@@ -85,9 +116,19 @@ export const FieldGroup = ({
|
|
|
85
116
|
|
|
86
117
|
return (
|
|
87
118
|
<fieldset
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
119
|
+
/*
|
|
120
|
+
Through `mergeSlotProps`, not around it. A caller may now pass
|
|
121
|
+
an `aria-describedby` of its own, and that attribute is the
|
|
122
|
+
one prop in the set that is a **list** — a plain spread keeps
|
|
123
|
+
whichever side is written last and drops the other, silently,
|
|
124
|
+
which is the defect `mergeSlotProps` exists to prevent one
|
|
125
|
+
level down. Received first, then this group's description and
|
|
126
|
+
error, for the reason the ordering array above gives.
|
|
127
|
+
*/
|
|
128
|
+
{...mergeSlotProps(receivedFieldsetProps, {
|
|
129
|
+
"aria-describedby":
|
|
130
|
+
describedBy === "" ? undefined : describedBy,
|
|
131
|
+
})}
|
|
91
132
|
className={toClassName(
|
|
92
133
|
// A `<fieldset>` arrives with a border, an inline margin, a
|
|
93
134
|
// block padding, and `min-inline-size: min-content` — the
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as VirtualizedGridStories from "./VirtualizedGrid.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={VirtualizedGridStories} />
|
|
6
|
+
|
|
7
|
+
# VirtualizedGrid
|
|
8
|
+
|
|
9
|
+
**`AdaptiveGrid`, with only the rows you can see mounted.** Same column rule, same
|
|
10
|
+
two-box structure, same content cap. The one difference is that a list of 2,000 costs
|
|
11
|
+
about as much as a list of 40.
|
|
12
|
+
|
|
13
|
+
<Canvas of={VirtualizedGridStories.Default} />
|
|
14
|
+
|
|
15
|
+
## Props
|
|
16
|
+
|
|
17
|
+
<Controls />
|
|
18
|
+
|
|
19
|
+
## When to reach for it
|
|
20
|
+
|
|
21
|
+
Not by default. `AdaptiveGrid` renders everything it is handed, and that is the right
|
|
22
|
+
behaviour: the DOM is searchable with `Ctrl+F`, `Ctrl+End` goes where you expect, and
|
|
23
|
+
nothing has to be measured. Reach for this one when the item count is genuinely
|
|
24
|
+
unbounded — a library listing, an inbox, a backlog that grows.
|
|
25
|
+
|
|
26
|
+
The number that prompted it, measured on QueuePilot's Pending page:
|
|
27
|
+
|
|
28
|
+
| | Every item rendered |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| Items | 2,162 |
|
|
31
|
+
| DOM nodes | 19,933 |
|
|
32
|
+
| `<img>` elements | 2,162 |
|
|
33
|
+
| `<button>` elements | 4,371 |
|
|
34
|
+
| Time to settle | 7.8 s |
|
|
35
|
+
| Scroll rate | ~43 fps |
|
|
36
|
+
|
|
37
|
+
Nothing about that page was wrong except how much of it existed at once.
|
|
38
|
+
|
|
39
|
+
## Rows are the unit
|
|
40
|
+
|
|
41
|
+
The virtualizer counts **rows**, not items. An item-level virtualizer would have to place
|
|
42
|
+
every item on both axes; a row-level one gets the inline axis from CSS grid for free and
|
|
43
|
+
only ever answers "how far down".
|
|
44
|
+
|
|
45
|
+
So `itemBlockSize` is a row-height estimate, and unlike `AdaptiveGrid`'s it is not a
|
|
46
|
+
commitment — every mounted row is measured and the estimate corrected. It still wants to
|
|
47
|
+
be close, because it is what the scrollbar is sized from before anything has been
|
|
48
|
+
measured. A bad guess makes the bar jump under the thumb on the first scroll.
|
|
49
|
+
|
|
50
|
+
## It scrolls the page, not a box of its own
|
|
51
|
+
|
|
52
|
+
`useWindowVirtualizer`, so the page scrolls the way every other page in the fleet
|
|
53
|
+
scrolls — the header leaves, the scrollbar is the browser's, `Ctrl+End` works. A
|
|
54
|
+
component that grew its own `overflow: auto` well would trap the wheel and strand the
|
|
55
|
+
page header above it.
|
|
56
|
+
|
|
57
|
+
The cost is that the grid has to know how far down the document it starts, which it
|
|
58
|
+
measures in a layout effect and feeds to the virtualizer as `scrollMargin`. Get that
|
|
59
|
+
wrong and every row is offset by the height of the header above it.
|
|
60
|
+
|
|
61
|
+
## The column rule is inherited, not reimplemented
|
|
62
|
+
|
|
63
|
+
<Canvas of={VirtualizedGridStories.AllVariants} />
|
|
64
|
+
|
|
65
|
+
Columns come from `useAdaptiveColumns` — the identical hook `AdaptiveGrid` calls. The
|
|
66
|
+
consequence that reads backwards holds here too: a **taller** window takes **fewer**
|
|
67
|
+
columns, because height is what a column is bought with.
|
|
68
|
+
|
|
69
|
+
This is deliberate. A second column heuristic that windowed lists followed and flow lists
|
|
70
|
+
did not would be a fleet where the same content lays out two ways depending on how many
|
|
71
|
+
of it there are.
|
|
72
|
+
|
|
73
|
+
## The states that are easy to miss
|
|
74
|
+
|
|
75
|
+
<Canvas of={VirtualizedGridStories.AllStates} />
|
|
76
|
+
|
|
77
|
+
An empty list draws a zero-height box and no rows — a windowed grid must not reserve
|
|
78
|
+
space for a list that is not there, and it is the state a filter lands on. One item is
|
|
79
|
+
one column, the same answer `AdaptiveGrid` gives.
|
|
80
|
+
|
|
81
|
+
## It is still read from the container
|
|
82
|
+
|
|
83
|
+
<Canvas of={VirtualizedGridStories.Responsive} />
|
|
84
|
+
|
|
85
|
+
The browser never moves and the column count still falls. Inline size comes off a
|
|
86
|
+
`ResizeObserver` on the container, so a grid beside a rail gets the room it actually has
|
|
87
|
+
rather than the room the window claims.
|
|
88
|
+
|
|
89
|
+
## Accessibility
|
|
90
|
+
|
|
91
|
+
A mounted window is a lie about length, so the true length is stated rather than implied:
|
|
92
|
+
|
|
93
|
+
- `role="list"` on the grid, with the `label` prop as its accessible name.
|
|
94
|
+
- `aria-setsize` on every cell, carrying the **full** item count.
|
|
95
|
+
- `aria-posinset` naming each cell's real position in that full list.
|
|
96
|
+
- `role="presentation"` on the row wrappers — they are a positioning device and have no
|
|
97
|
+
business appearing in the structure.
|
|
98
|
+
|
|
99
|
+
Without `aria-setsize` a screen reader announces "list, 40 items" on a list of 2,000.
|
|
100
|
+
|
|
101
|
+
Two things windowing genuinely costs, and neither has a fix at this layer: the browser's
|
|
102
|
+
`Ctrl+F` only searches what is mounted, and so does "select all". A page that needs
|
|
103
|
+
either owes its users a search field of its own.
|
|
104
|
+
|
|
105
|
+
## `getItemKey`
|
|
106
|
+
|
|
107
|
+
Pass one. Without it the key is the array index, which is wrong the moment the list is
|
|
108
|
+
filtered or an item is removed from the middle — React reuses the mounted subtree and the
|
|
109
|
+
wrong tile inherits the wrong open menu. That is not hypothetical; it is what a
|
|
110
|
+
dismiss-an-item list does on every interaction.
|