@charcuterie/ui 2.4.1 → 2.6.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.
Files changed (56) hide show
  1. package/dist/Checkbox/Checkbox.d.ts +68 -0
  2. package/dist/Checkbox/Checkbox.d.ts.map +1 -0
  3. package/dist/Checkbox/Checkbox.js +108 -0
  4. package/dist/Checkbox/Checkbox.js.map +1 -0
  5. package/dist/Field/Field.d.ts.map +1 -1
  6. package/dist/Field/Field.js +12 -1
  7. package/dist/Field/Field.js.map +1 -1
  8. package/dist/Menu/Menu.d.ts +60 -2
  9. package/dist/Menu/Menu.d.ts.map +1 -1
  10. package/dist/Menu/Menu.js +58 -11
  11. package/dist/Menu/Menu.js.map +1 -1
  12. package/dist/RadioGroup/RadioGroup.d.ts +54 -0
  13. package/dist/RadioGroup/RadioGroup.d.ts.map +1 -0
  14. package/dist/RadioGroup/RadioGroup.js +121 -0
  15. package/dist/RadioGroup/RadioGroup.js.map +1 -0
  16. package/dist/RadioGroup/RadioGroupOption.d.ts +27 -0
  17. package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -0
  18. package/dist/RadioGroup/RadioGroupOption.js +75 -0
  19. package/dist/RadioGroup/RadioGroupOption.js.map +1 -0
  20. package/dist/Switch/Switch.d.ts +60 -0
  21. package/dist/Switch/Switch.d.ts.map +1 -0
  22. package/dist/Switch/Switch.js +106 -0
  23. package/dist/Switch/Switch.js.map +1 -0
  24. package/dist/index.d.ts +6 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/slotWiring.d.ts +9 -7
  29. package/dist/slotWiring.d.ts.map +1 -1
  30. package/dist/slotWiring.js +10 -70
  31. package/dist/slotWiring.js.map +1 -1
  32. package/package.json +2 -2
  33. package/src/Checkbox/Checkbox.mdx +71 -0
  34. package/src/Checkbox/Checkbox.stories.tsx +122 -0
  35. package/src/Checkbox/Checkbox.test.tsx +109 -0
  36. package/src/Checkbox/Checkbox.tsx +205 -0
  37. package/src/Combobox/Combobox.stories.tsx +19 -4
  38. package/src/Field/Field.stories.tsx +19 -0
  39. package/src/Field/Field.test.tsx +30 -2
  40. package/src/Field/Field.tsx +13 -1
  41. package/src/Menu/Menu.stories.tsx +80 -2
  42. package/src/Menu/Menu.test.tsx +102 -0
  43. package/src/Menu/Menu.tsx +156 -17
  44. package/src/RadioGroup/RadioGroup.mdx +67 -0
  45. package/src/RadioGroup/RadioGroup.stories.tsx +140 -0
  46. package/src/RadioGroup/RadioGroup.test.tsx +232 -0
  47. package/src/RadioGroup/RadioGroup.tsx +223 -0
  48. package/src/RadioGroup/RadioGroupOption.tsx +148 -0
  49. package/src/Switch/Switch.mdx +55 -0
  50. package/src/Switch/Switch.stories.tsx +116 -0
  51. package/src/Switch/Switch.test.tsx +115 -0
  52. package/src/Switch/Switch.tsx +183 -0
  53. package/src/index.ts +9 -0
  54. package/src/slotWiring.ts +15 -107
  55. package/src/sourceRules.test.ts +11 -1
  56. package/src/tailwindCandidates.test.ts +9 -7
@@ -0,0 +1,116 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
4
+ import {
5
+ StoryCell,
6
+ StoryGrid,
7
+ StorySection,
8
+ } from "../board.storyHelpers.tsx"
9
+ import { Switch } from "./Switch.tsx"
10
+
11
+ const meta = {
12
+ title: "Components/Switch",
13
+ component: Switch,
14
+ parameters: { layout: "padded" },
15
+ argTypes: { size: controlSizeArgType },
16
+ // The component's own defaults, restated — Storybook does not seed
17
+ // `args` from docgen, so an unstated default shows in the props
18
+ // table with nothing selected in its control.
19
+ args: {
20
+ isChecked: false,
21
+ isDisabled: false,
22
+ isReadOnly: false,
23
+ size: "md",
24
+ },
25
+ } satisfies Meta<typeof Switch>
26
+
27
+ export default meta
28
+
29
+ type Story = StoryObj<typeof meta>
30
+
31
+ export const Default: Story = {
32
+ args: { label: "Automatic imports" },
33
+ }
34
+
35
+ export const AllVariants: Story = {
36
+ args: { label: "Automatic imports" },
37
+ render: (controlProps) => (
38
+ <StorySection title="Three control heights, all reading the density axis rather than hardcoding a length.">
39
+ <StoryGrid columns={3}>
40
+ {(["sm", "md", "lg"] as const).map((size) => (
41
+ <StoryCell key={size} label={size}>
42
+ <Switch
43
+ {...controlProps}
44
+ isChecked
45
+ label={`On at ${size}`}
46
+ size={size}
47
+ />
48
+ </StoryCell>
49
+ ))}
50
+ </StoryGrid>
51
+ </StorySection>
52
+ ),
53
+ }
54
+
55
+ /**
56
+ * The thumb changes colour as well as position, so the state reads at
57
+ * a glance in `daylight` sunlight — a thumb that only moved would not.
58
+ */
59
+ export const AllStates: Story = {
60
+ args: { label: "Automatic imports" },
61
+ render: (controlProps) => (
62
+ <StorySection title="What varies is whether the switch is on and whether it can be changed.">
63
+ <StoryGrid columns={2}>
64
+ <StoryCell label="off">
65
+ <Switch {...controlProps} label="Off" />
66
+ </StoryCell>
67
+
68
+ <StoryCell label="on">
69
+ <Switch {...controlProps} isChecked label="On" />
70
+ </StoryCell>
71
+
72
+ <StoryCell label="disabled, off">
73
+ <Switch
74
+ {...controlProps}
75
+ isDisabled
76
+ label="Disabled"
77
+ />
78
+ </StoryCell>
79
+
80
+ <StoryCell label="disabled, on">
81
+ <Switch
82
+ {...controlProps}
83
+ isChecked
84
+ isDisabled
85
+ label="Disabled and on"
86
+ />
87
+ </StoryCell>
88
+
89
+ <StoryCell label="read-only, off">
90
+ <Switch
91
+ {...controlProps}
92
+ isReadOnly
93
+ label="Read-only"
94
+ />
95
+ </StoryCell>
96
+
97
+ <StoryCell label="read-only, on">
98
+ <Switch
99
+ {...controlProps}
100
+ isChecked
101
+ isReadOnly
102
+ label="Read-only and on"
103
+ />
104
+ </StoryCell>
105
+ </StoryGrid>
106
+ </StorySection>
107
+ ),
108
+ }
109
+
110
+ /**
111
+ * A `button role="switch"`, so its keyboard path is the button's:
112
+ * Tab reaches it, Space and Enter flip it.
113
+ */
114
+ export const Interactive: Story = {
115
+ args: { label: "Automatic imports" },
116
+ }
@@ -0,0 +1,115 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./Switch.stories.tsx"
9
+
10
+ const { AllStates, Default, Interactive } =
11
+ composeStories(stories)
12
+
13
+ test("it is a switch a screen reader can name and read", async () => {
14
+ const { canvas, canvasElement } =
15
+ await mountStory(Default)
16
+
17
+ const control = expectAgentDrivable(canvas, {
18
+ name: "Automatic imports",
19
+ role: "switch",
20
+ })
21
+
22
+ // `aria-checked`, not `aria-pressed`: a switch announces "off",
23
+ // where a toggle button would announce "not pressed". The role is
24
+ // the whole reason to reach for this over a `Checkbox`.
25
+ await expect(control).toHaveAttribute(
26
+ "aria-checked",
27
+ "false",
28
+ )
29
+
30
+ await expectNoAxeViolations(canvasElement)
31
+ })
32
+
33
+ test("Space flips it, and flips it back", async () => {
34
+ const { canvas } = await mountStory(Interactive)
35
+
36
+ const control = expectAgentDrivable(canvas, {
37
+ name: "Automatic imports",
38
+ role: "switch",
39
+ })
40
+
41
+ control.focus()
42
+
43
+ await userEvent.keyboard(" ")
44
+
45
+ await waitFor(() => {
46
+ expect(control).toHaveAttribute("aria-checked", "true")
47
+ })
48
+
49
+ await userEvent.keyboard(" ")
50
+
51
+ await waitFor(() => {
52
+ expect(control).toHaveAttribute("aria-checked", "false")
53
+ })
54
+ })
55
+
56
+ test("a disabled switch cannot be flipped", async () => {
57
+ const { canvas, canvasElement } =
58
+ await mountStory(AllStates)
59
+
60
+ const control = expectAgentDrivable(canvas, {
61
+ name: "Disabled",
62
+ role: "switch",
63
+ })
64
+
65
+ await expect(control).toBeDisabled()
66
+
67
+ await userEvent.click(control)
68
+
69
+ await expect(control).toHaveAttribute(
70
+ "aria-checked",
71
+ "false",
72
+ )
73
+
74
+ await expectNoAxeViolations(canvasElement)
75
+ })
76
+
77
+ test("isChecked decides the first render", async () => {
78
+ const { canvas } = await mountStory(AllStates)
79
+
80
+ await expect(
81
+ expectAgentDrivable(canvas, {
82
+ name: "On",
83
+ role: "switch",
84
+ }),
85
+ ).toHaveAttribute("aria-checked", "true")
86
+ })
87
+
88
+ test("a read-only switch is announced and cannot be flipped", async () => {
89
+ const { canvas, canvasElement } =
90
+ await mountStory(AllStates)
91
+
92
+ const readOnly = expectAgentDrivable(canvas, {
93
+ name: "Read-only",
94
+ role: "switch",
95
+ })
96
+
97
+ await expect(readOnly).toHaveAttribute(
98
+ "aria-readonly",
99
+ "true",
100
+ )
101
+
102
+ await expect(readOnly).toHaveAttribute(
103
+ "aria-checked",
104
+ "false",
105
+ )
106
+
107
+ await userEvent.click(readOnly)
108
+
109
+ await expect(readOnly).toHaveAttribute(
110
+ "aria-checked",
111
+ "false",
112
+ )
113
+
114
+ await expectNoAxeViolations(canvasElement)
115
+ })
@@ -0,0 +1,183 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import type { ControlSize } from "@charcuterie/tokens"
3
+ import type { ReactNode } from "react"
4
+ import { useState } from "react"
5
+
6
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
7
+ import { toClassName } from "../toClassName.ts"
8
+
9
+ export type SwitchProps = {
10
+ className?: string
11
+ /**
12
+ * **Initial** only. The `Switch` owns it from then on — the same
13
+ * uncontrolled contract as `Checkbox` and `SegmentedControl`, so
14
+ * `onChange` reports the new state and there is no controlled
15
+ * `checked` prop to keep in step.
16
+ */
17
+ isChecked?: boolean
18
+ isDisabled?: boolean
19
+ /**
20
+ * Shows the state at full contrast but refuses to flip it — a
21
+ * setting you may read here and change elsewhere. Unlike
22
+ * `isDisabled` it does not dim, and it announces `aria-readonly`.
23
+ */
24
+ isReadOnly?: boolean
25
+ /** The visible text, and the switch's accessible name. */
26
+ label: ReactNode
27
+ onChange?: (isChecked: boolean) => void
28
+ size?: ControlSize
29
+ }
30
+
31
+ // Track and thumb are sized so `p-1` (4px) breathes evenly on every
32
+ // side and the thumb is centred by arithmetic, not by eye: inner
33
+ // height (track − 2·padding) equals the thumb, and the on-translate is
34
+ // (track width − 2·padding − thumb). Break one of those and the thumb
35
+ // clips an edge, which is what the first cut did.
36
+ const TRACK_SIZE_CLASS: Record<ControlSize, string> = {
37
+ sm: "h-5 w-9",
38
+ md: "h-6 w-11",
39
+ lg: "h-7 w-14",
40
+ }
41
+
42
+ const THUMB_SIZE_CLASS: Record<ControlSize, string> = {
43
+ sm: "size-3",
44
+ md: "size-4",
45
+ lg: "size-5",
46
+ }
47
+
48
+ const THUMB_ON_TRANSLATE_CLASS: Record<
49
+ ControlSize,
50
+ string
51
+ > = {
52
+ sm: "translate-x-4",
53
+ md: "translate-x-5",
54
+ lg: "translate-x-7",
55
+ }
56
+
57
+ const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
58
+ sm: "text-sm",
59
+ md: "text-md",
60
+ lg: "text-lg",
61
+ }
62
+
63
+ /**
64
+ * A boolean that takes effect the moment it is flipped — a setting,
65
+ * not a form value.
66
+ *
67
+ * A `Checkbox` and a `Switch` are the same state kind and a
68
+ * different affordance, and the split is meaning rather than looks:
69
+ * a checkbox is a value you submit ("I agree"), a switch is a state
70
+ * you toggle and live with immediately ("dark mode on"). ARIA gives
71
+ * the second its own role, and a screen reader announces "on/off"
72
+ * for a `switch` where it announces "checked/unchecked" for a
73
+ * checkbox.
74
+ *
75
+ * ### A `button role="switch"`, not an `input`
76
+ *
77
+ * The sliding track and thumb are the whole point of the affordance,
78
+ * and a `<button>` styles them without fighting a native control's
79
+ * own rendering. It carries `aria-checked`, `aria-labelledby`, and
80
+ * nothing else the browser would try to own. The value lives in
81
+ * React state seeded once from `isChecked` — the switch is the
82
+ * store, the same as the DOM is for `Checkbox`.
83
+ *
84
+ * ### The thumb is one colour, the track carries the state
85
+ *
86
+ * `bg-surface-raised` in both positions — the knob is the same object
87
+ * sliding, not two differently-painted dots, which is what the first
88
+ * cut looked like when off was `content-secondary` and on was
89
+ * `on-solid`. A themed system has no single colour that is "light" in
90
+ * both schemes, so consistency lives *within* a scheme: `surface-raised`
91
+ * is a white knob in a light one and a raised charcoal knob in a dark
92
+ * one, off and on identical either way. The **track** shows the state —
93
+ * `bg-intent-accent-solid` on, `bg-surface-sunken` with a `ring-2`
94
+ * outline off — which is where a switch's state belongs, and the ring
95
+ * gives the off track the defined edge a bare well would miss on a pale
96
+ * theme.
97
+ */
98
+ export const Switch = ({
99
+ className,
100
+ isChecked = false,
101
+ isDisabled = false,
102
+ isReadOnly = false,
103
+ label,
104
+ onChange,
105
+ size = "md",
106
+ }: SwitchProps): ReactNode => {
107
+ const [isOn, setIsOn] = useState(isChecked)
108
+
109
+ const labelId = useUniqueId()
110
+
111
+ return (
112
+ <span
113
+ className={toClassName(
114
+ "inline-flex items-center gap-2 text-content-secondary select-none",
115
+ TEXT_SIZE_CLASS[size],
116
+ // Disabled turns the whole control down with `opacity-60`, the
117
+ // same family treatment as `Checkbox` and `RadioGroup` — the
118
+ // track keeps its full-contrast state colour underneath.
119
+ isDisabled && "opacity-60",
120
+ className,
121
+ )}
122
+ >
123
+ <button
124
+ aria-checked={isOn}
125
+ aria-labelledby={labelId}
126
+ aria-readonly={isReadOnly || undefined}
127
+ className={toClassName(
128
+ // `p-1` is the breathing room; `ring-inset` draws the off
129
+ // outline without changing the box size, so the centring
130
+ // arithmetic above stays exact.
131
+ "inline-flex shrink-0 items-center rounded-full p-1 transition-colors duration-(--duration-fast) ease-standard",
132
+ // The track carries the state at full contrast whether or not
133
+ // it is disabled; `opacity-60` on the wrapper does the dimming.
134
+ // Read-only swaps the accent for the neutral intent and softens
135
+ // the off outline — readable, plainly not an actionable accent,
136
+ // the same language as `Checkbox` and `RadioGroup`.
137
+ isReadOnly
138
+ ? isOn
139
+ ? "bg-intent-neutral-solid"
140
+ : "bg-surface-sunken ring-2 ring-border-default ring-inset"
141
+ : isOn
142
+ ? "bg-intent-accent-solid"
143
+ : "bg-surface-sunken ring-2 ring-border-strong ring-inset",
144
+ isDisabled
145
+ ? "cursor-not-allowed"
146
+ : isReadOnly
147
+ ? "cursor-default"
148
+ : "cursor-pointer",
149
+ TRACK_SIZE_CLASS[size],
150
+ FOCUS_RING_CLASS,
151
+ )}
152
+ disabled={isDisabled}
153
+ onClick={() => {
154
+ // Read-only shows the state but will not flip it — the switch
155
+ // owns its value, so the guard is just an early return.
156
+ if (isReadOnly) {
157
+ return
158
+ }
159
+
160
+ const isNextOn = !isOn
161
+
162
+ setIsOn(isNextOn)
163
+
164
+ onChange?.(isNextOn)
165
+ }}
166
+ role="switch"
167
+ type="button"
168
+ >
169
+ <span
170
+ className={toClassName(
171
+ "rounded-full bg-surface-raised transition-transform duration-(--duration-fast) ease-standard",
172
+ isOn
173
+ ? THUMB_ON_TRANSLATE_CLASS[size]
174
+ : "translate-x-0",
175
+ THUMB_SIZE_CLASS[size],
176
+ )}
177
+ />
178
+ </button>
179
+
180
+ <span id={labelId}>{label}</span>
181
+ </span>
182
+ )
183
+ }
package/src/index.ts CHANGED
@@ -52,6 +52,8 @@ export type {
52
52
  CardSurface,
53
53
  } from "./Card/Card.tsx"
54
54
  export { Card } from "./Card/Card.tsx"
55
+ export type { CheckboxProps } from "./Checkbox/Checkbox.tsx"
56
+ export { Checkbox } from "./Checkbox/Checkbox.tsx"
55
57
  export type { ColorSchemeSwitcherProps } from "./ColorSchemeSwitcher/ColorSchemeSwitcher.tsx"
56
58
  export { ColorSchemeSwitcher } from "./ColorSchemeSwitcher/ColorSchemeSwitcher.tsx"
57
59
  export type {
@@ -141,6 +143,11 @@ export {
141
143
  toProgressPercent,
142
144
  toProgressValue,
143
145
  } from "./ProgressBar/progressValue.ts"
146
+ export type {
147
+ RadioGroupProps,
148
+ RadioItem,
149
+ } from "./RadioGroup/RadioGroup.tsx"
150
+ export { RadioGroup } from "./RadioGroup/RadioGroup.tsx"
144
151
  export type {
145
152
  SegmentedControlProps,
146
153
  SegmentedItem,
@@ -171,6 +178,8 @@ export type {
171
178
  SwatchSize,
172
179
  } from "./Swatch/Swatch.tsx"
173
180
  export { Swatch } from "./Swatch/Swatch.tsx"
181
+ export type { SwitchProps } from "./Switch/Switch.tsx"
182
+ export { Switch } from "./Switch/Switch.tsx"
174
183
  export type { SlotProps } from "./slotProps.ts"
175
184
  export { mergeSlotProps } from "./slotProps.ts"
176
185
  export {
package/src/slotWiring.ts CHANGED
@@ -15,116 +15,24 @@
15
15
  * a CSS bug and is not one.
16
16
  * - **`on*`** is a listener, and listeners are plural.
17
17
  *
18
- * ### Why this is not imported from `@charcuterie/logic`
18
+ * ### Shared with `@charcuterie/logic`
19
19
  *
20
- * `useClonedChild`'s `mergeClonedProps` applies the same rule one
21
- * level down — between a slot and the element a caller wrote — and
22
- * the two are near-identical. Sharing them means a new public export
23
- * on `@charcuterie/logic`, which is a `minor`; this is a patch, and
24
- * the duplication is thirty lines against a wrong version number.
25
- * Queued in `docs/2026-08-02-reported-by-consumers-queued-for-1-1.md`.
20
+ * `useClonedChild`'s `mergeClonedProps` applies the identical rule
21
+ * one level down — between a slot and the element a caller wrote —
22
+ * so the `ref`/`on*` primitives (`mergeRefs`, `chainHandlers`, and
23
+ * the two guards) are imported from `@charcuterie/logic` rather than
24
+ * copied here, where they used to be a byte-for-byte duplicate. Only
25
+ * `mergeSlotWiring` stays local: it merges the *other* direction
26
+ * (own over received) and returns just the wiring keys, which is a
27
+ * different function from `mergeClonedProps`.
26
28
  */
27
29
 
28
- const isEventHandlerName = (name: string) =>
29
- /^on[A-Z]/.test(name)
30
-
31
- type MergeableRef =
32
- | ((node: unknown) => unknown)
33
- | { current: unknown }
34
-
35
- const isMergeableRef = (
36
- value: unknown,
37
- ): value is MergeableRef =>
38
- typeof value === "function" ||
39
- (typeof value === "object" &&
40
- value !== null &&
41
- "current" in value)
42
-
43
- const setRef = (ref: MergeableRef, node: unknown) => {
44
- if (typeof ref === "function") {
45
- return ref(node)
46
- }
47
-
48
- ref.current = node
49
-
50
- return undefined
51
- }
52
-
53
- /**
54
- * Every pair of refs ever merged, so the same two always come back
55
- * as the **same function** — which is load-bearing rather than
56
- * frugal.
57
- *
58
- * React re-runs a callback ref whenever its identity changes between
59
- * renders, detaching with the old one and attaching with the new.
60
- * Both refs here are floating-ui `setReference`s, and calling one
61
- * sets state; a merged ref rebuilt every render would call both
62
- * twice per render, each call scheduling the render that rebuilds
63
- * it. `mergeSlotProps` is a plain function called during render, so
64
- * it cannot reach for `useMemo` — the memo has to be in the identity
65
- * of the inputs, which is what a `WeakMap` of `WeakMap`s is.
66
- */
67
- const mergedRefs = new WeakMap<
68
- MergeableRef,
69
- WeakMap<MergeableRef, (node: unknown) => () => void>
70
- >()
71
-
72
- const mergeRefs = (
73
- first: MergeableRef,
74
- second: MergeableRef,
75
- ) => {
76
- const bySecond =
77
- mergedRefs.get(first) ??
78
- new WeakMap<
79
- MergeableRef,
80
- (node: unknown) => () => void
81
- >()
82
-
83
- mergedRefs.set(first, bySecond)
84
-
85
- const cached = bySecond.get(second)
86
-
87
- if (cached) {
88
- return cached
89
- }
90
-
91
- const merged = (node: unknown) => {
92
- const cleanups = [
93
- setRef(first, node),
94
- setRef(second, node),
95
- ]
96
-
97
- // Per ref: one that returned a cleanup has opted out of being
98
- // called with `null`, and one that did not still expects it.
99
- return () => {
100
- for (const [index, cleanup] of cleanups.entries()) {
101
- if (typeof cleanup === "function") {
102
- cleanup()
103
- } else {
104
- setRef(index === 0 ? first : second, null)
105
- }
106
- }
107
- }
108
- }
109
-
110
- bySecond.set(second, merged)
111
-
112
- return merged
113
- }
114
-
115
- const chainHandlers =
116
- (
117
- first: (...args: unknown[]) => unknown,
118
- second: (...args: unknown[]) => unknown,
119
- ) =>
120
- (...args: unknown[]) => {
121
- // Outer first, the same order `aria-describedby` is joined in:
122
- // the merge order is structural — whoever wrapped whom — and the
123
- // author of the nesting is the one who chose it.
124
- first(...args)
125
-
126
- second(...args)
127
- }
30
+ import {
31
+ chainHandlers,
32
+ isEventHandlerName,
33
+ isMergeableRef,
34
+ mergeRefs,
35
+ } from "@charcuterie/logic"
128
36
 
129
37
  /**
130
38
  * The keys of `ownProps` that must not simply replace the ones
@@ -303,7 +303,17 @@ test("the barrel is the only place components are re-exported", async () => {
303
303
  // `Swatch` — a colour as content with a required name, the one
304
304
  // colour the system does not own — comes out of `portly-controllers`,
305
305
  // the fleet's newest consumer: +1 -> 32.
306
- expect(componentNames.length).toBe(32)
306
+ //
307
+ // The boolean-input family fills the gap `mux-magic`'s `BooleanField`
308
+ // hand-rolled around: `Checkbox` and `Switch` (the same state kind,
309
+ // a submitted value versus a setting that takes effect on flip) and
310
+ // `RadioGroup` (the stacked sibling of `SegmentedControl`, same
311
+ // `SinglePicker` + `RovingFocus` composition): +3 -> 35.
312
+ // `RadioGroupOption` is `RadioGroup`'s member — its own file only
313
+ // because both registrations are effects — and stays out of this
314
+ // count and the barrel by the `<Name>/<Name>.tsx` rule, exactly as
315
+ // `SegmentedOption` does.
316
+ expect(componentNames.length).toBe(35)
307
317
 
308
318
  for (const name of componentNames) {
309
319
  expect(barrel).toContain(`export { ${name} }`)
@@ -256,16 +256,18 @@ test("this package has class names to check at all", () => {
256
256
  })
257
257
 
258
258
  /**
259
- * The two class names in this package that are deliberately not
259
+ * The class names in this package that are deliberately not
260
260
  * utilities.
261
261
  *
262
- * `group` is Tailwind's own marker — it generates no CSS by design,
263
- * it is what `group-hover:` reads. The `charcuterie-*` four are the
264
- * looping affordances, which cannot be utilities because Tailwind's
265
- * own `animate-spin` hardcodes 1s past `prefers-reduced-motion`;
266
- * they live in `styles.css` and are checked against it below.
262
+ * `group` and `peer` are Tailwind's own markers — they generate no
263
+ * CSS by design, they are what `group-hover:` and `peer-checked:`
264
+ * read (`Checkbox` overlays its tick with `peer-checked:visible`).
265
+ * The `charcuterie-*` four are the looping affordances, which cannot
266
+ * be utilities because Tailwind's own `animate-spin` hardcodes 1s
267
+ * past `prefers-reduced-motion`; they live in `styles.css` and are
268
+ * checked against it below.
267
269
  */
268
- const TAILWIND_MARKER_CLASSES = ["group"]
270
+ const TAILWIND_MARKER_CLASSES = ["group", "peer"]
269
271
 
270
272
  test("every `charcuterie-` class a component uses is defined in styles.css", async () => {
271
273
  // The other half of the "no silently-unstyled component" gate. A