@charcuterie/ui 3.5.0 → 3.7.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/Stepper/Stepper.d.ts +89 -0
- package/dist/Stepper/Stepper.d.ts.map +1 -0
- package/dist/Stepper/Stepper.js +87 -0
- package/dist/Stepper/Stepper.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -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/Stepper/Stepper.mdx +86 -0
- package/src/Stepper/Stepper.stories.tsx +224 -0
- package/src/Stepper/Stepper.test.tsx +112 -0
- package/src/Stepper/Stepper.tsx +246 -0
- package/src/index.ts +7 -0
- package/src/slotProps.ts +16 -2
- package/src/sourceRules.test.ts +8 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useUniqueId } from "@charcuterie/logic"
|
|
1
2
|
import type { ControlSize } from "@charcuterie/tokens"
|
|
2
3
|
import type { ChangeEvent, ReactNode } from "react"
|
|
3
4
|
|
|
@@ -6,6 +7,20 @@ import { toClassName } from "../toClassName.ts"
|
|
|
6
7
|
|
|
7
8
|
export type CheckboxProps = {
|
|
8
9
|
className?: string
|
|
10
|
+
/**
|
|
11
|
+
* Standing help under the box — units, a consequence, a warning
|
|
12
|
+
* about what ticking it does. Same slot, same words and same
|
|
13
|
+
* announcement order as `Field`'s: bound with `aria-describedby`,
|
|
14
|
+
* so a screen reader reads it **with** the control rather than
|
|
15
|
+
* announcing it from nowhere.
|
|
16
|
+
*
|
|
17
|
+
* It renders **outside** the `<label>` on purpose. A `<label>`'s
|
|
18
|
+
* text content *is* the control's accessible name, so a hint put
|
|
19
|
+
* inside it would be read twice — once as part of the name, once as
|
|
20
|
+
* the description — and a pointer press anywhere on the hint would
|
|
21
|
+
* toggle the box.
|
|
22
|
+
*/
|
|
23
|
+
description?: ReactNode
|
|
9
24
|
/**
|
|
10
25
|
* The caller's own id, when it has one — a stable target for an
|
|
11
26
|
* autofill hint or a deep link. Left to React's `useId` on the
|
|
@@ -77,6 +92,25 @@ const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
|
|
|
77
92
|
lg: "text-lg",
|
|
78
93
|
}
|
|
79
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The description hangs under the **label text**, not under the box,
|
|
97
|
+
* so the hint and the words it explains share a left edge.
|
|
98
|
+
*
|
|
99
|
+
* Written out per size rather than computed, because the offset is
|
|
100
|
+
* `BOX_SIZE_CLASS` plus the label row's `gap-2` and a class name is
|
|
101
|
+
* never interpolated — `` `ps-${…}` `` generates nothing and fails
|
|
102
|
+
* silently. sm is 0.875rem + 0.5rem, md is 1rem + 0.5rem, lg is
|
|
103
|
+
* 1.25rem + 0.5rem.
|
|
104
|
+
*/
|
|
105
|
+
const DESCRIPTION_INDENT_CLASS: Record<
|
|
106
|
+
ControlSize,
|
|
107
|
+
string
|
|
108
|
+
> = {
|
|
109
|
+
sm: "ps-5.5",
|
|
110
|
+
md: "ps-6",
|
|
111
|
+
lg: "ps-7",
|
|
112
|
+
}
|
|
113
|
+
|
|
80
114
|
/**
|
|
81
115
|
* A single boolean, on or off, that owns nothing but itself.
|
|
82
116
|
*
|
|
@@ -97,6 +131,30 @@ const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
|
|
|
97
131
|
* also the one label association that needs no `for` at all, so there
|
|
98
132
|
* is no minted-then-dropped id to get wrong.
|
|
99
133
|
*
|
|
134
|
+
* ### A description is the one thing that cannot go in the `<label>`
|
|
135
|
+
*
|
|
136
|
+
* `Field` has had a `description` since it shipped and this component
|
|
137
|
+
* had none, so a hint could not follow its checkbox. Downstream that
|
|
138
|
+
* meant one hint out of three in a group stayed an app-styled
|
|
139
|
+
* paragraph and the box carried two hint typographies.
|
|
140
|
+
*
|
|
141
|
+
* The slot is here now, and it renders **outside** the `<label>`.
|
|
142
|
+
* A `<label>`'s text content is the control's accessible name, so a
|
|
143
|
+
* hint inside it is announced twice — once inside the name, once
|
|
144
|
+
* again as the description — and a pointer press on the hint toggles
|
|
145
|
+
* the box, which is not what a sentence of explanation should do. So
|
|
146
|
+
* the `<label>` is no longer the outermost element: a `<div>` holds
|
|
147
|
+
* the row and the hint, `className` moves out to it (`className` is
|
|
148
|
+
* the outermost box a component renders, and that rule does not bend
|
|
149
|
+
* for this one), and `aria-describedby` binds the two back together.
|
|
150
|
+
*
|
|
151
|
+
* There is no `error` here to go with it. `Field` derives
|
|
152
|
+
* `aria-invalid` from its error, and a lone boolean is almost never
|
|
153
|
+
* the invalid thing — a **group** of them is, and a group's error
|
|
154
|
+
* belongs on the `FieldGroup` around it, described rather than
|
|
155
|
+
* asserted. Adding one per box would put `aria-invalid` on a control
|
|
156
|
+
* that is not itself wrong.
|
|
157
|
+
*
|
|
100
158
|
* ### The `<input>` is the store
|
|
101
159
|
*
|
|
102
160
|
* `defaultChecked`, not `checked`. The DOM owns a checkbox's state
|
|
@@ -108,6 +166,7 @@ const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
|
|
|
108
166
|
*/
|
|
109
167
|
export const Checkbox = ({
|
|
110
168
|
className,
|
|
169
|
+
description,
|
|
111
170
|
id,
|
|
112
171
|
isChecked = false,
|
|
113
172
|
isDisabled = false,
|
|
@@ -116,142 +175,192 @@ export const Checkbox = ({
|
|
|
116
175
|
onChange,
|
|
117
176
|
size = "md",
|
|
118
177
|
value,
|
|
119
|
-
}: CheckboxProps): ReactNode =>
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// keeps the full shape and colour and just turns them down, which
|
|
136
|
-
// is the "present but inactive" the box actually is. Read-only is
|
|
137
|
-
// *not* dimmed — its whole point is that the value stays readable.
|
|
138
|
-
// The same treatment is on `Switch` and `RadioGroup`.
|
|
139
|
-
isDisabled && "opacity-60",
|
|
140
|
-
className,
|
|
141
|
-
)}
|
|
142
|
-
>
|
|
143
|
-
<span className="relative grid shrink-0 place-items-center">
|
|
144
|
-
<input
|
|
145
|
-
aria-readonly={isReadOnly || undefined}
|
|
178
|
+
}: CheckboxProps): ReactNode => {
|
|
179
|
+
const baseId = useUniqueId()
|
|
180
|
+
|
|
181
|
+
const descriptionId = `${baseId}-description`
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div
|
|
185
|
+
className={toClassName(
|
|
186
|
+
// The row and its hint stack, and the box stays inline-level so
|
|
187
|
+
// a `Checkbox` still sits in a sentence, a table cell or a flex
|
|
188
|
+
// row exactly where the bare `<label>` used to.
|
|
189
|
+
"inline-flex flex-col gap-1",
|
|
190
|
+
className,
|
|
191
|
+
)}
|
|
192
|
+
>
|
|
193
|
+
<label
|
|
146
194
|
className={toClassName(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
"peer appearance-none rounded-sm border-2 bg-surface-sunken transition-colors duration-(--duration-fast) ease-standard",
|
|
152
|
-
// Read-only wears the **neutral** intent, not the accent: full
|
|
153
|
-
// contrast (so the value reads) but plainly not an actionable
|
|
154
|
-
// accent control, with a softer `border-default` resting edge.
|
|
155
|
-
// Three distinct states result — enabled (accent, crisp),
|
|
156
|
-
// disabled (accent, dimmed by the wrapper), read-only (neutral).
|
|
157
|
-
// No `disabled:` colour override; the wrapper's `opacity-60`
|
|
158
|
-
// does the dimming, and doubling it on the box is what made a
|
|
159
|
-
// disabled checkbox vanish.
|
|
160
|
-
// `indeterminate:` wears the same fill as `checked:`. It is
|
|
161
|
-
// the state a *group* box is in when some of its members are
|
|
162
|
-
// ticked, and it cannot be written in markup at all —
|
|
163
|
-
// `indeterminate` is a DOM property with no HTML attribute —
|
|
164
|
-
// so the only way to reach it is a consumer writing to the
|
|
165
|
-
// input. `DataTable`'s select-all box does exactly that, and
|
|
166
|
-
// before this line it painted **nothing**: the property was
|
|
167
|
-
// set, screen readers announced "mixed", and the box on
|
|
168
|
-
// screen was indistinguishable from empty.
|
|
169
|
-
isReadOnly
|
|
170
|
-
? "border-border-default checked:border-intent-neutral-solid checked:bg-intent-neutral-solid indeterminate:border-intent-neutral-solid indeterminate:bg-intent-neutral-solid"
|
|
171
|
-
: "border-border-strong checked:border-intent-accent-solid checked:bg-intent-accent-solid indeterminate:border-intent-accent-solid indeterminate:bg-intent-accent-solid",
|
|
195
|
+
"inline-flex items-center gap-2 text-content-secondary select-none",
|
|
196
|
+
TEXT_SIZE_CLASS[size],
|
|
197
|
+
// One cursor, chosen by state — two cursor utilities of the same
|
|
198
|
+
// specificity do not resolve by class-list order.
|
|
172
199
|
isDisabled
|
|
173
200
|
? "cursor-not-allowed"
|
|
174
201
|
: isReadOnly
|
|
175
202
|
? "cursor-default"
|
|
176
203
|
: "cursor-pointer",
|
|
177
|
-
|
|
178
|
-
|
|
204
|
+
// Disabled dims the whole control with opacity rather than fading
|
|
205
|
+
// the box to a quieter token. The token scale has no step between
|
|
206
|
+
// `border-default` and `border-strong`, so a muted outline was
|
|
207
|
+
// either invisible or indistinguishable from enabled; `opacity-60`
|
|
208
|
+
// keeps the full shape and colour and just turns them down, which
|
|
209
|
+
// is the "present but inactive" the box actually is. Read-only is
|
|
210
|
+
// *not* dimmed — its whole point is that the value stays readable.
|
|
211
|
+
// The same treatment is on `Switch` and `RadioGroup`.
|
|
212
|
+
//
|
|
213
|
+
// It stays on the `<label>` and does NOT reach the description
|
|
214
|
+
// below, which is a contrast decision rather than an oversight.
|
|
215
|
+
// axe exempts a disabled control and its own label from the
|
|
216
|
+
// colour-contrast rule; a sibling paragraph gets no such
|
|
217
|
+
// exemption, and `content-secondary` at 60% measures **3.77:1**
|
|
218
|
+
// on `midnight` against a 4.5 threshold. A hint nobody can read
|
|
219
|
+
// is worse than a hint that does not dim with its control, and
|
|
220
|
+
// the hint is the part a user reads precisely when they are
|
|
221
|
+
// asking why the box is turned off.
|
|
222
|
+
isDisabled && "opacity-60",
|
|
179
223
|
)}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
224
|
+
>
|
|
225
|
+
<span className="relative grid shrink-0 place-items-center">
|
|
226
|
+
<input
|
|
227
|
+
aria-describedby={
|
|
228
|
+
description === undefined
|
|
229
|
+
? undefined
|
|
230
|
+
: descriptionId
|
|
231
|
+
}
|
|
232
|
+
aria-readonly={isReadOnly || undefined}
|
|
233
|
+
className={toClassName(
|
|
234
|
+
// `border-2` is the family's shared weight — the `Switch`
|
|
235
|
+
// track's `ring-2` and the `RadioGroup` ring match it, so the
|
|
236
|
+
// three read as one set — and a 2px edge is also what keeps a
|
|
237
|
+
// disabled box visible where a 1px one washed out.
|
|
238
|
+
"peer appearance-none rounded-sm border-2 bg-surface-sunken transition-colors duration-(--duration-fast) ease-standard",
|
|
239
|
+
// Read-only wears the **neutral** intent, not the accent: full
|
|
240
|
+
// contrast (so the value reads) but plainly not an actionable
|
|
241
|
+
// accent control, with a softer `border-default` resting edge.
|
|
242
|
+
// Three distinct states result — enabled (accent, crisp),
|
|
243
|
+
// disabled (accent, dimmed by the wrapper), read-only (neutral).
|
|
244
|
+
// No `disabled:` colour override; the wrapper's `opacity-60`
|
|
245
|
+
// does the dimming, and doubling it on the box is what made a
|
|
246
|
+
// disabled checkbox vanish.
|
|
247
|
+
// `indeterminate:` wears the same fill as `checked:`. It is
|
|
248
|
+
// the state a *group* box is in when some of its members are
|
|
249
|
+
// ticked, and it cannot be written in markup at all —
|
|
250
|
+
// `indeterminate` is a DOM property with no HTML attribute —
|
|
251
|
+
// so the only way to reach it is a consumer writing to the
|
|
252
|
+
// input. `DataTable`'s select-all box does exactly that, and
|
|
253
|
+
// before this line it painted **nothing**: the property was
|
|
254
|
+
// set, screen readers announced "mixed", and the box on
|
|
255
|
+
// screen was indistinguishable from empty.
|
|
256
|
+
isReadOnly
|
|
257
|
+
? "border-border-default checked:border-intent-neutral-solid checked:bg-intent-neutral-solid indeterminate:border-intent-neutral-solid indeterminate:bg-intent-neutral-solid"
|
|
258
|
+
: "border-border-strong checked:border-intent-accent-solid checked:bg-intent-accent-solid indeterminate:border-intent-accent-solid indeterminate:bg-intent-accent-solid",
|
|
259
|
+
isDisabled
|
|
260
|
+
? "cursor-not-allowed"
|
|
261
|
+
: isReadOnly
|
|
262
|
+
? "cursor-default"
|
|
263
|
+
: "cursor-pointer",
|
|
264
|
+
BOX_SIZE_CLASS[size],
|
|
265
|
+
FOCUS_RING_CLASS,
|
|
266
|
+
)}
|
|
267
|
+
defaultChecked={isChecked}
|
|
268
|
+
disabled={isDisabled}
|
|
269
|
+
id={id}
|
|
270
|
+
onChange={(
|
|
271
|
+
event: ChangeEvent<HTMLInputElement>,
|
|
272
|
+
) => {
|
|
273
|
+
if (isReadOnly) {
|
|
274
|
+
return
|
|
275
|
+
}
|
|
189
276
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
277
|
+
onChange?.(event.target.checked)
|
|
278
|
+
}}
|
|
279
|
+
onClick={(clickEvent) => {
|
|
280
|
+
// Native checkboxes have no `readonly`, so the toggle is
|
|
281
|
+
// blocked here — `preventDefault` on the click stops the
|
|
282
|
+
// checked flip a pointer would make.
|
|
283
|
+
if (isReadOnly) {
|
|
284
|
+
clickEvent.preventDefault()
|
|
285
|
+
}
|
|
286
|
+
}}
|
|
287
|
+
onKeyDown={(keyEvent) => {
|
|
288
|
+
// …and Space, the keyboard toggle a click block does not
|
|
289
|
+
// cover.
|
|
290
|
+
if (isReadOnly && keyEvent.key === " ") {
|
|
291
|
+
keyEvent.preventDefault()
|
|
292
|
+
}
|
|
293
|
+
}}
|
|
294
|
+
type="checkbox"
|
|
295
|
+
value={value}
|
|
296
|
+
/>
|
|
210
297
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
298
|
+
<svg
|
|
299
|
+
aria-hidden="true"
|
|
300
|
+
className={toClassName(
|
|
301
|
+
"pointer-events-none invisible absolute peer-checked:visible peer-indeterminate:invisible",
|
|
302
|
+
// Matches whichever fill the box wears, so the tick keeps its
|
|
303
|
+
// guaranteed on-solid contrast in either intent.
|
|
304
|
+
isReadOnly
|
|
305
|
+
? "text-intent-neutral-on-solid"
|
|
306
|
+
: "text-intent-accent-on-solid",
|
|
307
|
+
CHECK_SIZE_CLASS[size],
|
|
308
|
+
)}
|
|
309
|
+
fill="none"
|
|
310
|
+
stroke="currentColor"
|
|
311
|
+
strokeLinecap="round"
|
|
312
|
+
strokeLinejoin="round"
|
|
313
|
+
strokeWidth={3}
|
|
314
|
+
viewBox="0 0 24 24"
|
|
315
|
+
>
|
|
316
|
+
<path d="M20 6 9 17l-5-5" />
|
|
317
|
+
</svg>
|
|
231
318
|
|
|
232
|
-
|
|
319
|
+
{/* The mixed mark — a dash, not a tick. Its own element rather
|
|
233
320
|
than a second `<path>` in the tick's `<svg>`, because the
|
|
234
321
|
two states have to be able to hide independently and a
|
|
235
322
|
`<path>` cannot carry a `peer-*` variant of its own. */}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
323
|
+
<svg
|
|
324
|
+
aria-hidden="true"
|
|
325
|
+
className={toClassName(
|
|
326
|
+
"pointer-events-none invisible absolute peer-indeterminate:visible",
|
|
327
|
+
isReadOnly
|
|
328
|
+
? "text-intent-neutral-on-solid"
|
|
329
|
+
: "text-intent-accent-on-solid",
|
|
330
|
+
CHECK_SIZE_CLASS[size],
|
|
331
|
+
)}
|
|
332
|
+
fill="none"
|
|
333
|
+
stroke="currentColor"
|
|
334
|
+
strokeLinecap="round"
|
|
335
|
+
strokeWidth={3}
|
|
336
|
+
viewBox="0 0 24 24"
|
|
337
|
+
>
|
|
338
|
+
<path d="M6 12h12" />
|
|
339
|
+
</svg>
|
|
340
|
+
</span>
|
|
254
341
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
342
|
+
<span>{label}</span>
|
|
343
|
+
</label>
|
|
344
|
+
|
|
345
|
+
{/*
|
|
346
|
+
`text-sm` and `content-secondary`, the same two the `Field`
|
|
347
|
+
description wears — one hint typography for the whole library,
|
|
348
|
+
so a `Checkbox` and a `Field` in the same box do not disagree
|
|
349
|
+
about how big a hint is. That disagreement is what kept
|
|
350
|
+
queuepilot's dyn editor on an app-styled `<p>` for one hint out
|
|
351
|
+
of three.
|
|
352
|
+
*/}
|
|
353
|
+
{description === undefined ? null : (
|
|
354
|
+
<p
|
|
355
|
+
className={toClassName(
|
|
356
|
+
"text-content-secondary text-sm",
|
|
357
|
+
DESCRIPTION_INDENT_CLASS[size],
|
|
358
|
+
)}
|
|
359
|
+
id={descriptionId}
|
|
360
|
+
>
|
|
361
|
+
{description}
|
|
362
|
+
</p>
|
|
363
|
+
)}
|
|
364
|
+
</div>
|
|
365
|
+
)
|
|
366
|
+
}
|
package/src/Field/Field.mdx
CHANGED
|
@@ -78,6 +78,47 @@ floating node with no trigger and no anchor.
|
|
|
78
78
|
|
|
79
79
|
<Canvas of={FieldStories.Nested} />
|
|
80
80
|
|
|
81
|
+
## Where a prop lands
|
|
82
|
+
|
|
83
|
+
`FieldProps` and `FieldGroupProps` were **closed** types until 2026-08-21: the only prop a
|
|
84
|
+
caller could legally pass was `className`. That is the sharpest argument for opening them —
|
|
85
|
+
a component whose whole purpose is *configured by props, not a borrowed class* accepted only
|
|
86
|
+
the escape hatch that rule exists to close, so it forced the very thing the rule forbids.
|
|
87
|
+
queuepilot paid for it twice in one modal: a `FieldGroup` that gave up the `id` its
|
|
88
|
+
screenshot suite drives by, and a box beside it that stayed a hand-rolled `<fieldset>`
|
|
89
|
+
because it needed `hidden`.
|
|
90
|
+
|
|
91
|
+
The rule that opens them is one sentence — **a component's props land on the element it
|
|
92
|
+
owns** — and it reads differently in each component because they own different elements:
|
|
93
|
+
|
|
94
|
+
| | `Field` | `FieldGroup` |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| Renders | `<div>` around a **cloned** child | a `<fieldset>` around children rendered as-is |
|
|
97
|
+
| Rest props land on | the **control** | the **`<fieldset>`** |
|
|
98
|
+
| `className` lands on | the `<div>` | the `<fieldset>` |
|
|
99
|
+
| So you can pass | `name`, `placeholder`, `autoFocus`, `aria-*`, `ref` | `id`, `hidden`, `ref`, and `disabled` / `form` / `name` |
|
|
100
|
+
|
|
101
|
+
`Field`'s destination is not a new one, it is the existing one finally declared: the runtime
|
|
102
|
+
already spread everything it received into the clone, and `id` has named the control since
|
|
103
|
+
the component shipped. Routing the rest to the `<div>` would have moved that `id`, silently,
|
|
104
|
+
in every consumer.
|
|
105
|
+
|
|
106
|
+
It also could not have been done safely. A slot is a pass-through and **cannot tell who
|
|
107
|
+
wrote a prop** — a `Tooltip` above hands down `getReferenceProps()`, and the author types on
|
|
108
|
+
the same object. Holding the author's props back at the `<div>` needs a closed runtime
|
|
109
|
+
allow-list, and an allow-list is `FieldTooltip`'s silent drop rebuilt inside the fix for it:
|
|
110
|
+
the first slot that writes an `aria-expanded` breaks, and nothing says so.
|
|
111
|
+
|
|
112
|
+
So a caller who wants a handle on the **box** — an `id` for a screenshot, a `hidden` that
|
|
113
|
+
takes the label with it — wants `FieldGroup`. It wraps rather than clones, so its props have
|
|
114
|
+
nowhere else to go.
|
|
115
|
+
|
|
116
|
+
`disabled` on a `<fieldset>` is worth knowing before reaching for it: the platform disables
|
|
117
|
+
every control inside the element, not just the box. That is usually what a caller means, and
|
|
118
|
+
it is something a `<div>`-based group could not have offered at all.
|
|
119
|
+
|
|
120
|
+
<Canvas of={FieldStories.ForwardsRestProps} />
|
|
121
|
+
|
|
81
122
|
## Several controls are a `FieldGroup`, not a `Field`
|
|
82
123
|
|
|
83
124
|
`Field` mints one id and clones it onto one child. An `id` names one element and a
|
|
@@ -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
|
+
}
|