@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.
- package/dist/Checkbox/Checkbox.d.ts +10 -1
- package/dist/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/Checkbox/Checkbox.js +59 -12
- package/dist/Checkbox/Checkbox.js.map +1 -1
- package/dist/RadioGroup/RadioGroup.d.ts +8 -1
- package/dist/RadioGroup/RadioGroup.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroup.js +17 -10
- package/dist/RadioGroup/RadioGroup.js.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.d.ts +2 -1
- package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.js +33 -10
- package/dist/RadioGroup/RadioGroupOption.js.map +1 -1
- package/dist/Switch/Switch.d.ts +7 -1
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +27 -15
- package/dist/Switch/Switch.js.map +1 -1
- package/package.json +1 -1
- package/src/Checkbox/Checkbox.mdx +13 -0
- package/src/Checkbox/Checkbox.stories.tsx +23 -1
- package/src/Checkbox/Checkbox.test.tsx +29 -0
- package/src/Checkbox/Checkbox.tsx +74 -15
- package/src/RadioGroup/RadioGroup.mdx +9 -0
- package/src/RadioGroup/RadioGroup.stories.tsx +11 -1
- package/src/RadioGroup/RadioGroup.test.tsx +41 -0
- package/src/RadioGroup/RadioGroup.tsx +26 -7
- package/src/RadioGroup/RadioGroupOption.tsx +32 -10
- package/src/Switch/Switch.mdx +8 -6
- package/src/Switch/Switch.stories.tsx +23 -1
- package/src/Switch/Switch.test.tsx +29 -0
- package/src/Switch/Switch.tsx +34 -13
|
@@ -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,
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
},
|
|
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;
|
|
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;AAsC/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,UAAU,GAAG,KAAK,EAClB,KAAK,EACL,QAAQ,EACR,IAAI,GAAG,IAAI,GACG,EAAa,EAAE,CAAC,CAC9B,iBACE,SAAS,EAAE,WAAW,CACpB,mEAAmE,EACnE,eAAe,CAAC,IAAI,CAAC;IACrB,iEAAiE;IACjE,kDAAkD;IAClD,UAAU;QACR,CAAC,CAAC,oBAAoB;QACtB,CAAC,CAAC,UAAU;YACV,CAAC,CAAC,gBAAgB;YAClB,CAAC,CAAC,gBAAgB;IACtB,kEAAkE;IAClE,kEAAkE;IAClE,+DAA+D;IAC/D,mEAAmE;IACnE,kEAAkE;IAClE,kEAAkE;IAClE,mEAAmE;IACnE,sDAAsD;IACtD,UAAU,IAAI,YAAY,EAC1B,SAAS,CACV,aAED,gBAAM,SAAS,EAAC,2CAA2C,aACzD,iCACiB,UAAU,IAAI,SAAS,EACtC,SAAS,EAAE,WAAW;oBACpB,0DAA0D;oBAC1D,8DAA8D;oBAC9D,8DAA8D;oBAC9D,mDAAmD;oBACnD,uHAAuH;oBACvH,+DAA+D;oBAC/D,8DAA8D;oBAC9D,+DAA+D;oBAC/D,0DAA0D;oBAC1D,iEAAiE;oBACjE,6DAA6D;oBAC7D,8DAA8D;oBAC9D,4BAA4B;oBAC5B,UAAU;wBACR,CAAC,CAAC,2FAA2F;wBAC7F,CAAC,CAAC,wFAAwF,EAC5F,UAAU;wBACR,CAAC,CAAC,oBAAoB;wBACtB,CAAC,CAAC,UAAU;4BACV,CAAC,CAAC,gBAAgB;4BAClB,CAAC,CAAC,gBAAgB,EACtB,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,IAAI,UAAU,EAAE,CAAC;4BACf,OAAM;wBACR,CAAC;wBAED,QAAQ,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;oBAClC,CAAC,EACD,OAAO,EAAE,CAAC,UAAU,EAAE,EAAE;wBACtB,yDAAyD;wBACzD,yDAAyD;wBACzD,qCAAqC;wBACrC,IAAI,UAAU,EAAE,CAAC;4BACf,UAAU,CAAC,cAAc,EAAE,CAAA;wBAC7B,CAAC;oBACH,CAAC,EACD,SAAS,EAAE,CAAC,QAAQ,EAAE,EAAE;wBACtB,yDAAyD;wBACzD,SAAS;wBACT,IAAI,UAAU,IAAI,QAAQ,CAAC,GAAG,KAAK,GAAG,EAAE,CAAC;4BACvC,QAAQ,CAAC,cAAc,EAAE,CAAA;wBAC3B,CAAC;oBACH,CAAC,EACD,IAAI,EAAC,UAAU,GACf,EAEF,6BACc,MAAM,EAClB,SAAS,EAAE,WAAW,CACpB,6DAA6D;oBAC7D,8DAA8D;oBAC9D,iDAAiD;oBACjD,UAAU;wBACR,CAAC,CAAC,8BAA8B;wBAChC,CAAC,CAAC,6BAA6B,EACjC,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"}
|
|
@@ -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,
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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 === " " &&
|
|
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":";AAAA,OAAO,EACL,cAAc,EACd,cAAc,EACd,eAAe,GAChB,MAAM,oBAAoB,CAAA;AAG3B,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAA;AAEtD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAwB,CAAA;
|
|
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;
|
|
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
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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(
|
|
45
|
-
|
|
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",
|
|
49
|
-
? "bg-
|
|
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;
|
|
1
|
+
{"version":3,"file":"RadioGroupOption.js","sourceRoot":"","sources":["../../src/RadioGroup/RadioGroupOption.tsx"],"names":[],"mappings":"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"}
|
package/dist/Switch/Switch.d.ts
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/Switch/Switch.js
CHANGED
|
@@ -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],
|
|
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
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
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",
|
|
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;
|
|
1
|
+
{"version":3,"file":"Switch.js","sourceRoot":"","sources":["../../src/Switch/Switch.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -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: {
|
|
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
|
|
102
|
+
"inline-flex items-center gap-2 text-content-secondary select-none",
|
|
93
103
|
TEXT_SIZE_CLASS[size],
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
86
|
-
:
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
/>
|
package/src/Switch/Switch.mdx
CHANGED
|
@@ -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
|
-
##
|
|
47
|
-
|
|
48
|
-
`
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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: {
|
|
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
|
+
})
|
package/src/Switch/Switch.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
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",
|