@charcuterie/ui 2.4.0 → 2.5.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 (60) hide show
  1. package/dist/Checkbox/Checkbox.d.ts +59 -0
  2. package/dist/Checkbox/Checkbox.d.ts.map +1 -0
  3. package/dist/Checkbox/Checkbox.js +61 -0
  4. package/dist/Checkbox/Checkbox.js.map +1 -0
  5. package/dist/Combobox/Combobox.d.ts.map +1 -1
  6. package/dist/Combobox/Combobox.js +1 -2
  7. package/dist/Combobox/Combobox.js.map +1 -1
  8. package/dist/Field/Field.d.ts.map +1 -1
  9. package/dist/Field/Field.js +12 -1
  10. package/dist/Field/Field.js.map +1 -1
  11. package/dist/Menu/Menu.d.ts +60 -2
  12. package/dist/Menu/Menu.d.ts.map +1 -1
  13. package/dist/Menu/Menu.js +58 -11
  14. package/dist/Menu/Menu.js.map +1 -1
  15. package/dist/RadioGroup/RadioGroup.d.ts +47 -0
  16. package/dist/RadioGroup/RadioGroup.d.ts.map +1 -0
  17. package/dist/RadioGroup/RadioGroup.js +114 -0
  18. package/dist/RadioGroup/RadioGroup.js.map +1 -0
  19. package/dist/RadioGroup/RadioGroupOption.d.ts +26 -0
  20. package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -0
  21. package/dist/RadioGroup/RadioGroupOption.js +52 -0
  22. package/dist/RadioGroup/RadioGroupOption.js.map +1 -0
  23. package/dist/Switch/Switch.d.ts +54 -0
  24. package/dist/Switch/Switch.d.ts.map +1 -0
  25. package/dist/Switch/Switch.js +94 -0
  26. package/dist/Switch/Switch.js.map +1 -0
  27. package/dist/index.d.ts +6 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +3 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/slotWiring.d.ts +9 -7
  32. package/dist/slotWiring.d.ts.map +1 -1
  33. package/dist/slotWiring.js +10 -70
  34. package/dist/slotWiring.js.map +1 -1
  35. package/package.json +3 -3
  36. package/src/Checkbox/Checkbox.mdx +58 -0
  37. package/src/Checkbox/Checkbox.stories.tsx +100 -0
  38. package/src/Checkbox/Checkbox.test.tsx +80 -0
  39. package/src/Checkbox/Checkbox.tsx +146 -0
  40. package/src/Combobox/Combobox.stories.tsx +64 -4
  41. package/src/Combobox/Combobox.tsx +13 -1
  42. package/src/Field/Field.stories.tsx +19 -0
  43. package/src/Field/Field.test.tsx +30 -2
  44. package/src/Field/Field.tsx +13 -1
  45. package/src/Menu/Menu.stories.tsx +80 -2
  46. package/src/Menu/Menu.test.tsx +102 -0
  47. package/src/Menu/Menu.tsx +156 -17
  48. package/src/RadioGroup/RadioGroup.mdx +58 -0
  49. package/src/RadioGroup/RadioGroup.stories.tsx +130 -0
  50. package/src/RadioGroup/RadioGroup.test.tsx +191 -0
  51. package/src/RadioGroup/RadioGroup.tsx +204 -0
  52. package/src/RadioGroup/RadioGroupOption.tsx +126 -0
  53. package/src/Switch/Switch.mdx +53 -0
  54. package/src/Switch/Switch.stories.tsx +94 -0
  55. package/src/Switch/Switch.test.tsx +86 -0
  56. package/src/Switch/Switch.tsx +162 -0
  57. package/src/index.ts +9 -0
  58. package/src/slotWiring.ts +15 -107
  59. package/src/sourceRules.test.ts +11 -1
  60. package/src/tailwindCandidates.test.ts +9 -7
@@ -0,0 +1,86 @@
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
+ })
@@ -0,0 +1,162 @@
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
+ /** The visible text, and the switch's accessible name. */
20
+ label: ReactNode
21
+ onChange?: (isChecked: boolean) => void
22
+ size?: ControlSize
23
+ }
24
+
25
+ // Track and thumb are sized so `p-1` (4px) breathes evenly on every
26
+ // side and the thumb is centred by arithmetic, not by eye: inner
27
+ // height (track − 2·padding) equals the thumb, and the on-translate is
28
+ // (track width − 2·padding − thumb). Break one of those and the thumb
29
+ // clips an edge, which is what the first cut did.
30
+ const TRACK_SIZE_CLASS: Record<ControlSize, string> = {
31
+ sm: "h-5 w-9",
32
+ md: "h-6 w-11",
33
+ lg: "h-7 w-14",
34
+ }
35
+
36
+ const THUMB_SIZE_CLASS: Record<ControlSize, string> = {
37
+ sm: "size-3",
38
+ md: "size-4",
39
+ lg: "size-5",
40
+ }
41
+
42
+ const THUMB_ON_TRANSLATE_CLASS: Record<
43
+ ControlSize,
44
+ string
45
+ > = {
46
+ sm: "translate-x-4",
47
+ md: "translate-x-5",
48
+ lg: "translate-x-7",
49
+ }
50
+
51
+ const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
52
+ sm: "text-sm",
53
+ md: "text-md",
54
+ lg: "text-lg",
55
+ }
56
+
57
+ /**
58
+ * A boolean that takes effect the moment it is flipped — a setting,
59
+ * not a form value.
60
+ *
61
+ * A `Checkbox` and a `Switch` are the same state kind and a
62
+ * different affordance, and the split is meaning rather than looks:
63
+ * a checkbox is a value you submit ("I agree"), a switch is a state
64
+ * you toggle and live with immediately ("dark mode on"). ARIA gives
65
+ * the second its own role, and a screen reader announces "on/off"
66
+ * for a `switch` where it announces "checked/unchecked" for a
67
+ * checkbox.
68
+ *
69
+ * ### A `button role="switch"`, not an `input`
70
+ *
71
+ * The sliding track and thumb are the whole point of the affordance,
72
+ * and a `<button>` styles them without fighting a native control's
73
+ * own rendering. It carries `aria-checked`, `aria-labelledby`, and
74
+ * nothing else the browser would try to own. The value lives in
75
+ * React state seeded once from `isChecked` — the switch is the
76
+ * store, the same as the DOM is for `Checkbox`.
77
+ *
78
+ * ### The thumb is one colour, the track carries the state
79
+ *
80
+ * `bg-surface-raised` in both positions — the knob is the same object
81
+ * sliding, not two differently-painted dots, which is what the first
82
+ * cut looked like when off was `content-secondary` and on was
83
+ * `on-solid`. A themed system has no single colour that is "light" in
84
+ * both schemes, so consistency lives *within* a scheme: `surface-raised`
85
+ * is a white knob in a light one and a raised charcoal knob in a dark
86
+ * one, off and on identical either way. The **track** shows the state —
87
+ * `bg-intent-accent-solid` on, `bg-surface-sunken` with a `ring-2`
88
+ * outline off — which is where a switch's state belongs, and the ring
89
+ * gives the off track the defined edge a bare well would miss on a pale
90
+ * theme.
91
+ */
92
+ export const Switch = ({
93
+ className,
94
+ isChecked = false,
95
+ isDisabled = false,
96
+ label,
97
+ onChange,
98
+ size = "md",
99
+ }: SwitchProps): ReactNode => {
100
+ const [isOn, setIsOn] = useState(isChecked)
101
+
102
+ const labelId = useUniqueId()
103
+
104
+ return (
105
+ <span
106
+ className={toClassName(
107
+ "inline-flex items-center gap-2 text-content-secondary select-none",
108
+ TEXT_SIZE_CLASS[size],
109
+ isDisabled && "text-content-disabled",
110
+ className,
111
+ )}
112
+ >
113
+ <button
114
+ aria-checked={isOn}
115
+ aria-labelledby={labelId}
116
+ className={toClassName(
117
+ // `p-1` is the breathing room; `ring-inset` draws the off
118
+ // outline without changing the box size, so the centring
119
+ // arithmetic above stays exact.
120
+ "inline-flex shrink-0 cursor-pointer items-center rounded-full p-1 transition-colors duration-(--duration-fast) ease-standard",
121
+ // One state class, chosen by ternary — two `bg-`/`ring-`
122
+ // utilities of the same specificity do not resolve by
123
+ // class-list order, so disabled has to win as the only one
124
+ // written.
125
+ isDisabled
126
+ ? "cursor-not-allowed bg-surface-sunken ring-2 ring-border-subtle ring-inset"
127
+ : isOn
128
+ ? "bg-intent-accent-solid"
129
+ : "bg-surface-sunken ring-2 ring-border-strong ring-inset",
130
+ TRACK_SIZE_CLASS[size],
131
+ FOCUS_RING_CLASS,
132
+ )}
133
+ disabled={isDisabled}
134
+ onClick={() => {
135
+ const isNextOn = !isOn
136
+
137
+ setIsOn(isNextOn)
138
+
139
+ onChange?.(isNextOn)
140
+ }}
141
+ role="switch"
142
+ type="button"
143
+ >
144
+ <span
145
+ className={toClassName(
146
+ "rounded-full transition-transform duration-(--duration-fast) ease-standard",
147
+ // One knob, same colour off and on; disabled mutes it.
148
+ isDisabled
149
+ ? "bg-content-disabled"
150
+ : "bg-surface-raised",
151
+ isOn
152
+ ? THUMB_ON_TRANSLATE_CLASS[size]
153
+ : "translate-x-0",
154
+ THUMB_SIZE_CLASS[size],
155
+ )}
156
+ />
157
+ </button>
158
+
159
+ <span id={labelId}>{label}</span>
160
+ </span>
161
+ )
162
+ }
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