@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.
@@ -1 +1 @@
1
- {"version":3,"file":"slotProps.js","sourceRoot":"","sources":["../src/slotProps.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AA6FjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAG5B,aAAwB,EACxB,QAAkB,EACI,EAAE;IACxB,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"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 { AllStates, Default, Interactive, WithValues } =
11
- composeStories(stories)
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
- <label
121
- className={toClassName(
122
- "inline-flex items-center gap-2 text-content-secondary select-none",
123
- TEXT_SIZE_CLASS[size],
124
- // One cursor, chosen by state — two cursor utilities of the same
125
- // specificity do not resolve by class-list order.
126
- isDisabled
127
- ? "cursor-not-allowed"
128
- : isReadOnly
129
- ? "cursor-default"
130
- : "cursor-pointer",
131
- // Disabled dims the whole control with opacity rather than fading
132
- // the box to a quieter token. The token scale has no step between
133
- // `border-default` and `border-strong`, so a muted outline was
134
- // either invisible or indistinguishable from enabled; `opacity-60`
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
- // `border-2` is the family's shared weight — the `Switch`
148
- // track's `ring-2` and the `RadioGroup` ring match it, so the
149
- // three read as one set — and a 2px edge is also what keeps a
150
- // disabled box visible where a 1px one washed out.
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
- BOX_SIZE_CLASS[size],
178
- FOCUS_RING_CLASS,
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
- defaultChecked={isChecked}
181
- disabled={isDisabled}
182
- id={id}
183
- onChange={(
184
- event: ChangeEvent<HTMLInputElement>,
185
- ) => {
186
- if (isReadOnly) {
187
- return
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
- onChange?.(event.target.checked)
191
- }}
192
- onClick={(clickEvent) => {
193
- // Native checkboxes have no `readonly`, so the toggle is
194
- // blocked here — `preventDefault` on the click stops the
195
- // checked flip a pointer would make.
196
- if (isReadOnly) {
197
- clickEvent.preventDefault()
198
- }
199
- }}
200
- onKeyDown={(keyEvent) => {
201
- // …and Space, the keyboard toggle a click block does not
202
- // cover.
203
- if (isReadOnly && keyEvent.key === " ") {
204
- keyEvent.preventDefault()
205
- }
206
- }}
207
- type="checkbox"
208
- value={value}
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
- <svg
212
- aria-hidden="true"
213
- className={toClassName(
214
- "pointer-events-none invisible absolute peer-checked:visible peer-indeterminate:invisible",
215
- // Matches whichever fill the box wears, so the tick keeps its
216
- // guaranteed on-solid contrast in either intent.
217
- isReadOnly
218
- ? "text-intent-neutral-on-solid"
219
- : "text-intent-accent-on-solid",
220
- CHECK_SIZE_CLASS[size],
221
- )}
222
- fill="none"
223
- stroke="currentColor"
224
- strokeLinecap="round"
225
- strokeLinejoin="round"
226
- strokeWidth={3}
227
- viewBox="0 0 24 24"
228
- >
229
- <path d="M20 6 9 17l-5-5" />
230
- </svg>
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
- {/* The mixed mark — a dash, not a tick. Its own element rather
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
- <svg
237
- aria-hidden="true"
238
- className={toClassName(
239
- "pointer-events-none invisible absolute peer-indeterminate:visible",
240
- isReadOnly
241
- ? "text-intent-neutral-on-solid"
242
- : "text-intent-accent-on-solid",
243
- CHECK_SIZE_CLASS[size],
244
- )}
245
- fill="none"
246
- stroke="currentColor"
247
- strokeLinecap="round"
248
- strokeWidth={3}
249
- viewBox="0 0 24 24"
250
- >
251
- <path d="M6 12h12" />
252
- </svg>
253
- </span>
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
- <span>{label}</span>
256
- </label>
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
+ }