@charcuterie/ui 3.6.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/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/slotProps.ts +16 -2
package/dist/slotProps.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slotProps.js","sourceRoot":"","sources":["../src/slotProps.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AA6FjD
|
|
1
|
+
{"version":3,"file":"slotProps.js","sourceRoot":"","sources":["../src/slotProps.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AA6FjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAI5B,aAA4B,EAC5B,QAAkB,EACQ,EAAE;IAC5B,MAAM,eAAe,GAAG,MAAM,CAAC,WAAW,CACxC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,MAAM,CAC7B,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,KAAK,KAAK,SAAS,CACnC,CACU,CAAA;IAEb,MAAM,WAAW,GAAG;QAClB,aAAa,CAAC,kBAAkB,CAAC;QACjC,QAAQ,CAAC,kBAAkB,CAAC;KAC7B;SACE,MAAM,CAAC,OAAO,CAAC;SACf,IAAI,CAAC,GAAG,CAAC,CAAA;IAEZ,OAAO;QACL,GAAG,aAAa;QAChB,GAAG,eAAe;QAClB,kBAAkB,EAChB,WAAW,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW;QAC9C,sDAAsD;QACtD,6DAA6D;QAC7D,8BAA8B;QAC9B,GAAG,eAAe,CAChB,aAAwC,EACxC,eAAe,CAChB;KACF,CAAA;AACH,CAAC,CAAA"}
|
package/package.json
CHANGED
|
@@ -86,6 +86,38 @@ does not become one. `isChecked` still seeds the box, `onChange` still reports a
|
|
|
86
86
|
|
|
87
87
|
<Canvas of={CheckboxStories.WithValues} />
|
|
88
88
|
|
|
89
|
+
## A hint follows the box
|
|
90
|
+
|
|
91
|
+
`description` is standing help bound with `aria-describedby`, so a screen reader reads it
|
|
92
|
+
**with** the control rather than announcing it from nowhere — the same slot, the same words
|
|
93
|
+
and the same announcement order as [`Field`](/docs/components-field--docs)'s.
|
|
94
|
+
|
|
95
|
+
`Field` has had one since it shipped and this component had none, which is a gap you only
|
|
96
|
+
feel in a mixed box: a hint after a checkbox had to be the app's own paragraph while the
|
|
97
|
+
hint after the `Field` two rows down came from the library, and the two disagreed about how
|
|
98
|
+
big a hint is. One control's help looking unlike its neighbour's, in the same editor.
|
|
99
|
+
|
|
100
|
+
The hint renders **outside** the `<label>`, which is the only place it can go. A `<label>`'s
|
|
101
|
+
text content *is* the control's accessible name, so a hint inside it would be announced
|
|
102
|
+
twice — once inside the name, once again as the description — and a pointer press on a
|
|
103
|
+
sentence of explanation would toggle the box. So the `<label>` is no longer the outermost
|
|
104
|
+
element this component renders: a `<div>` holds the row and the hint, and `className` moves
|
|
105
|
+
out to it, because `className` is the outermost box a component renders and that rule does
|
|
106
|
+
not bend for this one.
|
|
107
|
+
|
|
108
|
+
Two smaller decisions inside it:
|
|
109
|
+
|
|
110
|
+
- **`opacity-60` stays on the `<label>`** and does not reach the hint. axe exempts a
|
|
111
|
+
disabled control and its own label from the colour-contrast rule and exempts nothing else,
|
|
112
|
+
so dimming the hint with the box measures **3.77:1** on `midnight` against a 4.5
|
|
113
|
+
threshold. A hint nobody can read is worse than a hint that does not dim, and a turned-off
|
|
114
|
+
box is exactly when that sentence gets read.
|
|
115
|
+
- **There is no `error` to go with it.** `Field` derives `aria-invalid` from its error, and a
|
|
116
|
+
lone boolean is almost never the invalid thing — a *group* of them is, and a group's error
|
|
117
|
+
belongs on the `FieldGroup` around it, described rather than asserted.
|
|
118
|
+
|
|
119
|
+
<Canvas of={CheckboxStories.WithDescriptions} />
|
|
120
|
+
|
|
89
121
|
## Checkbox or `Switch`
|
|
90
122
|
|
|
91
123
|
Same state kind, different meaning. A checkbox is a value you **submit** — "delete
|
|
@@ -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
|
+
}
|