@charcuterie/ui 2.5.0 → 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.
@@ -16,6 +16,15 @@ export type CheckboxProps = {
16
16
  */
17
17
  isChecked?: boolean;
18
18
  isDisabled?: boolean;
19
+ /**
20
+ * Shows the value at full contrast but refuses to change it — a
21
+ * setting you may read here and edit elsewhere. Distinct from
22
+ * `isDisabled`, which dims: a read-only box is not turned down,
23
+ * because the whole point is that you can still read it. Announced
24
+ * as `aria-readonly`, and the toggle is blocked on both pointer and
25
+ * `Space`.
26
+ */
27
+ isReadOnly?: boolean;
19
28
  /**
20
29
  * The visible text beside the box, and the checkbox's accessible
21
30
  * name — the `<label>` wraps the control, so there is no `for` to
@@ -55,5 +64,5 @@ export type CheckboxProps = {
55
64
  * `<input>`'s job. `isChecked` seeds the first paint and nothing
56
65
  * after.
57
66
  */
58
- export declare const Checkbox: ({ className, id, isChecked, isDisabled, label, onChange, size, }: CheckboxProps) => ReactNode;
67
+ export declare const Checkbox: ({ className, id, isChecked, isDisabled, isReadOnly, label, onChange, size, }: CheckboxProps) => ReactNode;
59
68
  //# sourceMappingURL=Checkbox.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Checkbox.d.ts","sourceRoot":"","sources":["../../src/Checkbox/Checkbox.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAe,SAAS,EAAE,MAAM,OAAO,CAAA;AAKnD,MAAM,MAAM,aAAa,GAAG;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,EAAE,CAAC,EAAE,MAAM,CAAA;IACX;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,KAAK,EAAE,SAAS,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAA;IACvC,IAAI,CAAC,EAAE,WAAW,CAAA;CACnB,CAAA;AAoBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,QAAQ,GAAI,kEAQtB,aAAa,KAAG,SAyDlB,CAAA"}
1
+ {"version":3,"file":"Checkbox.d.ts","sourceRoot":"","sources":["../../src/Checkbox/Checkbox.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAe,SAAS,EAAE,MAAM,OAAO,CAAA;AAKnD,MAAM,MAAM,aAAa,GAAG;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,EAAE,CAAC,EAAE,MAAM,CAAA;IACX;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,KAAK,EAAE,SAAS,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAA;IACvC,IAAI,CAAC,EAAE,WAAW,CAAA;CACnB,CAAA;AAoBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,QAAQ,GAAI,8EAStB,aAAa,KAAG,SA0GlB,CAAA"}
@@ -45,17 +45,64 @@ const TEXT_SIZE_CLASS = {
45
45
  * `<input>`'s job. `isChecked` seeds the first paint and nothing
46
46
  * after.
47
47
  */
48
- export const Checkbox = ({ className, id, isChecked = false, isDisabled = false, label, onChange, size = "md", }) => (_jsxs("label", { className: toClassName("inline-flex cursor-pointer items-center gap-2 text-content-secondary select-none", TEXT_SIZE_CLASS[size], isDisabled &&
49
- "cursor-not-allowed text-content-disabled", className), children: [_jsxs("span", { className: "relative grid shrink-0 place-items-center", children: [_jsx("input", { className: toClassName("peer cursor-pointer appearance-none rounded-sm border border-border-strong bg-surface-sunken transition-colors duration-(--duration-fast) ease-standard", "checked:border-intent-accent-solid checked:bg-intent-accent-solid",
50
- // Disabled mutes the border and the cursor, and — unlike the
51
- // shared `DISABLED_CLASS` — does *not* touch the background.
52
- // The generic bundle repaints the box `surface-sunken`, which
53
- // erased both the checked accent fill (white tick on grey)
54
- // and, in a light scheme, the unchecked box entirely: a
55
- // `border-subtle` outline on `surface-sunken` on
56
- // `surface-base` is three near-identical creams. A disabled
57
- // box is still a box the user has to see.
58
- "disabled:cursor-not-allowed disabled:border-border-default", BOX_SIZE_CLASS[size], FOCUS_RING_CLASS), defaultChecked: isChecked, disabled: isDisabled, id: id, onChange: (event) => {
48
+ export const Checkbox = ({ className, id, isChecked = false, isDisabled = false, isReadOnly = false, label, onChange, size = "md", }) => (_jsxs("label", { className: toClassName("inline-flex items-center gap-2 text-content-secondary select-none", TEXT_SIZE_CLASS[size],
49
+ // One cursor, chosen by state — two cursor utilities of the same
50
+ // specificity do not resolve by class-list order.
51
+ isDisabled
52
+ ? "cursor-not-allowed"
53
+ : isReadOnly
54
+ ? "cursor-default"
55
+ : "cursor-pointer",
56
+ // Disabled dims the whole control with opacity rather than fading
57
+ // the box to a quieter token. The token scale has no step between
58
+ // `border-default` and `border-strong`, so a muted outline was
59
+ // either invisible or indistinguishable from enabled; `opacity-60`
60
+ // keeps the full shape and colour and just turns them down, which
61
+ // is the "present but inactive" the box actually is. Read-only is
62
+ // *not* dimmed — its whole point is that the value stays readable.
63
+ // The same treatment is on `Switch` and `RadioGroup`.
64
+ isDisabled && "opacity-60", className), children: [_jsxs("span", { className: "relative grid shrink-0 place-items-center", children: [_jsx("input", { "aria-readonly": isReadOnly || undefined, className: toClassName(
65
+ // `border-2` is the family's shared weight — the `Switch`
66
+ // track's `ring-2` and the `RadioGroup` ring match it, so the
67
+ // three read as one set — and a 2px edge is also what keeps a
68
+ // disabled box visible where a 1px one washed out.
69
+ "peer appearance-none rounded-sm border-2 bg-surface-sunken transition-colors duration-(--duration-fast) ease-standard",
70
+ // Read-only wears the **neutral** intent, not the accent: full
71
+ // contrast (so the value reads) but plainly not an actionable
72
+ // accent control, with a softer `border-default` resting edge.
73
+ // Three distinct states result — enabled (accent, crisp),
74
+ // disabled (accent, dimmed by the wrapper), read-only (neutral).
75
+ // No `disabled:` colour override; the wrapper's `opacity-60`
76
+ // does the dimming, and doubling it on the box is what made a
77
+ // disabled checkbox vanish.
78
+ isReadOnly
79
+ ? "border-border-default checked:border-intent-neutral-solid checked:bg-intent-neutral-solid"
80
+ : "border-border-strong checked:border-intent-accent-solid checked:bg-intent-accent-solid", isDisabled
81
+ ? "cursor-not-allowed"
82
+ : isReadOnly
83
+ ? "cursor-default"
84
+ : "cursor-pointer", BOX_SIZE_CLASS[size], FOCUS_RING_CLASS), defaultChecked: isChecked, disabled: isDisabled, id: id, onChange: (event) => {
85
+ if (isReadOnly) {
86
+ return;
87
+ }
59
88
  onChange?.(event.target.checked);
60
- }, type: "checkbox" }), _jsx("svg", { "aria-hidden": "true", className: toClassName("pointer-events-none invisible absolute text-intent-accent-on-solid peer-checked:visible", CHECK_SIZE_CLASS[size]), fill: "none", stroke: "currentColor", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 3, viewBox: "0 0 24 24", children: _jsx("path", { d: "M20 6 9 17l-5-5" }) })] }), _jsx("span", { children: label })] }));
89
+ }, onClick: (clickEvent) => {
90
+ // Native checkboxes have no `readonly`, so the toggle is
91
+ // blocked here — `preventDefault` on the click stops the
92
+ // checked flip a pointer would make.
93
+ if (isReadOnly) {
94
+ clickEvent.preventDefault();
95
+ }
96
+ }, onKeyDown: (keyEvent) => {
97
+ // …and Space, the keyboard toggle a click block does not
98
+ // cover.
99
+ if (isReadOnly && keyEvent.key === " ") {
100
+ keyEvent.preventDefault();
101
+ }
102
+ }, type: "checkbox" }), _jsx("svg", { "aria-hidden": "true", className: toClassName("pointer-events-none invisible absolute peer-checked:visible",
103
+ // Matches whichever fill the box wears, so the tick keeps its
104
+ // guaranteed on-solid contrast in either intent.
105
+ isReadOnly
106
+ ? "text-intent-neutral-on-solid"
107
+ : "text-intent-accent-on-solid", CHECK_SIZE_CLASS[size]), fill: "none", stroke: "currentColor", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 3, viewBox: "0 0 24 24", children: _jsx("path", { d: "M20 6 9 17l-5-5" }) })] }), _jsx("span", { children: label })] }));
61
108
  //# sourceMappingURL=Checkbox.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"Checkbox.js","sourceRoot":"","sources":["../../src/Checkbox/Checkbox.tsx"],"names":[],"mappings":";AAGA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACrD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AA6B/C,MAAM,cAAc,GAAgC;IAClD,EAAE,EAAE,UAAU;IACd,EAAE,EAAE,QAAQ;IACZ,EAAE,EAAE,QAAQ;CACb,CAAA;AAED,MAAM,gBAAgB,GAAgC;IACpD,EAAE,EAAE,UAAU;IACd,EAAE,EAAE,QAAQ;IACZ,EAAE,EAAE,UAAU;CACf,CAAA;AAED,MAAM,eAAe,GAAgC;IACnD,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;CACd,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,EACvB,SAAS,EACT,EAAE,EACF,SAAS,GAAG,KAAK,EACjB,UAAU,GAAG,KAAK,EAClB,KAAK,EACL,QAAQ,EACR,IAAI,GAAG,IAAI,GACG,EAAa,EAAE,CAAC,CAC9B,iBACE,SAAS,EAAE,WAAW,CACpB,kFAAkF,EAClF,eAAe,CAAC,IAAI,CAAC,EACrB,UAAU;QACR,0CAA0C,EAC5C,SAAS,CACV,aAED,gBAAM,SAAS,EAAC,2CAA2C,aACzD,gBACE,SAAS,EAAE,WAAW,CACpB,yJAAyJ,EACzJ,mEAAmE;oBACnE,6DAA6D;oBAC7D,6DAA6D;oBAC7D,8DAA8D;oBAC9D,2DAA2D;oBAC3D,wDAAwD;oBACxD,iDAAiD;oBACjD,4DAA4D;oBAC5D,0CAA0C;oBAC1C,4DAA4D,EAC5D,cAAc,CAAC,IAAI,CAAC,EACpB,gBAAgB,CACjB,EACD,cAAc,EAAE,SAAS,EACzB,QAAQ,EAAE,UAAU,EACpB,EAAE,EAAE,EAAE,EACN,QAAQ,EAAE,CACR,KAAoC,EACpC,EAAE;wBACF,QAAQ,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;oBAClC,CAAC,EACD,IAAI,EAAC,UAAU,GACf,EAEF,6BACc,MAAM,EAClB,SAAS,EAAE,WAAW,CACpB,yFAAyF,EACzF,gBAAgB,CAAC,IAAI,CAAC,CACvB,EACD,IAAI,EAAC,MAAM,EACX,MAAM,EAAC,cAAc,EACrB,aAAa,EAAC,OAAO,EACrB,cAAc,EAAC,OAAO,EACtB,WAAW,EAAE,CAAC,EACd,OAAO,EAAC,WAAW,YAEnB,eAAM,CAAC,EAAC,iBAAiB,GAAG,GACxB,IACD,EAEP,yBAAO,KAAK,GAAQ,IACd,CACT,CAAA"}
1
+ {"version":3,"file":"Checkbox.js","sourceRoot":"","sources":["../../src/Checkbox/Checkbox.tsx"],"names":[],"mappings":"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"}
@@ -14,6 +14,13 @@ export type RadioItem = {
14
14
  export type RadioGroupProps = {
15
15
  className?: string;
16
16
  items: readonly RadioItem[];
17
+ /**
18
+ * Shows which option is chosen at full contrast but refuses to move
19
+ * the choice — the arrow keys and clicks no longer select, and the
20
+ * group announces `aria-readonly`. Focus can still travel the
21
+ * options so a keyboard user can read them.
22
+ */
23
+ isReadOnly?: boolean;
17
24
  /** The group's accessible name. Required. */
18
25
  label: string;
19
26
  onChange?: (selectedValue: string | null) => void;
@@ -43,5 +50,5 @@ export type RadioGroupProps = {
43
50
  * move focus and check — APG's one activation mode, the same reason
44
51
  * `SegmentedControl` has no `manual` prop.
45
52
  */
46
- export declare const RadioGroup: ({ className, items, label, onChange, selectedValue, size, }: RadioGroupProps) => ReactNode;
53
+ export declare const RadioGroup: ({ className, items, isReadOnly, label, onChange, selectedValue, size, }: RadioGroupProps) => ReactNode;
47
54
  //# sourceMappingURL=RadioGroup.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"RadioGroup.d.ts","sourceRoot":"","sources":["../../src/RadioGroup/RadioGroup.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAMtC,MAAM,MAAM,SAAS,GAAG;IACtB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,KAAK,EAAE,SAAS,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,SAAS,SAAS,EAAE,CAAA;IAC3B,6CAA6C;IAC7C,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;IACjD,0DAA0D;IAC1D,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,IAAI,CAAC,EAAE,WAAW,CAAA;CACnB,CAAA;AAQD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,UAAU,GAAI,6DAOxB,eAAe,KAAG,SAqIpB,CAAA"}
1
+ {"version":3,"file":"RadioGroup.d.ts","sourceRoot":"","sources":["../../src/RadioGroup/RadioGroup.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAMtC,MAAM,MAAM,SAAS,GAAG;IACtB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,KAAK,EAAE,SAAS,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,SAAS,SAAS,EAAE,CAAA;IAC3B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,6CAA6C;IAC7C,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;IACjD,0DAA0D;IAC1D,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,IAAI,CAAC,EAAE,WAAW,CAAA;CACnB,CAAA;AAQD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,UAAU,GAAI,yEAQxB,eAAe,KAAG,SAgJpB,CAAA"}
@@ -30,7 +30,7 @@ const GAP_CLASS = {
30
30
  * move focus and check — APG's one activation mode, the same reason
31
31
  * `SegmentedControl` has no `manual` prop.
32
32
  */
33
- export const RadioGroup = ({ className, items, label, onChange, selectedValue, size = "md", }) => {
33
+ export const RadioGroup = ({ className, items, isReadOnly = false, label, onChange, selectedValue, size = "md", }) => {
34
34
  const groupRef = useRef(null);
35
35
  const optionElements = useRef(new Map());
36
36
  const initialValue = selectedValue ??
@@ -53,17 +53,22 @@ export const RadioGroup = ({ className, items, label, onChange, selectedValue, s
53
53
  */
54
54
  const checkedValue = selection.selectedValue ?? selection.pendingValue;
55
55
  const chooseOption = useCallback((value) => {
56
+ // Focus may still land on the option so it can be read; only the
57
+ // choice is frozen when read-only.
56
58
  setActiveValue(value);
57
- select(value);
58
- }, [select, setActiveValue]);
59
+ if (!isReadOnly) {
60
+ select(value);
61
+ }
62
+ }, [isReadOnly, select, setActiveValue]);
59
63
  useEffect(() => {
60
64
  // Selection follows focus — the radio-group rule, and the one
61
65
  // line a `manual` mode this component deliberately lacks would
62
- // remove.
63
- if (activeValue !== null) {
66
+ // remove. Read-only severs exactly this link: focus still travels
67
+ // for reading, but moving it no longer moves the choice.
68
+ if (activeValue !== null && !isReadOnly) {
64
69
  select(activeValue);
65
70
  }
66
- }, [activeValue, select]);
71
+ }, [activeValue, isReadOnly, select]);
67
72
  useEffect(() => {
68
73
  const group = groupRef.current;
69
74
  if (!group ||
@@ -73,7 +78,7 @@ export const RadioGroup = ({ className, items, label, onChange, selectedValue, s
73
78
  }
74
79
  optionElements.current.get(activeValue)?.focus();
75
80
  }, [activeValue]);
76
- return (_jsx("div", { "aria-label": label, className: toClassName("flex flex-col", GAP_CLASS[size], className), onKeyDown: (keyEvent) => {
81
+ return (_jsx("div", { "aria-label": label, "aria-readonly": isReadOnly || undefined, className: toClassName("flex flex-col", GAP_CLASS[size], className), onKeyDown: (keyEvent) => {
77
82
  const commands = {
78
83
  ArrowDown: focus.next,
79
84
  ArrowLeft: focus.previous,
@@ -93,12 +98,14 @@ export const RadioGroup = ({ className, items, label, onChange, selectedValue, s
93
98
  }
94
99
  // Space checks the focused option. Selection already follows
95
100
  // focus, so this is almost always a no-op — one idempotent
96
- // command, not a second code path.
97
- if (keyEvent.key === " " && activeValue !== null) {
101
+ // command, not a second code path — and read-only skips it.
102
+ if (keyEvent.key === " " &&
103
+ activeValue !== null &&
104
+ !isReadOnly) {
98
105
  keyEvent.preventDefault();
99
106
  select(activeValue);
100
107
  }
101
- }, ref: groupRef, role: "radiogroup", children: items.map((item) => (_jsx(RadioGroupOption, { isChecked: checkedValue === item.value, item: item, onChoose: chooseOption, registerFocus: focus.register, registerSelection: selection.register, size: size,
108
+ }, ref: groupRef, role: "radiogroup", children: items.map((item) => (_jsx(RadioGroupOption, { isChecked: checkedValue === item.value, isReadOnly: isReadOnly, item: item, onChoose: chooseOption, registerFocus: focus.register, registerSelection: selection.register, size: size,
102
109
  // The roving-tabindex rule read from the core rather than
103
110
  // restated here: exactly one option is in the tab order, so
104
111
  // Tab enters and leaves the group once.
@@ -1 +1 @@
1
- {"version":3,"file":"RadioGroup.js","sourceRoot":"","sources":["../../src/RadioGroup/RadioGroup.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"RadioGroup.js","sourceRoot":"","sources":["../../src/RadioGroup/RadioGroup.tsx"],"names":[],"mappings":"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"}
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
3
3
  import type { RadioItem } from "./RadioGroup.tsx";
4
4
  export type RadioGroupOptionProps = {
5
5
  isChecked: boolean;
6
+ isReadOnly: boolean;
6
7
  item: RadioItem;
7
8
  onChoose: (value: string) => void;
8
9
  /** `RovingFocus.register` — membership of the arrow-key group. */
@@ -22,5 +23,5 @@ export type RadioGroupOptionProps = {
22
23
  * and sized independently of the ring — a filled `bg-intent-accent`
23
24
  * ring with no inner dot reads as a checkbox, not a radio.
24
25
  */
25
- export declare const RadioGroupOption: ({ isChecked, item, onChoose, registerFocus, registerSelection, size, tabIndex, trackElement, }: RadioGroupOptionProps) => ReactNode;
26
+ export declare const RadioGroupOption: ({ isChecked, isReadOnly, item, onChoose, registerFocus, registerSelection, size, tabIndex, trackElement, }: RadioGroupOptionProps) => ReactNode;
26
27
  //# sourceMappingURL=RadioGroupOption.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"RadioGroupOption.d.ts","sourceRoot":"","sources":["../../src/RadioGroup/RadioGroupOption.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAKtC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAEjD,MAAM,MAAM,qBAAqB,GAAG;IAClC,SAAS,EAAE,OAAO,CAAA;IAClB,IAAI,EAAE,SAAS,CAAA;IACf,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACjC,kEAAkE;IAClE,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,IAAI,CAAA;IAC5C,0DAA0D;IAC1D,iBAAiB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,IAAI,CAAA;IAChD,IAAI,EAAE,WAAW,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,CACZ,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,iBAAiB,GAAG,IAAI,KAC9B,IAAI,CAAA;CACV,CAAA;AAoBD;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,GAAI,gGAS9B,qBAAqB,KAAG,SAiE1B,CAAA"}
1
+ {"version":3,"file":"RadioGroupOption.d.ts","sourceRoot":"","sources":["../../src/RadioGroup/RadioGroupOption.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAKtC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAEjD,MAAM,MAAM,qBAAqB,GAAG;IAClC,SAAS,EAAE,OAAO,CAAA;IAClB,UAAU,EAAE,OAAO,CAAA;IACnB,IAAI,EAAE,SAAS,CAAA;IACf,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACjC,kEAAkE;IAClE,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,IAAI,CAAA;IAC5C,0DAA0D;IAC1D,iBAAiB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,IAAI,CAAA;IAChD,IAAI,EAAE,WAAW,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,CACZ,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,iBAAiB,GAAG,IAAI,KAC9B,IAAI,CAAA;CACV,CAAA;AAuBD;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,GAAI,4GAU9B,qBAAqB,KAAG,SAkF1B,CAAA"}
@@ -2,8 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect } from "react";
3
3
  import { FOCUS_RING_CLASS } from "../intentStyles.js";
4
4
  import { toClassName } from "../toClassName.js";
5
+ // Deliberately the `Switch`'s `THUMB_SIZE_CLASS`, value for value — a
6
+ // radio's ring and a switch's knob are then the same circle at every
7
+ // density, which is what makes the family read as one set.
5
8
  const RING_SIZE_CLASS = {
6
- sm: "size-3.5",
9
+ sm: "size-3",
7
10
  md: "size-4",
8
11
  lg: "size-5",
9
12
  };
@@ -26,7 +29,7 @@ const TEXT_SIZE_CLASS = {
26
29
  * and sized independently of the ring — a filled `bg-intent-accent`
27
30
  * ring with no inner dot reads as a checkbox, not a radio.
28
31
  */
29
- export const RadioGroupOption = ({ isChecked, item, onChoose, registerFocus, registerSelection, size, tabIndex, trackElement, }) => {
32
+ export const RadioGroupOption = ({ isChecked, isReadOnly, item, onChoose, registerFocus, registerSelection, size, tabIndex, trackElement, }) => {
30
33
  const { isDisabled = false, label, value } = item;
31
34
  useEffect(() => registerSelection(value), [registerSelection, value]);
32
35
  useEffect(() => {
@@ -35,18 +38,38 @@ export const RadioGroupOption = ({ isChecked, item, onChoose, registerFocus, reg
35
38
  }
36
39
  return registerFocus(value);
37
40
  }, [isDisabled, registerFocus, value]);
38
- return (_jsxs("button", { "aria-checked": isChecked, className: toClassName("inline-flex cursor-pointer items-center gap-2 rounded-sm text-start transition-colors duration-(--duration-fast) ease-standard", TEXT_SIZE_CLASS[size], isDisabled
39
- ? "cursor-not-allowed text-content-disabled"
40
- : "text-content-secondary hover:text-content-primary", FOCUS_RING_CLASS), disabled: isDisabled, onClick: () => {
41
+ return (_jsxs("button", { "aria-checked": isChecked, className: toClassName("inline-flex items-center gap-2 rounded-sm text-content-secondary text-start transition-colors duration-(--duration-fast) ease-standard", TEXT_SIZE_CLASS[size],
42
+ // Disabled turns the whole option down with `opacity-60`, the
43
+ // same family treatment as `Checkbox` and `Switch`, rather than
44
+ // fading the ring to a token that disappeared on a pale theme.
45
+ // Read-only keeps full contrast — only the cursor and the hover
46
+ // affordance say it will not move.
47
+ isDisabled
48
+ ? "cursor-not-allowed opacity-60"
49
+ : isReadOnly
50
+ ? "cursor-default"
51
+ : "cursor-pointer hover:text-content-primary", FOCUS_RING_CLASS), disabled: isDisabled, onClick: () => {
41
52
  onChoose(value);
42
53
  }, ref: (element) => {
43
54
  trackElement(value, element);
44
- }, role: "radio", tabIndex: tabIndex, type: "button", children: [_jsx("span", { className: toClassName("grid shrink-0 place-items-center rounded-full border transition-colors duration-(--duration-fast) ease-standard", isDisabled
45
- ? "border-border-subtle"
55
+ }, role: "radio", tabIndex: tabIndex, type: "button", children: [_jsx("span", { className: toClassName(
56
+ // `border-2` matches the `Switch` thumb's ring weight and the
57
+ // `Checkbox` box, and `RING_SIZE_CLASS` matches the thumb's
58
+ // diameter — so a radio dot and a switch knob are the same
59
+ // circle. A disabled ring holds `border-default` rather than
60
+ // fading to the `border-subtle` that vanished on a pale theme.
61
+ "grid shrink-0 place-items-center rounded-full border-2 transition-colors duration-(--duration-fast) ease-standard",
62
+ // Read-only wears the neutral intent and a softer resting edge
63
+ // — the same "readable but not an actionable accent" language
64
+ // as `Checkbox` and `Switch`.
65
+ isReadOnly
66
+ ? isChecked
67
+ ? "border-intent-neutral-solid"
68
+ : "border-border-default"
46
69
  : isChecked
47
70
  ? "border-intent-accent-solid"
48
- : "border-border-strong", RING_SIZE_CLASS[size]), children: _jsx("span", { className: toClassName("rounded-full transition-transform duration-(--duration-fast) ease-standard", isChecked ? "scale-100" : "scale-0", isDisabled
49
- ? "bg-content-disabled"
50
- : "bg-intent-accent-solid", DOT_SIZE_CLASS[size]) }) }), _jsx("span", { children: label })] }));
71
+ : "border-border-strong", RING_SIZE_CLASS[size]), children: _jsx("span", { className: toClassName("rounded-full transition-transform duration-(--duration-fast) ease-standard", isReadOnly
72
+ ? "bg-intent-neutral-solid"
73
+ : "bg-intent-accent-solid", isChecked ? "scale-100" : "scale-0", DOT_SIZE_CLASS[size]) }) }), _jsx("span", { children: label })] }));
51
74
  };
52
75
  //# sourceMappingURL=RadioGroupOption.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"RadioGroupOption.js","sourceRoot":"","sources":["../../src/RadioGroup/RadioGroupOption.tsx"],"names":[],"mappings":";AAEA,OAAO,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEjC,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACrD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAmB/C,MAAM,eAAe,GAAgC;IACnD,EAAE,EAAE,UAAU;IACd,EAAE,EAAE,QAAQ;IACZ,EAAE,EAAE,QAAQ;CACb,CAAA;AAED,MAAM,cAAc,GAAgC;IAClD,EAAE,EAAE,UAAU;IACd,EAAE,EAAE,QAAQ;IACZ,EAAE,EAAE,UAAU;CACf,CAAA;AAED,MAAM,eAAe,GAAgC;IACnD,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;CACd,CAAA;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,EAC/B,SAAS,EACT,IAAI,EACJ,QAAQ,EACR,aAAa,EACb,iBAAiB,EACjB,IAAI,EACJ,QAAQ,EACR,YAAY,GACU,EAAa,EAAE;IACrC,MAAM,EAAE,UAAU,GAAG,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,IAAI,CAAA;IAEjD,SAAS,CACP,GAAG,EAAE,CAAC,iBAAiB,CAAC,KAAK,CAAC,EAC9B,CAAC,iBAAiB,EAAE,KAAK,CAAC,CAC3B,CAAA;IAED,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,UAAU,EAAE,CAAC;YACf,OAAM;QACR,CAAC;QAED,OAAO,aAAa,CAAC,KAAK,CAAC,CAAA;IAC7B,CAAC,EAAE,CAAC,UAAU,EAAE,aAAa,EAAE,KAAK,CAAC,CAAC,CAAA;IAEtC,OAAO,CAEL,kCACgB,SAAS,EACvB,SAAS,EAAE,WAAW,CACpB,gIAAgI,EAChI,eAAe,CAAC,IAAI,CAAC,EACrB,UAAU;YACR,CAAC,CAAC,0CAA0C;YAC5C,CAAC,CAAC,mDAAmD,EACvD,gBAAgB,CACjB,EACD,QAAQ,EAAE,UAAU,EACpB,OAAO,EAAE,GAAG,EAAE;YACZ,QAAQ,CAAC,KAAK,CAAC,CAAA;QACjB,CAAC,EACD,GAAG,EAAE,CAAC,OAAO,EAAE,EAAE;YACf,YAAY,CAAC,KAAK,EAAE,OAAO,CAAC,CAAA;QAC9B,CAAC,EACD,IAAI,EAAC,OAAO,EACZ,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAC,QAAQ,aAEb,eACE,SAAS,EAAE,WAAW,CACpB,iHAAiH,EACjH,UAAU;oBACR,CAAC,CAAC,sBAAsB;oBACxB,CAAC,CAAC,SAAS;wBACT,CAAC,CAAC,4BAA4B;wBAC9B,CAAC,CAAC,sBAAsB,EAC5B,eAAe,CAAC,IAAI,CAAC,CACtB,YAED,eACE,SAAS,EAAE,WAAW,CACpB,4EAA4E,EAC5E,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,EACnC,UAAU;wBACR,CAAC,CAAC,qBAAqB;wBACvB,CAAC,CAAC,wBAAwB,EAC5B,cAAc,CAAC,IAAI,CAAC,CACrB,GACD,GACG,EAEP,yBAAO,KAAK,GAAQ,IACb,CACV,CAAA;AACH,CAAC,CAAA"}
1
+ {"version":3,"file":"RadioGroupOption.js","sourceRoot":"","sources":["../../src/RadioGroup/RadioGroupOption.tsx"],"names":[],"mappings":"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"}
@@ -10,6 +10,12 @@ export type SwitchProps = {
10
10
  */
11
11
  isChecked?: boolean;
12
12
  isDisabled?: boolean;
13
+ /**
14
+ * Shows the state at full contrast but refuses to flip it — a
15
+ * setting you may read here and change elsewhere. Unlike
16
+ * `isDisabled` it does not dim, and it announces `aria-readonly`.
17
+ */
18
+ isReadOnly?: boolean;
13
19
  /** The visible text, and the switch's accessible name. */
14
20
  label: ReactNode;
15
21
  onChange?: (isChecked: boolean) => void;
@@ -50,5 +56,5 @@ export type SwitchProps = {
50
56
  * gives the off track the defined edge a bare well would miss on a pale
51
57
  * theme.
52
58
  */
53
- export declare const Switch: ({ className, isChecked, isDisabled, label, onChange, size, }: SwitchProps) => ReactNode;
59
+ export declare const Switch: ({ className, isChecked, isDisabled, isReadOnly, label, onChange, size, }: SwitchProps) => ReactNode;
54
60
  //# sourceMappingURL=Switch.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Switch.d.ts","sourceRoot":"","sources":["../../src/Switch/Switch.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAMtC,MAAM,MAAM,WAAW,GAAG;IACxB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,0DAA0D;IAC1D,KAAK,EAAE,SAAS,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAA;IACvC,IAAI,CAAC,EAAE,WAAW,CAAA;CACnB,CAAA;AAkCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,eAAO,MAAM,MAAM,GAAI,8DAOpB,WAAW,KAAG,SA+DhB,CAAA"}
1
+ {"version":3,"file":"Switch.d.ts","sourceRoot":"","sources":["../../src/Switch/Switch.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAMtC,MAAM,MAAM,WAAW,GAAG;IACxB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,0DAA0D;IAC1D,KAAK,EAAE,SAAS,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAA;IACvC,IAAI,CAAC,EAAE,WAAW,CAAA;CACnB,CAAA;AAkCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,eAAO,MAAM,MAAM,GAAI,0EAQpB,WAAW,KAAG,SA6EhB,CAAA"}
@@ -63,31 +63,43 @@ const TEXT_SIZE_CLASS = {
63
63
  * gives the off track the defined edge a bare well would miss on a pale
64
64
  * theme.
65
65
  */
66
- export const Switch = ({ className, isChecked = false, isDisabled = false, label, onChange, size = "md", }) => {
66
+ export const Switch = ({ className, isChecked = false, isDisabled = false, isReadOnly = false, label, onChange, size = "md", }) => {
67
67
  const [isOn, setIsOn] = useState(isChecked);
68
68
  const labelId = useUniqueId();
69
- return (_jsxs("span", { className: toClassName("inline-flex items-center gap-2 text-content-secondary select-none", TEXT_SIZE_CLASS[size], isDisabled && "text-content-disabled", className), children: [_jsx("button", { "aria-checked": isOn, "aria-labelledby": labelId, className: toClassName(
69
+ return (_jsxs("span", { className: toClassName("inline-flex items-center gap-2 text-content-secondary select-none", TEXT_SIZE_CLASS[size],
70
+ // Disabled turns the whole control down with `opacity-60`, the
71
+ // same family treatment as `Checkbox` and `RadioGroup` — the
72
+ // track keeps its full-contrast state colour underneath.
73
+ isDisabled && "opacity-60", className), children: [_jsx("button", { "aria-checked": isOn, "aria-labelledby": labelId, "aria-readonly": isReadOnly || undefined, className: toClassName(
70
74
  // `p-1` is the breathing room; `ring-inset` draws the off
71
75
  // outline without changing the box size, so the centring
72
76
  // arithmetic above stays exact.
73
- "inline-flex shrink-0 cursor-pointer items-center rounded-full p-1 transition-colors duration-(--duration-fast) ease-standard",
74
- // One state class, chosen by ternary — two `bg-`/`ring-`
75
- // utilities of the same specificity do not resolve by
76
- // class-list order, so disabled has to win as the only one
77
- // written.
78
- isDisabled
79
- ? "cursor-not-allowed bg-surface-sunken ring-2 ring-border-subtle ring-inset"
77
+ "inline-flex shrink-0 items-center rounded-full p-1 transition-colors duration-(--duration-fast) ease-standard",
78
+ // The track carries the state at full contrast whether or not
79
+ // it is disabled; `opacity-60` on the wrapper does the dimming.
80
+ // Read-only swaps the accent for the neutral intent and softens
81
+ // the off outline — readable, plainly not an actionable accent,
82
+ // the same language as `Checkbox` and `RadioGroup`.
83
+ isReadOnly
84
+ ? isOn
85
+ ? "bg-intent-neutral-solid"
86
+ : "bg-surface-sunken ring-2 ring-border-default ring-inset"
80
87
  : isOn
81
88
  ? "bg-intent-accent-solid"
82
- : "bg-surface-sunken ring-2 ring-border-strong ring-inset", TRACK_SIZE_CLASS[size], FOCUS_RING_CLASS), disabled: isDisabled, onClick: () => {
89
+ : "bg-surface-sunken ring-2 ring-border-strong ring-inset", isDisabled
90
+ ? "cursor-not-allowed"
91
+ : isReadOnly
92
+ ? "cursor-default"
93
+ : "cursor-pointer", TRACK_SIZE_CLASS[size], FOCUS_RING_CLASS), disabled: isDisabled, onClick: () => {
94
+ // Read-only shows the state but will not flip it — the switch
95
+ // owns its value, so the guard is just an early return.
96
+ if (isReadOnly) {
97
+ return;
98
+ }
83
99
  const isNextOn = !isOn;
84
100
  setIsOn(isNextOn);
85
101
  onChange?.(isNextOn);
86
- }, role: "switch", type: "button", children: _jsx("span", { className: toClassName("rounded-full transition-transform duration-(--duration-fast) ease-standard",
87
- // One knob, same colour off and on; disabled mutes it.
88
- isDisabled
89
- ? "bg-content-disabled"
90
- : "bg-surface-raised", isOn
102
+ }, role: "switch", type: "button", children: _jsx("span", { className: toClassName("rounded-full bg-surface-raised transition-transform duration-(--duration-fast) ease-standard", isOn
91
103
  ? THUMB_ON_TRANSLATE_CLASS[size]
92
104
  : "translate-x-0", THUMB_SIZE_CLASS[size]) }) }), _jsx("span", { id: labelId, children: label })] }));
93
105
  };
@@ -1 +1 @@
1
- {"version":3,"file":"Switch.js","sourceRoot":"","sources":["../../src/Switch/Switch.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAGhD,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAEhC,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACrD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAkB/C,oEAAoE;AACpE,iEAAiE;AACjE,uEAAuE;AACvE,sEAAsE;AACtE,kDAAkD;AAClD,MAAM,gBAAgB,GAAgC;IACpD,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,UAAU;IACd,EAAE,EAAE,UAAU;CACf,CAAA;AAED,MAAM,gBAAgB,GAAgC;IACpD,EAAE,EAAE,QAAQ;IACZ,EAAE,EAAE,QAAQ;IACZ,EAAE,EAAE,QAAQ;CACb,CAAA;AAED,MAAM,wBAAwB,GAG1B;IACF,EAAE,EAAE,eAAe;IACnB,EAAE,EAAE,eAAe;IACnB,EAAE,EAAE,eAAe;CACpB,CAAA;AAED,MAAM,eAAe,GAAgC;IACnD,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;CACd,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,EACrB,SAAS,EACT,SAAS,GAAG,KAAK,EACjB,UAAU,GAAG,KAAK,EAClB,KAAK,EACL,QAAQ,EACR,IAAI,GAAG,IAAI,GACC,EAAa,EAAE;IAC3B,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,SAAS,CAAC,CAAA;IAE3C,MAAM,OAAO,GAAG,WAAW,EAAE,CAAA;IAE7B,OAAO,CACL,gBACE,SAAS,EAAE,WAAW,CACpB,mEAAmE,EACnE,eAAe,CAAC,IAAI,CAAC,EACrB,UAAU,IAAI,uBAAuB,EACrC,SAAS,CACV,aAED,iCACgB,IAAI,qBACD,OAAO,EACxB,SAAS,EAAE,WAAW;gBACpB,0DAA0D;gBAC1D,yDAAyD;gBACzD,gCAAgC;gBAChC,8HAA8H;gBAC9H,yDAAyD;gBACzD,sDAAsD;gBACtD,2DAA2D;gBAC3D,WAAW;gBACX,UAAU;oBACR,CAAC,CAAC,2EAA2E;oBAC7E,CAAC,CAAC,IAAI;wBACJ,CAAC,CAAC,wBAAwB;wBAC1B,CAAC,CAAC,wDAAwD,EAC9D,gBAAgB,CAAC,IAAI,CAAC,EACtB,gBAAgB,CACjB,EACD,QAAQ,EAAE,UAAU,EACpB,OAAO,EAAE,GAAG,EAAE;oBACZ,MAAM,QAAQ,GAAG,CAAC,IAAI,CAAA;oBAEtB,OAAO,CAAC,QAAQ,CAAC,CAAA;oBAEjB,QAAQ,EAAE,CAAC,QAAQ,CAAC,CAAA;gBACtB,CAAC,EACD,IAAI,EAAC,QAAQ,EACb,IAAI,EAAC,QAAQ,YAEb,eACE,SAAS,EAAE,WAAW,CACpB,4EAA4E;oBAC5E,uDAAuD;oBACvD,UAAU;wBACR,CAAC,CAAC,qBAAqB;wBACvB,CAAC,CAAC,mBAAmB,EACvB,IAAI;wBACF,CAAC,CAAC,wBAAwB,CAAC,IAAI,CAAC;wBAChC,CAAC,CAAC,eAAe,EACnB,gBAAgB,CAAC,IAAI,CAAC,CACvB,GACD,GACK,EAET,eAAM,EAAE,EAAE,OAAO,YAAG,KAAK,GAAQ,IAC5B,CACR,CAAA;AACH,CAAC,CAAA"}
1
+ {"version":3,"file":"Switch.js","sourceRoot":"","sources":["../../src/Switch/Switch.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "2.5.0",
3
+ "version": "2.6.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",
@@ -49,6 +49,19 @@ is a control every consumer has to hold it for.
49
49
 
50
50
  <Canvas of={CheckboxStories.AllStates} />
51
51
 
52
+ ## Three ways a box can be inactive
53
+
54
+ `isDisabled` and `isReadOnly` are different states, and the box shows all three:
55
+
56
+ - **Enabled** — the accent intent, a crisp `border-strong` edge.
57
+ - **Disabled** — the same box turned down with `opacity-60`. It dims rather than fading a
58
+ token because the scale has no step between `border-default` and `border-strong`, so a
59
+ muted outline was either invisible or indistinguishable from enabled.
60
+ - **Read-only** — full contrast, because the whole point is that the value stays legible,
61
+ but in the **neutral** intent with a softer edge: an informational value you may read
62
+ here and change elsewhere, not an actionable accent. It announces `aria-readonly`, and
63
+ the toggle is blocked on both pointer and `Space`.
64
+
52
65
  ## Checkbox or `Switch`
53
66
 
54
67
  Same state kind, different meaning. A checkbox is a value you **submit** — "delete
@@ -16,7 +16,12 @@ const meta = {
16
16
  // The component's own defaults, restated — Storybook does not seed
17
17
  // `args` from docgen, so an unstated default shows in the props
18
18
  // table with nothing selected in its control.
19
- args: { isChecked: false, isDisabled: false, size: "md" },
19
+ args: {
20
+ isChecked: false,
21
+ isDisabled: false,
22
+ isReadOnly: false,
23
+ size: "md",
24
+ },
20
25
  } satisfies Meta<typeof Checkbox>
21
26
 
22
27
  export default meta
@@ -85,6 +90,23 @@ export const AllStates: Story = {
85
90
  label="Disabled and checked"
86
91
  />
87
92
  </StoryCell>
93
+
94
+ <StoryCell label="read-only, unchecked">
95
+ <Checkbox
96
+ {...controlProps}
97
+ isReadOnly
98
+ label="Read-only"
99
+ />
100
+ </StoryCell>
101
+
102
+ <StoryCell label="read-only, checked">
103
+ <Checkbox
104
+ {...controlProps}
105
+ isChecked
106
+ isReadOnly
107
+ label="Read-only and checked"
108
+ />
109
+ </StoryCell>
88
110
  </StoryGrid>
89
111
  </StorySection>
90
112
  ),
@@ -78,3 +78,32 @@ test("isChecked decides the first render", async () => {
78
78
  }),
79
79
  ).toBeChecked()
80
80
  })
81
+
82
+ test("a read-only box is announced and cannot be toggled", async () => {
83
+ const { canvas, canvasElement } =
84
+ await mountStory(AllStates)
85
+
86
+ const readOnly = expectAgentDrivable(canvas, {
87
+ name: "Read-only",
88
+ role: "checkbox",
89
+ })
90
+
91
+ // Full contrast, not `disabled` — a read-only value stays readable,
92
+ // and it is focusable so a screen reader reaches it. The state is
93
+ // carried by `aria-readonly`, not by removing it from the tree.
94
+ await expect(readOnly).toHaveAttribute(
95
+ "aria-readonly",
96
+ "true",
97
+ )
98
+
99
+ await expect(readOnly).not.toBeDisabled()
100
+
101
+ await expect(readOnly).not.toBeChecked()
102
+
103
+ await userEvent.click(readOnly)
104
+
105
+ // The pointer block held — the box did not flip.
106
+ await expect(readOnly).not.toBeChecked()
107
+
108
+ await expectNoAxeViolations(canvasElement)
109
+ })
@@ -20,6 +20,15 @@ export type CheckboxProps = {
20
20
  */
21
21
  isChecked?: boolean
22
22
  isDisabled?: boolean
23
+ /**
24
+ * Shows the value at full contrast but refuses to change it — a
25
+ * setting you may read here and edit elsewhere. Distinct from
26
+ * `isDisabled`, which dims: a read-only box is not turned down,
27
+ * because the whole point is that you can still read it. Announced
28
+ * as `aria-readonly`, and the toggle is blocked on both pointer and
29
+ * `Space`.
30
+ */
31
+ isReadOnly?: boolean
23
32
  /**
24
33
  * The visible text beside the box, and the checkbox's accessible
25
34
  * name — the `<label>` wraps the control, so there is no `for` to
@@ -83,33 +92,59 @@ export const Checkbox = ({
83
92
  id,
84
93
  isChecked = false,
85
94
  isDisabled = false,
95
+ isReadOnly = false,
86
96
  label,
87
97
  onChange,
88
98
  size = "md",
89
99
  }: CheckboxProps): ReactNode => (
90
100
  <label
91
101
  className={toClassName(
92
- "inline-flex cursor-pointer items-center gap-2 text-content-secondary select-none",
102
+ "inline-flex items-center gap-2 text-content-secondary select-none",
93
103
  TEXT_SIZE_CLASS[size],
94
- isDisabled &&
95
- "cursor-not-allowed text-content-disabled",
104
+ // One cursor, chosen by state — two cursor utilities of the same
105
+ // specificity do not resolve by class-list order.
106
+ isDisabled
107
+ ? "cursor-not-allowed"
108
+ : isReadOnly
109
+ ? "cursor-default"
110
+ : "cursor-pointer",
111
+ // Disabled dims the whole control with opacity rather than fading
112
+ // the box to a quieter token. The token scale has no step between
113
+ // `border-default` and `border-strong`, so a muted outline was
114
+ // either invisible or indistinguishable from enabled; `opacity-60`
115
+ // keeps the full shape and colour and just turns them down, which
116
+ // is the "present but inactive" the box actually is. Read-only is
117
+ // *not* dimmed — its whole point is that the value stays readable.
118
+ // The same treatment is on `Switch` and `RadioGroup`.
119
+ isDisabled && "opacity-60",
96
120
  className,
97
121
  )}
98
122
  >
99
123
  <span className="relative grid shrink-0 place-items-center">
100
124
  <input
125
+ aria-readonly={isReadOnly || undefined}
101
126
  className={toClassName(
102
- "peer cursor-pointer appearance-none rounded-sm border border-border-strong bg-surface-sunken transition-colors duration-(--duration-fast) ease-standard",
103
- "checked:border-intent-accent-solid checked:bg-intent-accent-solid",
104
- // Disabled mutes the border and the cursor, and — unlike the
105
- // shared `DISABLED_CLASS` — does *not* touch the background.
106
- // The generic bundle repaints the box `surface-sunken`, which
107
- // erased both the checked accent fill (white tick on grey)
108
- // and, in a light scheme, the unchecked box entirely: a
109
- // `border-subtle` outline on `surface-sunken` on
110
- // `surface-base` is three near-identical creams. A disabled
111
- // box is still a box the user has to see.
112
- "disabled:cursor-not-allowed disabled:border-border-default",
127
+ // `border-2` is the family's shared weight — the `Switch`
128
+ // track's `ring-2` and the `RadioGroup` ring match it, so the
129
+ // three read as one set — and a 2px edge is also what keeps a
130
+ // disabled box visible where a 1px one washed out.
131
+ "peer appearance-none rounded-sm border-2 bg-surface-sunken transition-colors duration-(--duration-fast) ease-standard",
132
+ // Read-only wears the **neutral** intent, not the accent: full
133
+ // contrast (so the value reads) but plainly not an actionable
134
+ // accent control, with a softer `border-default` resting edge.
135
+ // Three distinct states result — enabled (accent, crisp),
136
+ // disabled (accent, dimmed by the wrapper), read-only (neutral).
137
+ // No `disabled:` colour override; the wrapper's `opacity-60`
138
+ // does the dimming, and doubling it on the box is what made a
139
+ // disabled checkbox vanish.
140
+ isReadOnly
141
+ ? "border-border-default checked:border-intent-neutral-solid checked:bg-intent-neutral-solid"
142
+ : "border-border-strong checked:border-intent-accent-solid checked:bg-intent-accent-solid",
143
+ isDisabled
144
+ ? "cursor-not-allowed"
145
+ : isReadOnly
146
+ ? "cursor-default"
147
+ : "cursor-pointer",
113
148
  BOX_SIZE_CLASS[size],
114
149
  FOCUS_RING_CLASS,
115
150
  )}
@@ -119,15 +154,39 @@ export const Checkbox = ({
119
154
  onChange={(
120
155
  event: ChangeEvent<HTMLInputElement>,
121
156
  ) => {
157
+ if (isReadOnly) {
158
+ return
159
+ }
160
+
122
161
  onChange?.(event.target.checked)
123
162
  }}
163
+ onClick={(clickEvent) => {
164
+ // Native checkboxes have no `readonly`, so the toggle is
165
+ // blocked here — `preventDefault` on the click stops the
166
+ // checked flip a pointer would make.
167
+ if (isReadOnly) {
168
+ clickEvent.preventDefault()
169
+ }
170
+ }}
171
+ onKeyDown={(keyEvent) => {
172
+ // …and Space, the keyboard toggle a click block does not
173
+ // cover.
174
+ if (isReadOnly && keyEvent.key === " ") {
175
+ keyEvent.preventDefault()
176
+ }
177
+ }}
124
178
  type="checkbox"
125
179
  />
126
180
 
127
181
  <svg
128
182
  aria-hidden="true"
129
183
  className={toClassName(
130
- "pointer-events-none invisible absolute text-intent-accent-on-solid peer-checked:visible",
184
+ "pointer-events-none invisible absolute peer-checked:visible",
185
+ // Matches whichever fill the box wears, so the tick keeps its
186
+ // guaranteed on-solid contrast in either intent.
187
+ isReadOnly
188
+ ? "text-intent-neutral-on-solid"
189
+ : "text-intent-accent-on-solid",
131
190
  CHECK_SIZE_CLASS[size],
132
191
  )}
133
192
  fill="none"
@@ -47,6 +47,15 @@ at.
47
47
 
48
48
  <Canvas of={RadioGroupStories.AllStates} />
49
49
 
50
+ ## A read-only group shows the choice without moving it
51
+
52
+ `isReadOnly` severs the one link that defines this component — selection follows focus — so
53
+ the arrow keys and clicks still travel the options for reading but no longer move the
54
+ choice. The group announces `aria-readonly`, and the chosen option shows at full contrast
55
+ in the **neutral** intent: a value you may read here and change elsewhere. That is distinct
56
+ from a single **disabled** option, which is dimmed with `opacity-60` and dropped from the
57
+ focus group but stays selectable via `selectedValue`.
58
+
50
59
  ## `selectedValue` is the initial value, not a controlled one
51
60
 
52
61
  It decides the first render and nothing after — Charcuterie owns the choice from then on,
@@ -31,7 +31,7 @@ const meta = {
31
31
  // The component's own defaults, restated — Storybook does not seed
32
32
  // `args` from docgen, so an unstated default shows in the props
33
33
  // table with nothing selected in its control.
34
- args: { size: "md" },
34
+ args: { isReadOnly: false, size: "md" },
35
35
  } satisfies Meta<typeof RadioGroup>
36
36
 
37
37
  export default meta
@@ -115,6 +115,16 @@ export const AllStates: Story = {
115
115
  label="On import"
116
116
  />
117
117
  </StoryCell>
118
+
119
+ <StoryCell label="read-only">
120
+ <RadioGroup
121
+ {...controlProps}
122
+ isReadOnly
123
+ items={NAMING_ITEMS}
124
+ label="Naming scheme, read-only"
125
+ selectedValue="anidb"
126
+ />
127
+ </StoryCell>
118
128
  </StoryGrid>
119
129
  </StorySection>
120
130
  ),
@@ -189,3 +189,44 @@ test("selectedValue decides the first render and nothing after", async () => {
189
189
  ).toHaveTextContent("Use a custom pattern")
190
190
  })
191
191
  })
192
+
193
+ test("a read-only group is announced and its choice cannot move", async () => {
194
+ const { canvas, canvasElement } =
195
+ await mountStory(AllStates)
196
+
197
+ const group = expectAgentDrivable(canvas, {
198
+ name: "Naming scheme, read-only",
199
+ role: "radiogroup",
200
+ })
201
+
202
+ await expect(group).toHaveAttribute(
203
+ "aria-readonly",
204
+ "true",
205
+ )
206
+
207
+ // The checked option shows at full contrast; that is the value the
208
+ // read-only group is displaying.
209
+ await waitFor(() => {
210
+ expect(
211
+ group.querySelector('[aria-checked="true"]'),
212
+ ).toHaveTextContent("Match AniDB titles")
213
+ })
214
+
215
+ const other = Array.from(
216
+ group.querySelectorAll<HTMLButtonElement>(
217
+ '[role="radio"]',
218
+ ),
219
+ ).find(
220
+ (one) => one.textContent === "Use a custom pattern",
221
+ )
222
+
223
+ await userEvent.click(other ?? group)
224
+
225
+ // Selection-follows-focus is severed when read-only — a click on
226
+ // another option does not move the choice.
227
+ await expect(
228
+ group.querySelector('[aria-checked="true"]'),
229
+ ).toHaveTextContent("Match AniDB titles")
230
+
231
+ await expectNoAxeViolations(canvasElement)
232
+ })
@@ -25,6 +25,13 @@ export type RadioItem = {
25
25
  export type RadioGroupProps = {
26
26
  className?: string
27
27
  items: readonly RadioItem[]
28
+ /**
29
+ * Shows which option is chosen at full contrast but refuses to move
30
+ * the choice — the arrow keys and clicks no longer select, and the
31
+ * group announces `aria-readonly`. Focus can still travel the
32
+ * options so a keyboard user can read them.
33
+ */
34
+ isReadOnly?: boolean
28
35
  /** The group's accessible name. Required. */
29
36
  label: string
30
37
  onChange?: (selectedValue: string | null) => void
@@ -64,6 +71,7 @@ const GAP_CLASS: Record<ControlSize, string> = {
64
71
  export const RadioGroup = ({
65
72
  className,
66
73
  items,
74
+ isReadOnly = false,
67
75
  label,
68
76
  onChange,
69
77
  selectedValue,
@@ -104,21 +112,26 @@ export const RadioGroup = ({
104
112
 
105
113
  const chooseOption = useCallback(
106
114
  (value: string) => {
115
+ // Focus may still land on the option so it can be read; only the
116
+ // choice is frozen when read-only.
107
117
  setActiveValue(value)
108
118
 
109
- select(value)
119
+ if (!isReadOnly) {
120
+ select(value)
121
+ }
110
122
  },
111
- [select, setActiveValue],
123
+ [isReadOnly, select, setActiveValue],
112
124
  )
113
125
 
114
126
  useEffect(() => {
115
127
  // Selection follows focus — the radio-group rule, and the one
116
128
  // line a `manual` mode this component deliberately lacks would
117
- // remove.
118
- if (activeValue !== null) {
129
+ // remove. Read-only severs exactly this link: focus still travels
130
+ // for reading, but moving it no longer moves the choice.
131
+ if (activeValue !== null && !isReadOnly) {
119
132
  select(activeValue)
120
133
  }
121
- }, [activeValue, select])
134
+ }, [activeValue, isReadOnly, select])
122
135
 
123
136
  useEffect(() => {
124
137
  const group = groupRef.current
@@ -137,6 +150,7 @@ export const RadioGroup = ({
137
150
  return (
138
151
  <div
139
152
  aria-label={label}
153
+ aria-readonly={isReadOnly || undefined}
140
154
  className={toClassName(
141
155
  "flex flex-col",
142
156
  GAP_CLASS[size],
@@ -167,8 +181,12 @@ export const RadioGroup = ({
167
181
 
168
182
  // Space checks the focused option. Selection already follows
169
183
  // focus, so this is almost always a no-op — one idempotent
170
- // command, not a second code path.
171
- if (keyEvent.key === " " && activeValue !== null) {
184
+ // command, not a second code path — and read-only skips it.
185
+ if (
186
+ keyEvent.key === " " &&
187
+ activeValue !== null &&
188
+ !isReadOnly
189
+ ) {
172
190
  keyEvent.preventDefault()
173
191
 
174
192
  select(activeValue)
@@ -180,6 +198,7 @@ export const RadioGroup = ({
180
198
  {items.map((item) => (
181
199
  <RadioGroupOption
182
200
  isChecked={checkedValue === item.value}
201
+ isReadOnly={isReadOnly}
183
202
  item={item}
184
203
  key={item.value}
185
204
  onChoose={chooseOption}
@@ -8,6 +8,7 @@ import type { RadioItem } from "./RadioGroup.tsx"
8
8
 
9
9
  export type RadioGroupOptionProps = {
10
10
  isChecked: boolean
11
+ isReadOnly: boolean
11
12
  item: RadioItem
12
13
  onChoose: (value: string) => void
13
14
  /** `RovingFocus.register` — membership of the arrow-key group. */
@@ -22,8 +23,11 @@ export type RadioGroupOptionProps = {
22
23
  ) => void
23
24
  }
24
25
 
26
+ // Deliberately the `Switch`'s `THUMB_SIZE_CLASS`, value for value — a
27
+ // radio's ring and a switch's knob are then the same circle at every
28
+ // density, which is what makes the family read as one set.
25
29
  const RING_SIZE_CLASS: Record<ControlSize, string> = {
26
- sm: "size-3.5",
30
+ sm: "size-3",
27
31
  md: "size-4",
28
32
  lg: "size-5",
29
33
  }
@@ -51,6 +55,7 @@ const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
51
55
  */
52
56
  export const RadioGroupOption = ({
53
57
  isChecked,
58
+ isReadOnly,
54
59
  item,
55
60
  onChoose,
56
61
  registerFocus,
@@ -79,11 +84,18 @@ export const RadioGroupOption = ({
79
84
  <button
80
85
  aria-checked={isChecked}
81
86
  className={toClassName(
82
- "inline-flex cursor-pointer items-center gap-2 rounded-sm text-start transition-colors duration-(--duration-fast) ease-standard",
87
+ "inline-flex items-center gap-2 rounded-sm text-content-secondary text-start transition-colors duration-(--duration-fast) ease-standard",
83
88
  TEXT_SIZE_CLASS[size],
89
+ // Disabled turns the whole option down with `opacity-60`, the
90
+ // same family treatment as `Checkbox` and `Switch`, rather than
91
+ // fading the ring to a token that disappeared on a pale theme.
92
+ // Read-only keeps full contrast — only the cursor and the hover
93
+ // affordance say it will not move.
84
94
  isDisabled
85
- ? "cursor-not-allowed text-content-disabled"
86
- : "text-content-secondary hover:text-content-primary",
95
+ ? "cursor-not-allowed opacity-60"
96
+ : isReadOnly
97
+ ? "cursor-default"
98
+ : "cursor-pointer hover:text-content-primary",
87
99
  FOCUS_RING_CLASS,
88
100
  )}
89
101
  disabled={isDisabled}
@@ -99,9 +111,19 @@ export const RadioGroupOption = ({
99
111
  >
100
112
  <span
101
113
  className={toClassName(
102
- "grid shrink-0 place-items-center rounded-full border transition-colors duration-(--duration-fast) ease-standard",
103
- isDisabled
104
- ? "border-border-subtle"
114
+ // `border-2` matches the `Switch` thumb's ring weight and the
115
+ // `Checkbox` box, and `RING_SIZE_CLASS` matches the thumb's
116
+ // diameter — so a radio dot and a switch knob are the same
117
+ // circle. A disabled ring holds `border-default` rather than
118
+ // fading to the `border-subtle` that vanished on a pale theme.
119
+ "grid shrink-0 place-items-center rounded-full border-2 transition-colors duration-(--duration-fast) ease-standard",
120
+ // Read-only wears the neutral intent and a softer resting edge
121
+ // — the same "readable but not an actionable accent" language
122
+ // as `Checkbox` and `Switch`.
123
+ isReadOnly
124
+ ? isChecked
125
+ ? "border-intent-neutral-solid"
126
+ : "border-border-default"
105
127
  : isChecked
106
128
  ? "border-intent-accent-solid"
107
129
  : "border-border-strong",
@@ -111,10 +133,10 @@ export const RadioGroupOption = ({
111
133
  <span
112
134
  className={toClassName(
113
135
  "rounded-full transition-transform duration-(--duration-fast) ease-standard",
114
- isChecked ? "scale-100" : "scale-0",
115
- isDisabled
116
- ? "bg-content-disabled"
136
+ isReadOnly
137
+ ? "bg-intent-neutral-solid"
117
138
  : "bg-intent-accent-solid",
139
+ isChecked ? "scale-100" : "scale-0",
118
140
  DOT_SIZE_CLASS[size],
119
141
  )}
120
142
  />
@@ -43,11 +43,13 @@ a filled track plus a contrasting thumb survives both, which is the same reason
43
43
 
44
44
  <Canvas of={SwitchStories.AllStates} />
45
45
 
46
- ## One that is off is still reachable
47
-
48
- `disabled` removes it from the tab order and the pointer, and `aria-disabled` is left to the
49
- native `disabled` the `<button>` already carries. A disabled-and-on switch is a setting the
50
- user cannot currently change — a policy forced from above, say — not a broken control, and
51
- it reads that way to a screen reader.
46
+ ## Disabled dims, read-only does not
47
+
48
+ `isDisabled` removes the switch from the tab order and the pointer and turns the whole
49
+ control down with `opacity-60` — a setting the user cannot currently change, a policy
50
+ forced from above, still fully visible. `isReadOnly` is the other half: full contrast so
51
+ the state stays legible, but the accent gives way to the **neutral** intent and the flip
52
+ is refused (`aria-readonly`, the `onClick` guarded). A read-only switch is a value you may
53
+ read here and change elsewhere; a disabled one is a control that is simply off-limits now.
52
54
 
53
55
  <Canvas of={SwitchStories.Interactive} />
@@ -16,7 +16,12 @@ const meta = {
16
16
  // The component's own defaults, restated — Storybook does not seed
17
17
  // `args` from docgen, so an unstated default shows in the props
18
18
  // table with nothing selected in its control.
19
- args: { isChecked: false, isDisabled: false, size: "md" },
19
+ args: {
20
+ isChecked: false,
21
+ isDisabled: false,
22
+ isReadOnly: false,
23
+ size: "md",
24
+ },
20
25
  } satisfies Meta<typeof Switch>
21
26
 
22
27
  export default meta
@@ -80,6 +85,23 @@ export const AllStates: Story = {
80
85
  label="Disabled and on"
81
86
  />
82
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>
83
105
  </StoryGrid>
84
106
  </StorySection>
85
107
  ),
@@ -84,3 +84,32 @@ test("isChecked decides the first render", async () => {
84
84
  }),
85
85
  ).toHaveAttribute("aria-checked", "true")
86
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
+ })
@@ -16,6 +16,12 @@ export type SwitchProps = {
16
16
  */
17
17
  isChecked?: boolean
18
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
19
25
  /** The visible text, and the switch's accessible name. */
20
26
  label: ReactNode
21
27
  onChange?: (isChecked: boolean) => void
@@ -93,6 +99,7 @@ export const Switch = ({
93
99
  className,
94
100
  isChecked = false,
95
101
  isDisabled = false,
102
+ isReadOnly = false,
96
103
  label,
97
104
  onChange,
98
105
  size = "md",
@@ -106,32 +113,50 @@ export const Switch = ({
106
113
  className={toClassName(
107
114
  "inline-flex items-center gap-2 text-content-secondary select-none",
108
115
  TEXT_SIZE_CLASS[size],
109
- isDisabled && "text-content-disabled",
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",
110
120
  className,
111
121
  )}
112
122
  >
113
123
  <button
114
124
  aria-checked={isOn}
115
125
  aria-labelledby={labelId}
126
+ aria-readonly={isReadOnly || undefined}
116
127
  className={toClassName(
117
128
  // `p-1` is the breathing room; `ring-inset` draws the off
118
129
  // outline without changing the box size, so the centring
119
130
  // 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"
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"
127
141
  : isOn
128
142
  ? "bg-intent-accent-solid"
129
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",
130
149
  TRACK_SIZE_CLASS[size],
131
150
  FOCUS_RING_CLASS,
132
151
  )}
133
152
  disabled={isDisabled}
134
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
+
135
160
  const isNextOn = !isOn
136
161
 
137
162
  setIsOn(isNextOn)
@@ -143,11 +168,7 @@ export const Switch = ({
143
168
  >
144
169
  <span
145
170
  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",
171
+ "rounded-full bg-surface-raised transition-transform duration-(--duration-fast) ease-standard",
151
172
  isOn
152
173
  ? THUMB_ON_TRANSLATE_CLASS[size]
153
174
  : "translate-x-0",