@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
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
StoryGrid,
|
|
7
7
|
StorySection,
|
|
8
8
|
} from "../board.storyHelpers.tsx"
|
|
9
|
+
import { FieldGroup } from "../Field/FieldGroup.tsx"
|
|
9
10
|
import { Checkbox } from "./Checkbox.tsx"
|
|
10
11
|
|
|
11
12
|
const meta = {
|
|
@@ -162,3 +163,76 @@ export const WithValues: Story = {
|
|
|
162
163
|
</StorySection>
|
|
163
164
|
),
|
|
164
165
|
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `description` is standing help that follows the box, bound with
|
|
169
|
+
* `aria-describedby` so a screen reader reads it **with** the control
|
|
170
|
+
* — the same slot, the same words and the same announcement order as
|
|
171
|
+
* `Field`'s.
|
|
172
|
+
*
|
|
173
|
+
* It renders outside the `<label>`, which is the only place it can go:
|
|
174
|
+
* a `<label>`'s text content *is* the control's accessible name, so a
|
|
175
|
+
* hint inside it would be announced twice and a pointer press on it
|
|
176
|
+
* would toggle the box.
|
|
177
|
+
*
|
|
178
|
+
* The gap this closes is a mixed box. Before it, a hint after a
|
|
179
|
+
* checkbox had to be the app's own paragraph while the hint after the
|
|
180
|
+
* `Field` two rows down came from the library, and the two disagreed
|
|
181
|
+
* about the size of a hint — one control's help looking unlike its
|
|
182
|
+
* neighbour's, in the same editor.
|
|
183
|
+
*/
|
|
184
|
+
export const WithDescriptions: Story = {
|
|
185
|
+
args: { label: "Delete originals after import" },
|
|
186
|
+
render: (controlProps) => (
|
|
187
|
+
<StorySection title="One hint typography for the whole library: `text-sm`, `content-secondary`, hung under the label text rather than under the box.">
|
|
188
|
+
<StoryGrid columns={2}>
|
|
189
|
+
<StoryCell label="described">
|
|
190
|
+
<Checkbox
|
|
191
|
+
{...controlProps}
|
|
192
|
+
description="The source files are removed once every title has been written."
|
|
193
|
+
label="Delete originals after import"
|
|
194
|
+
/>
|
|
195
|
+
</StoryCell>
|
|
196
|
+
|
|
197
|
+
<StoryCell label="described and checked">
|
|
198
|
+
<Checkbox
|
|
199
|
+
{...controlProps}
|
|
200
|
+
description="Nothing plays after the last title finishes."
|
|
201
|
+
isChecked
|
|
202
|
+
label="Turn everything off when it finishes"
|
|
203
|
+
/>
|
|
204
|
+
</StoryCell>
|
|
205
|
+
|
|
206
|
+
<StoryCell label="described and disabled">
|
|
207
|
+
<Checkbox
|
|
208
|
+
{...controlProps}
|
|
209
|
+
description="A turned-off box is exactly when this sentence gets read, so it keeps full contrast."
|
|
210
|
+
isDisabled
|
|
211
|
+
label="Re-encode to the archive profile"
|
|
212
|
+
/>
|
|
213
|
+
</StoryCell>
|
|
214
|
+
|
|
215
|
+
<StoryCell label="described, in a group">
|
|
216
|
+
<FieldGroup
|
|
217
|
+
description="A group's own help sits under all of it."
|
|
218
|
+
label="After a rip"
|
|
219
|
+
>
|
|
220
|
+
<Checkbox
|
|
221
|
+
{...controlProps}
|
|
222
|
+
description="Written beside the video, not into it."
|
|
223
|
+
label="Keep chapter markers"
|
|
224
|
+
size="sm"
|
|
225
|
+
/>
|
|
226
|
+
|
|
227
|
+
<Checkbox
|
|
228
|
+
{...controlProps}
|
|
229
|
+
description="Costs a second pass over each title."
|
|
230
|
+
label="Verify the checksum"
|
|
231
|
+
size="sm"
|
|
232
|
+
/>
|
|
233
|
+
</FieldGroup>
|
|
234
|
+
</StoryCell>
|
|
235
|
+
</StoryGrid>
|
|
236
|
+
</StorySection>
|
|
237
|
+
),
|
|
238
|
+
}
|
|
@@ -7,8 +7,13 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
7
7
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
8
|
import * as stories from "./Checkbox.stories.tsx"
|
|
9
9
|
|
|
10
|
-
const {
|
|
11
|
-
|
|
10
|
+
const {
|
|
11
|
+
AllStates,
|
|
12
|
+
Default,
|
|
13
|
+
Interactive,
|
|
14
|
+
WithDescriptions,
|
|
15
|
+
WithValues,
|
|
16
|
+
} = composeStories(stories)
|
|
12
17
|
|
|
13
18
|
test("it is a checkbox a screen reader can name", async () => {
|
|
14
19
|
const { canvas, canvasElement } =
|
|
@@ -158,3 +163,106 @@ test("a box with no value stays out of the way", async () => {
|
|
|
158
163
|
}),
|
|
159
164
|
).not.toHaveAttribute("value")
|
|
160
165
|
})
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The description is *announced*, which is the only claim worth
|
|
169
|
+
* asserting — a hint that renders and is not bound to the control is
|
|
170
|
+
* decoration, and that is what an app-styled paragraph beside a
|
|
171
|
+
* checkbox already was.
|
|
172
|
+
*
|
|
173
|
+
* The second half is the trap: the hint must be **outside** the
|
|
174
|
+
* `<label>`. A `<label>`'s text content is the control's accessible
|
|
175
|
+
* name, so a hint inside it would be read once inside the name and
|
|
176
|
+
* once again as the description, and the name assertion below is what
|
|
177
|
+
* catches that — it fails on "Delete originals after import The source
|
|
178
|
+
* files are removed…".
|
|
179
|
+
*/
|
|
180
|
+
test("a description is announced with the box, and stays out of its name", async () => {
|
|
181
|
+
const { canvas, canvasElement } = await mountStory(
|
|
182
|
+
WithDescriptions,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
const checkbox = expectAgentDrivable(canvas, {
|
|
186
|
+
name: "Delete originals after import",
|
|
187
|
+
role: "checkbox",
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
await expect(
|
|
191
|
+
document.getElementById(
|
|
192
|
+
checkbox.getAttribute("aria-describedby") ?? "",
|
|
193
|
+
),
|
|
194
|
+
).toHaveTextContent(
|
|
195
|
+
"The source files are removed once every title has been written.",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
// The hint is a sibling of the `<label>`, not a descendant of it.
|
|
199
|
+
await expect(checkbox.closest("label")?.textContent).toBe(
|
|
200
|
+
"Delete originals after import",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
await expectNoAxeViolations(canvasElement)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A group of described boxes inside a `FieldGroup` — the shape the
|
|
208
|
+
* downstream editor wanted and could not write, because one hint out
|
|
209
|
+
* of three had to be the app's own paragraph.
|
|
210
|
+
*
|
|
211
|
+
* Each box names its own hint and the group names the group's, so
|
|
212
|
+
* three `aria-describedby` values point at three different nodes and
|
|
213
|
+
* none of them collide.
|
|
214
|
+
*/
|
|
215
|
+
test("each box owns its own description, and the group owns the group's", async () => {
|
|
216
|
+
const { canvas, canvasElement } = await mountStory(
|
|
217
|
+
WithDescriptions,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
const group = expectAgentDrivable(canvas, {
|
|
221
|
+
name: "After a rip",
|
|
222
|
+
role: "group",
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
await expect(
|
|
226
|
+
document.getElementById(
|
|
227
|
+
group.getAttribute("aria-describedby") ?? "",
|
|
228
|
+
),
|
|
229
|
+
).toHaveTextContent(
|
|
230
|
+
"A group's own help sits under all of it.",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
const describedIds = [
|
|
234
|
+
"Keep chapter markers",
|
|
235
|
+
"Verify the checksum",
|
|
236
|
+
].map((name) =>
|
|
237
|
+
expectAgentDrivable(canvas, {
|
|
238
|
+
name,
|
|
239
|
+
role: "checkbox",
|
|
240
|
+
}).getAttribute("aria-describedby"),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
await expect(new Set(describedIds).size).toBe(2)
|
|
244
|
+
|
|
245
|
+
await expect(
|
|
246
|
+
document.getElementById(describedIds[0] ?? ""),
|
|
247
|
+
).toHaveTextContent(
|
|
248
|
+
"Written beside the video, not into it.",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
await expectNoAxeViolations(canvasElement)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test("a box with no description writes no aria-describedby", async () => {
|
|
255
|
+
const { canvas } = await mountStory(Default)
|
|
256
|
+
|
|
257
|
+
// The slot is opt-in. React drops an `undefined` attribute rather
|
|
258
|
+
// than writing an empty string, so a box without a hint points at
|
|
259
|
+
// nothing — an `aria-describedby` naming an id that is not in the
|
|
260
|
+
// document is the defect `Field` was built to prevent, and it would
|
|
261
|
+
// arrive here the moment the id were written unconditionally.
|
|
262
|
+
await expect(
|
|
263
|
+
expectAgentDrivable(canvas, {
|
|
264
|
+
name: "Delete originals after import",
|
|
265
|
+
role: "checkbox",
|
|
266
|
+
}),
|
|
267
|
+
).not.toHaveAttribute("aria-describedby")
|
|
268
|
+
})
|
|
@@ -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
|