@recursica/mui-adapter 0.21.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/ARCHITECTURE.md +3 -0
  2. package/CHANGELOG.md +75 -0
  3. package/dist/index.d.ts +247 -41
  4. package/dist/mui-adapter.cjs +78 -78
  5. package/dist/mui-adapter.cjs.map +1 -1
  6. package/dist/mui-adapter.css +1 -1
  7. package/dist/mui-adapter.js +7624 -7311
  8. package/dist/mui-adapter.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/components/Accordion/ACCORDION_IMPLEMENTATION_NOTES.md +63 -0
  11. package/src/components/Accordion/Accordion.module.css +21 -2
  12. package/src/components/Accordion/Accordion.stories.tsx +38 -0
  13. package/src/components/Accordion/Accordion.tsx +27 -12
  14. package/src/components/AssistiveElement/ASSISTIVEELEMENT_IMPLEMENTATION_NOTES.md +59 -0
  15. package/src/components/AssistiveElement/AssistiveElement.module.css +8 -1
  16. package/src/components/AssistiveElement/AssistiveElement.tsx +17 -7
  17. package/src/components/Autocomplete/Autocomplete.tsx +18 -2
  18. package/src/components/Avatar/AVATAR_IMPLEMENTATION_NOTES.md +36 -0
  19. package/src/components/Avatar/Avatar.tsx +6 -9
  20. package/src/components/Badge/Badge.module.css +0 -12
  21. package/src/components/Badge/Badge.tsx +0 -4
  22. package/src/components/Button/Button.module.css +13 -0
  23. package/src/components/Card/Card.tsx +5 -5
  24. package/src/components/Checkbox/Checkbox.module.css +17 -21
  25. package/src/components/Checkbox/Checkbox.tsx +24 -18
  26. package/src/components/Chip/CHIP_IMPLEMENTATION_NOTES.md +102 -0
  27. package/src/components/Chip/Chip.module.css +46 -11
  28. package/src/components/Chip/Chip.tsx +41 -5
  29. package/src/components/Chip/USAGE.md +19 -0
  30. package/src/components/FileUpload/FILEUPLOAD_IMPLEMENTATION_NOTES.md +249 -0
  31. package/src/components/FileUpload/FileUpload.module.css +203 -38
  32. package/src/components/FileUpload/FileUpload.stories.tsx +348 -4
  33. package/src/components/FileUpload/FileUpload.tsx +350 -6
  34. package/src/components/FileUpload/USAGE.md +163 -5
  35. package/src/components/Flex/Flex.tsx +1 -1
  36. package/src/components/FormControlWrapper/FORMCONTROLWRAPPER_IMPLEMENTATION_NOTES.md +35 -0
  37. package/src/components/FormControlWrapper/FormControlWrapper.tsx +24 -8
  38. package/src/components/Grid/Grid.tsx +1 -1
  39. package/src/components/Group/Group.tsx +1 -1
  40. package/src/components/HoverCard/HoverCard.tsx +1 -1
  41. package/src/components/Label/Label.module.css +15 -2
  42. package/src/components/Label/Label.tsx +7 -6
  43. package/src/components/Menu/Menu.tsx +1 -1
  44. package/src/components/Modal/Modal.tsx +1 -1
  45. package/src/components/NumberInput/NumberInput.tsx +1 -1
  46. package/src/components/Pagination/Pagination.tsx +1 -1
  47. package/src/components/Panel/Panel.tsx +1 -1
  48. package/src/components/Radio/Radio.module.css +33 -38
  49. package/src/components/Radio/Radio.tsx +58 -44
  50. package/src/components/Radio/RadioGroup.tsx +5 -0
  51. package/src/components/SegmentedControl/SegmentedControl.module.css +0 -7
  52. package/src/components/SegmentedControl/SegmentedControl.tsx +4 -8
  53. package/src/components/Slider/Slider.tsx +1 -1
  54. package/src/components/Stack/Stack.tsx +1 -1
  55. package/src/components/Stepper/Stepper.tsx +3 -3
  56. package/src/components/Switch/SWITCH_IMPLEMENTATION_NOTES.md +123 -0
  57. package/src/components/Switch/Switch.module.css +152 -89
  58. package/src/components/Switch/Switch.tsx +140 -33
  59. package/src/components/Switch/SwitchGroup.tsx +37 -9
  60. package/src/components/Table/Table.tsx +1 -1
  61. package/src/components/Tabs/IMPLEMENTATION_NOTES.md +10 -0
  62. package/src/components/Tabs/Tabs.module.css +120 -43
  63. package/src/components/Tabs/Tabs.stories.tsx +5 -2
  64. package/src/components/Tabs/Tabs.tsx +1 -1
  65. package/src/components/Tabs/USAGE.md +27 -11
  66. package/src/components/TextField/TextField.tsx +1 -1
  67. package/src/components/TimePicker/TimePicker.tsx +1 -1
  68. package/src/components/Timeline/Timeline.tsx +1 -1
  69. package/src/components/Timeline/TimelineItem.tsx +2 -2
  70. package/src/components/Toast/Toast.tsx +4 -4
  71. package/src/components/Tooltip/Tooltip.tsx +1 -1
  72. package/src/components/Tree/Tree.tsx +1 -1
  73. package/src/index.ts +3 -1
  74. package/src/types/mantine.d.ts +0 -7
@@ -0,0 +1,123 @@
1
+ # Switch Implementation Notes
2
+
3
+ Decisions and gotchas specific to wrapping `@mui/material`'s `Switch` for the UI Kit's Switch.
4
+
5
+ ## Label/description/error
6
+
7
+ MUI's `Switch` has no built-in label/description/error rendering — built by hand here.
8
+ The label is a plain `<label>`; description/error render through the shared
9
+ `AssistiveElement` component (`assistiveWithIcon={false}`) instead of local styled divs,
10
+ so they inherit real design tokens rather than hardcoded Mantine defaults.
11
+
12
+ ## `Switch.Group` compound export
13
+
14
+ `SwitchGroup.tsx` existed but wasn't attached as `Switch.Group`. Fixed.
15
+
16
+ ## `SwitchGroup` didn't control its children — now fixed
17
+
18
+ MUI's `FormGroup` has no controlled-value concept — no equivalent to Mantine's real
19
+ `Switch.Group`, which threads a `string[]` of checked values and `checked`/`onChange` to each
20
+ child via context. This adapter's `SwitchGroup` used to destructure and discard
21
+ `value`/`defaultValue`/`onChange` entirely — purely cosmetic.
22
+
23
+ Fixed the same way this adapter's own `Checkbox`/`CheckboxGroup` already solved the identical
24
+ problem (a closer, in-repo precedent than `Accordion`, which the notes previously pointed to
25
+ without checking Checkbox first): a `SwitchGroupContext` provider on `SwitchGroup` carrying
26
+ `value`/`onChange`/`name`/`readOnly`, consumed by `Switch` to compute `isChecked` from array
27
+ membership and to translate each toggle into an add/remove against that array before calling
28
+ the group's `onChange`. Standalone (ungrouped) `Switch` usage is untouched — the group-derived
29
+ `checked`/`onChange` are only spread onto `<MuiSwitch>` when a `SwitchGroupContext` is present.
30
+ Verified live: initial `value`/`defaultValue` now renders correctly, clicking a sibling
31
+ switch toggles only its own entry in the array, and `readOnly` on the group disables every
32
+ child — all confirmed against the existing `SwitchGroup.stories.tsx` scenarios via Playwright.
33
+
34
+ ## Thumb icon (check/x glyph)
35
+
36
+ MUI's `icon`/`checkedIcon` props are the _same mechanism_ MUI uses internally to render its
37
+ own default circular thumb (`icon: <the circle>, checkedIcon: <the circle>`, then
38
+ spread-overwritten by whatever public `icon`/`checkedIcon` the caller passes) — confirmed
39
+ against MUI's own `Switch.js`/`SwitchBase.js` source. Passing a raw glyph into them replaces
40
+ the whole thumb circle, it doesn't add an icon inside it.
41
+
42
+ Fixed the same way `Checkbox` in this adapter already solved the identical problem: build our
43
+ own combined node — a `.thumb`-styled `<span>` wrapping `.thumbIconWrapper` with both
44
+ `CheckIcon`/`CloseIcon` (inline SVGs, same paths as Mantine's, copied rather than imported
45
+ since mui-adapter doesn't depend on `@mantine/core`) — and pass _that same node reference_ as
46
+ both `icon` and `checkedIcon` (mirroring MUI's own `icon={icon} checkedIcon={icon}` pattern).
47
+ Same reference means it never unmounts/remounts on toggle, which is what lets the two glyphs
48
+ crossfade via CSS opacity instead of a hard swap — matching Mantine's `thumbIcon` behavior.
49
+ Verified against a live Storybook via adapter-tester (geometry + SVG count now match Mantine
50
+ 1:1 across Default/Static Variations stories).
51
+
52
+ ## Track/thumb geometry, checked-state selectors, focus ring
53
+
54
+ Original CSS was a copy of mantine-adapter's file, but MUI's DOM is structurally different
55
+ (`track`/`switchBase` are siblings, not nested; MUI hardcodes its own root box, hit-area
56
+ padding, and thumb travel distance for its own 20px default thumb). Fixed by driving
57
+ `.switchRoot`/`.switchBase`/`.thumb` sizing entirely from our tokens, replacing
58
+ `input:checked + .track` sibling selectors (never matched in MUI's DOM) with `:has()`-scoped
59
+ selectors, adding an explicit focus ring from `--recursica_brand_states_focus_*` tokens, and
60
+ hardcoding `color="default"` so MUI's own primary-blue hover/focus circle never activates.
61
+ Verified via live measurement (adapter-tester): track/thumb box dimensions now match Mantine
62
+ exactly (64×28 track, 20×20 thumb).
63
+
64
+ Two gotchas worth flagging for future MUI adapter work:
65
+
66
+ 1. `classes.root` targets MUI's own switch span, but this component's outer wrapper `<div>`
67
+ (switch+label row) also uses `.root` — sizing one squeezes the other. Split into
68
+ `.switchRoot` (MUI switch only) vs `.root` (outer wrapper, cursor only).
69
+ 2. CSS Modules hashes literal MUI state classes (`Mui-checked`, `Mui-focusVisible`) unless
70
+ wrapped in `:global()` — same pattern already used in `Accordion`/`Button`.
71
+
72
+ ## Disabled-state opacity didn't reach the thumb
73
+
74
+ **Found 2026-08-14, reported by Matt:** the Read Only story's thumb stayed full-color/opacity
75
+ while Mantine's dimmed along with the track. Same root cause as the geometry work above:
76
+ Mantine nests `.thumb` inside `.track`, so an `opacity` on `.track` visually dims the thumb for
77
+ free; MUI has them as siblings, so the disabled-opacity selectors (which only targeted
78
+ `.track`) never touched the thumb at all. Fixed by adding `.thumb` to both selectors alongside
79
+ `.track`. Verified live: thumb now dims identically to Mantine in both the plain-disabled and
80
+ disabled-checked cases.
81
+
82
+ ## Track opacity — MUI's own always-on defaults, unrelated to our tokens
83
+
84
+ MUI's `SwitchTrack` styled component sets `opacity: 0.38` unconditionally (its own faded-pill
85
+ baseline), and separately `.Mui-checked + .track { opacity: 0.5 }` when checked — neither has
86
+ an equivalent in our token schema, and neither was being cancelled. Result: every enabled track
87
+ was dimmed by MUI's own opacity, most visibly on the checked state (deep red washed out to
88
+ pink). Fixed with an explicit `opacity: 1` in both the base `.track` rule and the
89
+ `.root:has(input:checked) .track` rule (needed in both — MUI's checked-specific selector is
90
+ more specific than its own base rule, so overriding only the base wasn't enough). Disabled-state
91
+ opacity is unaffected; those selectors are already more specific than either of these.
92
+
93
+ ## Focus ring was on the thumb's hit-area circle, not the track
94
+
95
+ `.switchBase:global(.Mui-focusVisible)` is MUI's actual focusable element, but it's sized to
96
+ the thumb's 20×20 hit area, not the 64×28 track — so the ring drew a small circle instead of
97
+ outlining the switch. Moved to `.switchBase:global(.Mui-focusVisible) ~ .track` (siblings under
98
+ `.switchRoot`, switchBase first) so the ring renders on the track itself. Mantine-adapter had
99
+ the mirror-image bug: no override at all, so Mantine's own default outline (blue) showed on the
100
+ hidden `<input>` — fixed there with `.root input:focus-visible { outline: none }` +
101
+ `.root input:focus-visible + .track { ... }`, drawing the same Recursica-token ring in the same
102
+ place.
103
+
104
+ ## `classNames` prop-name bug — external override was silently inert
105
+
106
+ The external override merge read `restRecord.classNames`, but MUI's actual prop is `classes`
107
+ (that's Mantine's naming, copied over by mistake) — fixed. Note only `root`/`track`/
108
+ `switchBase` are real MUI `classes` slots; `body`/`trackLabel`/`labelWrapper`/`label` are this
109
+ component's own hardcoded elements (styled directly in JSX, not via MUI's `classes` prop) and
110
+ remain unreachable through this override path — a separate, pre-existing gap, not something the
111
+ prop-name fix could address.
112
+
113
+ ## `SwitchGroup` side-by-side layout always rendered as if stacked
114
+
115
+ **Found 2026-08-14, reported by Matt:** the `SideBySideLayout` story showed the group label
116
+ above the switches instead of beside them. Root cause: `SwitchGroup` passed the switch-item's
117
+ own inline label max-width token (200px) as the group's `controlMaxWidth` — but the mandatory
118
+ side-by-side label column is a fixed 224px, wider than that cap, so the label always overflowed
119
+ onto its own line regardless of layout mode. Reproduced identically in Mantine (same code
120
+ pattern, not a MUI-only bug). Fixed by not capping the group's control width at all — each
121
+ switch's own label already wraps at 200px via `.labelWrapper`, so no group-level cap is needed.
122
+ Verified live in both adapters: side-by-side now shows the label column at left with the
123
+ switches beside it; stacked layout unaffected.
@@ -1,13 +1,46 @@
1
- /* HARDCODED VALUES
1
+ /* HARDCODED VALUES
2
2
  *
3
3
  * 1. track border: none; (we do not use border for the switch track)
4
4
  * 2. thumb border: none; (we do not use border for the thumb)
5
- * 3. outline: none; (focus rings handled internally by mantine)
5
+ * 3. .switchBase background-color: transparent; (cancels MUI's built-in primary-color
6
+ * hover/focus circle so only our tokens and our own explicit focus ring show — see
7
+ * SWITCH_IMPLEMENTATION_NOTES.md #5)
8
+ * 4. .track opacity: 1; (cancels MUI's own always-on track opacity — 0.38 unchecked, 0.5
9
+ * checked, both MUI defaults with no equivalent in our tokens; our disabled-state opacity
10
+ * rules below are the only intended dimming)
6
11
  */
7
12
 
8
13
  .root {
9
14
  /* Provide a stable cursor when not disabled */
10
15
  cursor: pointer;
16
+ }
17
+
18
+ .root:has(input:disabled) {
19
+ cursor: not-allowed;
20
+ }
21
+
22
+ /* Sizes MUI's actual switch root span (mapped via classes.root — MUI requires that exact key,
23
+ but the class name it points to is ours). Kept separate from .root above: when a label is
24
+ present, .root is applied to *this component's own outer wrapper div* (switch + label side
25
+ by side), not to the switch itself — giving both the same box-model rules would squeeze that
26
+ wrapper (and the label inside it) down to the track's own width. See
27
+ SWITCH_IMPLEMENTATION_NOTES.md #5. */
28
+ .switchRoot {
29
+ /* Cancel MUI's own hardcoded root box (width: 58px, height: 38px, padding: 12px,
30
+ overflow: hidden for the medium size) — without this the track/thumb tokens below get
31
+ clipped/shrunk to MUI's default geometry instead of driving the real size. */
32
+ width: var(--recursica_ui-kit_components_switch_properties_track-width);
33
+ height: calc(
34
+ var(--recursica_ui-kit_components_switch_properties_thumb-height) +
35
+ (
36
+ var(
37
+ --recursica_ui-kit_components_switch_properties_track-inner-padding
38
+ ) *
39
+ 2
40
+ )
41
+ );
42
+ padding: 0;
43
+ overflow: visible;
11
44
 
12
45
  --switch-track-padding: var(
13
46
  --recursica_ui-kit_components_switch_properties_track-inner-padding
@@ -23,17 +56,19 @@
23
56
  );
24
57
  }
25
58
 
26
- .root:has(input:disabled) {
27
- cursor: not-allowed;
28
- }
29
-
30
59
  /* Track/thumb dim per selection state; label dims via its own (single, state-agnostic) token */
31
- .root:has(input:checked:disabled) .track {
60
+ /* In mantine-adapter, .thumb nests inside .track, so dimming .track's opacity visually dims
61
+ the thumb too "for free". MUI's DOM has .track and .thumb as siblings (documented
62
+ structural difference, see SWITCH_IMPLEMENTATION_NOTES.md), so the same selector only dimmed
63
+ the track — the thumb stayed at full color/opacity. Each selector targets both explicitly. */
64
+ .root:has(input:checked:disabled) .track,
65
+ .root:has(input:checked:disabled) .thumb {
32
66
  opacity: var(
33
67
  --recursica_ui-kit_components_switch_variants_selection-states_selected_variants_states_disabled_properties_opacity
34
68
  );
35
69
  }
36
- .root:has(input:disabled):not(:has(input:checked)) .track {
70
+ .root:has(input:disabled):not(:has(input:checked)) .track,
71
+ .root:has(input:disabled):not(:has(input:checked)) .thumb {
37
72
  opacity: var(
38
73
  --recursica_ui-kit_components_switch_variants_selection-states_unselected_variants_states_disabled_properties_opacity
39
74
  );
@@ -51,7 +86,7 @@
51
86
  --recursica_ui-kit_components_switch_variants_selection-states_unselected_properties_colors_track-color
52
87
  );
53
88
  }
54
- .root input:checked + .track:hover {
89
+ .root:has(input:checked) .track:hover {
55
90
  background-color: var(
56
91
  --recursica_ui-kit_components_switch_variants_selection-states_selected_properties_colors_track-color
57
92
  );
@@ -69,28 +104,71 @@
69
104
  box-shadow: var(
70
105
  --recursica_ui-kit_components_switch_properties_track-elevation
71
106
  );
72
- padding: var(--switch-track-padding);
73
107
  border: none; /* HARDCODE */
74
-
75
- /* Mantine uses variables internally for these, but we explicitly override the CSS to ensure our dimensions */
76
- --switch-width: var(--switch-track-width);
77
- --switch-height: calc(
78
- var(--switch-thumb-height) + (var(--switch-track-padding) * 2)
79
- );
80
- --switch-thumb-size: var(--switch-thumb-width);
81
-
82
- display: flex;
83
- align-items: center;
108
+ opacity: 1; /* HARDCODE */
84
109
  box-sizing: border-box;
85
110
  transition: background-color 150ms ease;
86
111
  cursor: inherit;
87
112
  }
88
113
 
89
- .root input:checked + .track {
114
+ .root:has(input:checked) .track {
90
115
  background-color: var(
91
116
  --recursica_ui-kit_components_switch_variants_selection-states_selected_properties_colors_track-color
92
117
  );
93
118
  border-color: transparent;
119
+ /* HARDCODE #4: MUI's own checked-state selector (`.Mui-checked + .track { opacity: 0.5 }`)
120
+ is more specific than the base .track rule above, so it still wins there — needs its own
121
+ override here too. */
122
+ opacity: 1;
123
+ }
124
+
125
+ /* Positions/sizes the thumb's clickable hit area (MUI's internal SwitchBase). MUI's own
126
+ defaults here (padding: 9px; top/left: 0) are calibrated for its own hardcoded 20px thumb
127
+ and 58x38 root — meaningless against our tokens, so every dimension is replaced. The thumb
128
+ itself (the visible circle) renders as this element's only child, sized by .thumb below;
129
+ with padding: 0 and matching width/height, .switchBase and .thumb become the same box, so
130
+ the thumb sits flush inside the track's own inner padding. See
131
+ SWITCH_IMPLEMENTATION_NOTES.md #5. */
132
+ .switchBase {
133
+ top: var(--switch-track-padding);
134
+ left: var(--switch-track-padding);
135
+ width: var(--switch-thumb-width);
136
+ height: var(--switch-thumb-height);
137
+ padding: 0;
138
+ box-sizing: border-box;
139
+ }
140
+
141
+ .switchBase:hover,
142
+ .switchBase:global(.Mui-focusVisible) {
143
+ background-color: transparent; /* HARDCODE */
144
+ }
145
+
146
+ /* Focus ring reads on the whole visible track (the pill), not the thumb's small 20x20 hit-area
147
+ circle .switchBase actually is — .switchBase and .track are siblings under .switchRoot (in
148
+ that DOM order), so the ring is drawn on .track via the general sibling combinator when the
149
+ hit area is focus-visible. */
150
+ .switchBase:global(.Mui-focusVisible) ~ .track {
151
+ outline: var(--recursica_brand_states_focus_border-size) solid
152
+ var(--recursica_brand_states_focus_color);
153
+ outline-offset: var(--recursica_brand_states_focus_margin);
154
+ box-shadow: 0 0 var(--recursica_brand_states_focus_blur)
155
+ var(--recursica_brand_states_focus_color);
156
+ }
157
+
158
+ /* Moves the thumb from resting-left to resting-right. MUI hardcodes this same transform to
159
+ translateX(20px) for its own default thumb; ours is calculated from our tokens instead —
160
+ the exact distance between the thumb's checked and unchecked resting positions inside the
161
+ track's inner padding. .Mui-checked is MUI's own global state class (not a CSS Modules
162
+ local class), so it must be wrapped in :global() — same as Accordion's .Mui-expanded and
163
+ Button's .Mui-disabled — or the build hashes/scopes it into a name that never matches the
164
+ real DOM. */
165
+ .switchBase:global(.Mui-checked) {
166
+ transform: translateX(
167
+ calc(
168
+ var(--switch-track-width) - var(--switch-thumb-width) -
169
+ (var(--switch-track-padding) * 2)
170
+ )
171
+ );
94
172
  }
95
173
 
96
174
  .thumb {
@@ -110,12 +188,61 @@
110
188
  transition: background-color 150ms ease;
111
189
  }
112
190
 
113
- .root input:checked + .track .thumb {
191
+ .root:has(input:checked) .thumb {
114
192
  background-color: var(
115
193
  --recursica_ui-kit_components_switch_variants_selection-states_selected_properties_colors_thumb-color
116
194
  );
117
195
  }
118
196
 
197
+ .thumbIconWrapper {
198
+ display: flex;
199
+ align-items: center;
200
+ justify-content: center;
201
+ width: 100%;
202
+ height: 100%;
203
+ position: relative;
204
+ }
205
+
206
+ .checkIcon,
207
+ .closeIcon {
208
+ width: var(--recursica_ui-kit_components_switch_properties_thumb-icon-size);
209
+ height: var(--recursica_ui-kit_components_switch_properties_thumb-icon-size);
210
+ position: absolute;
211
+ transition:
212
+ opacity 150ms ease,
213
+ transform 150ms ease;
214
+ }
215
+
216
+ /* Unchecked state */
217
+ .checkIcon {
218
+ opacity: 0;
219
+ transform: scale(0.5);
220
+ color: var(
221
+ --recursica_ui-kit_components_switch_variants_selection-states_selected_properties_colors_icon-color
222
+ );
223
+ }
224
+
225
+ .closeIcon {
226
+ opacity: 1;
227
+ transform: scale(1);
228
+ color: var(
229
+ --recursica_ui-kit_components_switch_variants_selection-states_unselected_properties_colors_icon-color
230
+ );
231
+ }
232
+
233
+ /* Checked state — same :has() scoping already used for track/thumb color above; .thumb isn't
234
+ a sibling of input here (MUI nests both under switchBase), but :has() only needs input:checked
235
+ to be a descendant of .root, so this still scopes correctly. */
236
+ .root:has(input:checked) .thumb .checkIcon {
237
+ opacity: 1;
238
+ transform: scale(1);
239
+ }
240
+
241
+ .root:has(input:checked) .thumb .closeIcon {
242
+ opacity: 0;
243
+ transform: scale(0.5);
244
+ }
245
+
119
246
  .labelWrapper {
120
247
  display: flex;
121
248
  flex-direction: column;
@@ -166,73 +293,9 @@
166
293
  );
167
294
  }
168
295
 
169
- .description {
170
- margin-top: 5px; /* HARDCODE: mantine default */
171
- color: #868e96; /* HARDCODE: mantine default dimmed */
172
- font-size: 12px; /* HARDCODE: mantine default xs */
173
- line-height: 1.2;
174
- }
175
-
176
- .error {
177
- margin-top: 5px; /* HARDCODE: mantine default */
178
- color: #fa5252; /* HARDCODE: mantine default error */
179
- font-size: 12px; /* HARDCODE: mantine default xs */
180
- line-height: 1.2;
181
- }
182
-
183
- .description[data-disabled="true"],
184
- .error[data-disabled="true"] {
185
- opacity: var(
186
- --recursica_ui-kit_components_switch-item_variants_states_disabled_properties_opacity
187
- );
188
- }
189
-
190
- .thumbIconWrapper {
191
- display: flex;
192
- align-items: center;
193
- justify-content: center;
194
- width: 100%;
195
- height: 100%;
196
- position: relative;
197
- }
198
-
199
- .checkIcon,
200
- .closeIcon {
201
- width: var(--recursica_ui-kit_components_switch_properties_thumb-icon-size);
202
- height: var(--recursica_ui-kit_components_switch_properties_thumb-icon-size);
203
- position: absolute;
204
- transition:
205
- opacity 150ms ease,
206
- transform 150ms ease;
207
- }
208
-
209
- /* Unchecked state */
210
- .checkIcon {
211
- opacity: 0;
212
- transform: scale(0.5);
213
- color: var(
214
- --recursica_ui-kit_components_switch_variants_selection-states_selected_properties_colors_icon-color
215
- );
216
- }
217
-
218
- .closeIcon {
219
- opacity: 1;
220
- transform: scale(1);
221
- color: var(
222
- --recursica_ui-kit_components_switch_variants_selection-states_unselected_properties_colors_icon-color
223
- );
224
- }
225
-
226
- /* Checked state */
227
- .root input:checked + .track .thumb .checkIcon {
228
- opacity: 1;
229
- transform: scale(1);
230
- }
231
-
232
- .root input:checked + .track .thumb .closeIcon {
233
- opacity: 0;
234
- transform: scale(0.5);
235
- }
296
+ /* description/error text renders via AssistiveElement directly (see Switch.tsx) —
297
+ it owns its own tokenized styling. AssistiveElement has no disabled state and
298
+ must never dim. */
236
299
 
237
300
  /* --- GROUP STYLES --- */
238
301
  .groupRoot {
@@ -1,4 +1,4 @@
1
- import { forwardRef } from "react";
1
+ import { forwardRef, useContext } from "react";
2
2
  import {
3
3
  Switch as MuiSwitch,
4
4
  type SwitchProps as MuiSwitchProps,
@@ -17,9 +17,49 @@ import {
17
17
  FormControlLayout,
18
18
  type FormControlLayoutProps,
19
19
  } from "../FormControlLayout/FormControlLayout";
20
+ import { AssistiveElement } from "../AssistiveElement/AssistiveElement";
20
21
  import styles from "./Switch.module.css";
21
22
 
22
- // Removed unused SwitchGroup import
23
+ import { SwitchGroup, SwitchGroupContext } from "./SwitchGroup";
24
+
25
+ // Same glyphs as mantine-adapter's Switch (re-exported from @mantine/core's CheckIcon/CloseIcon,
26
+ // not importable here since mui-adapter doesn't depend on @mantine/core) — kept pixel-identical
27
+ // so adapter-tester's cross-adapter diff stays clean. Same precedent as Checkbox's inline icons.
28
+ function CheckIcon(props: React.ComponentProps<"svg">) {
29
+ return (
30
+ <svg
31
+ viewBox="0 0 10 7"
32
+ fill="none"
33
+ xmlns="http://www.w3.org/2000/svg"
34
+ {...props}
35
+ >
36
+ <path
37
+ d="M4 4.586L1.707 2.293A1 1 0 1 0 .293 3.707l3 3a.997.997 0 0 0 1.414 0l5-5A1 1 0 1 0 8.293.293L4 4.586z"
38
+ fill="currentColor"
39
+ fillRule="evenodd"
40
+ clipRule="evenodd"
41
+ />
42
+ </svg>
43
+ );
44
+ }
45
+
46
+ function CloseIcon(props: React.ComponentProps<"svg">) {
47
+ return (
48
+ <svg
49
+ viewBox="0 0 15 15"
50
+ fill="none"
51
+ xmlns="http://www.w3.org/2000/svg"
52
+ {...props}
53
+ >
54
+ <path
55
+ d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
56
+ fill="currentColor"
57
+ fillRule="evenodd"
58
+ clipRule="evenodd"
59
+ />
60
+ </svg>
61
+ );
62
+ }
23
63
 
24
64
  export type SwitchWrapperProps = Omit<
25
65
  MuiSwitchProps,
@@ -37,7 +77,11 @@ export type RecursicaSwitchPropsAlias =
37
77
 
38
78
  export type SwitchProps = RecursicaOverStyled<RecursicaSwitchPropsAlias>;
39
79
 
40
- type SwitchComponent = React.ComponentType<SwitchProps>;
80
+ type SwitchComponent = React.ForwardRefExoticComponent<
81
+ SwitchProps & React.RefAttributes<HTMLInputElement>
82
+ > & {
83
+ Group: typeof SwitchGroup;
84
+ };
41
85
 
42
86
  export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
43
87
  function Switch(props, ref) {
@@ -46,7 +90,6 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
46
90
  readOnly,
47
91
  readOnlyComponent,
48
92
  disabled,
49
- // Removed thumbIcon from destructured props since it's not supported
50
93
  formLayout,
51
94
  labelSize,
52
95
  controlMaxWidth,
@@ -66,31 +109,47 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
66
109
  delete restRecord["radius"];
67
110
  delete restRecord["variant"];
68
111
 
112
+ // NOTE: classes.root must stay keyed "root" (MUI's own slot name), but it targets a
113
+ // dedicated .switchRoot CSS class, not .root — .root is this component's own outer
114
+ // wrapper class (applied separately, see finalClass below) and sizing the actual MUI
115
+ // switch span the same way would squeeze that wrapper down to the track's own width.
116
+ // See SWITCH_IMPLEMENTATION_NOTES.md #5.
117
+ // NOTE: no "thumb" key here — the default MuiSwitch-thumb slot classes.thumb targets is
118
+ // never rendered. We supply our own icon/checkedIcon node below (see thumbNode), which
119
+ // replaces that slot outright (confirmed against MUI's SwitchBase/Switch source: icon and
120
+ // checkedIcon substitute the whole default thumb, they don't add to it), so the .thumb
121
+ // class is applied directly to our own node instead.
69
122
  const mergedClassNames: Partial<Record<string, string>> = {
70
- root: styles.root,
123
+ root: styles.switchRoot,
71
124
  body: styles.body,
72
125
  track: styles.track,
73
- thumb: styles.thumb,
126
+ switchBase: styles.switchBase,
74
127
  trackLabel: styles.trackLabel,
75
128
  labelWrapper: styles.labelWrapper,
76
129
  label: styles.label,
77
130
  };
78
131
 
79
- const classNamesProp = restRecord.classNames;
132
+ // NOTE: MUI's actual prop is "classes", not "classNames" (that's Mantine's naming) — this
133
+ // was reading the wrong key and silently doing nothing. Fixed. Also note "body"/
134
+ // "trackLabel"/"labelWrapper"/"label" below aren't real MUI `classes` slots at all (MUI
135
+ // only recognizes root/switchBase/track/thumb/input/checked/disabled/colorX/sizeX/edgeX) —
136
+ // those four are this component's own hardcoded elements, styled directly in the JSX below,
137
+ // not through MUI's classes mechanism. Only root/track/switchBase actually take effect via
138
+ // this override path; the rest are pre-existing dead weight, unrelated to the classNames bug.
139
+ const classesProp = restRecord.classes;
80
140
  if (
81
- classNamesProp &&
82
- typeof classNamesProp === "object" &&
83
- !Array.isArray(classNamesProp)
141
+ classesProp &&
142
+ typeof classesProp === "object" &&
143
+ !Array.isArray(classesProp)
84
144
  ) {
85
- const o = classNamesProp as Partial<Record<string, string>>;
86
- mergedClassNames.root = o.root ? `${styles.root} ${o.root}` : styles.root;
145
+ const o = classesProp as Partial<Record<string, string>>;
146
+ mergedClassNames.root = o.root
147
+ ? `${styles.switchRoot} ${o.root}`
148
+ : styles.switchRoot;
87
149
  mergedClassNames.body = o.body ? `${styles.body} ${o.body}` : styles.body;
88
150
  mergedClassNames.track = o.track
89
151
  ? `${styles.track} ${o.track}`
90
152
  : styles.track;
91
- mergedClassNames.thumb = o.thumb
92
- ? `${styles.thumb} ${o.thumb}`
93
- : styles.thumb;
94
153
  mergedClassNames.trackLabel = o.trackLabel
95
154
  ? `${styles.trackLabel} ${o.trackLabel}`
96
155
  : styles.trackLabel;
@@ -107,8 +166,29 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
107
166
  ? `${styles.root} ${classNameProp}`
108
167
  : styles.root;
109
168
 
169
+ // Threads a grouped Switch's checked state through SwitchGroupContext (mirrors Checkbox's
170
+ // identical CheckboxGroupContext pattern) — FormGroup itself has no controlled-value
171
+ // concept, so this is the only place group membership is actually computed. Standalone
172
+ // (ungrouped) Switch usage is untouched: isChecked/isGroupReadOnly are no-ops outside a
173
+ // <Switch.Group>. See SWITCH_IMPLEMENTATION_NOTES.md.
174
+ const groupContext = useContext(SwitchGroupContext);
175
+ const isGrouped = groupContext !== null;
176
+ const isGroupReadOnly = isGrouped ? !!groupContext.readOnly : false;
177
+ const isChecked = isGrouped
178
+ ? (groupContext.value || []).includes(restRecord.value as string)
179
+ : !!(restRecord.checked ?? restRecord.defaultChecked);
180
+
181
+ const handleGroupChange = (event: React.ChangeEvent<HTMLInputElement>) => {
182
+ if (!isGrouped) return;
183
+ const currentValue = groupContext.value || [];
184
+ const thisValue = restRecord.value as string;
185
+ const newValue = event.target.checked
186
+ ? [...currentValue, thisValue]
187
+ : currentValue.filter((v) => v !== thisValue);
188
+ groupContext.onChange?.(event, newValue);
189
+ };
190
+
110
191
  if (readOnly && !!readOnlyComponent) {
111
- const isChecked = !!(restRecord.checked ?? restRecord.defaultChecked);
112
192
  const ReadOnlyComp = readOnlyComponent;
113
193
  const roNode = (
114
194
  <ReadOnlyComp
@@ -134,17 +214,41 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
134
214
  return <>{roNode}</>;
135
215
  }
136
216
 
137
- // We omit Mui's sizing/coloring so we rely strictly on variables from Switch.module.css
217
+ // Same node passed as both icon and checkedIcon (mirrors MUI's own Switch.js, which does
218
+ // the same: `icon={icon} checkedIcon={icon}`) so it never unmounts/remounts on toggle —
219
+ // that's what lets the checkIcon/closeIcon crossfade via CSS opacity instead of a hard swap,
220
+ // matching mantine-adapter's thumbIcon (single node, both glyphs always mounted).
221
+ const thumbNode = (
222
+ <span className={styles.thumb}>
223
+ <span className={styles.thumbIconWrapper}>
224
+ <CheckIcon className={styles.checkIcon} />
225
+ <CloseIcon className={styles.closeIcon} />
226
+ </span>
227
+ </span>
228
+ );
229
+
230
+ // We omit Mui's sizing/coloring so we rely strictly on variables from Switch.module.css.
231
+ // color is hardcoded to "default" (never left to MUI's "primary" fallback) so MUI's own
232
+ // palette-driven blue checked/hover/focus styling never activates in the first place —
233
+ // see SWITCH_IMPLEMENTATION_NOTES.md #5.
234
+ const isSwitchDisabled = readOnly || disabled || isGroupReadOnly;
235
+
138
236
  const switchNode = (
139
237
  <MuiSwitch
140
238
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
141
239
  ref={ref as any}
142
240
  className={!label ? `${finalClass} ${styles.inner}` : styles.inner}
143
241
  classes={mergedClassNames}
144
- disabled={readOnly || disabled}
145
- data-disabled={readOnly || disabled || undefined}
146
- // thumbIcon={thumbIcon ?? FinalThumbIcon}
242
+ color="default"
243
+ disabled={isSwitchDisabled}
244
+ data-disabled={isSwitchDisabled || undefined}
245
+ icon={thumbNode}
246
+ checkedIcon={thumbNode}
147
247
  {...(sanitizedProps as unknown as MuiSwitchProps)}
248
+ {...(isGrouped
249
+ ? { checked: isChecked, onChange: handleGroupChange }
250
+ : {})}
251
+ disableRipple
148
252
  />
149
253
  );
150
254
 
@@ -160,21 +264,23 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
160
264
  >
161
265
  {label as React.ReactNode}
162
266
  </label>
163
- {description && (
164
- <div
165
- className={styles.description}
166
- data-disabled={readOnly || disabled ? true : undefined}
167
- >
168
- {description}
169
- </div>
170
- )}
171
- {error && (
172
- <div
173
- className={styles.error}
174
- data-disabled={readOnly || disabled ? true : undefined}
267
+ {/* description/error are mutually exclusive — error takes precedence */}
268
+ {error ? (
269
+ <AssistiveElement
270
+ assistiveVariant="error"
271
+ assistiveWithIcon={false}
175
272
  >
176
273
  {error}
177
- </div>
274
+ </AssistiveElement>
275
+ ) : (
276
+ description && (
277
+ <AssistiveElement
278
+ assistiveVariant="help"
279
+ assistiveWithIcon={false}
280
+ >
281
+ {description}
282
+ </AssistiveElement>
283
+ )
178
284
  )}
179
285
  </div>
180
286
  </div>
@@ -201,3 +307,4 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
201
307
  ) as SwitchComponent;
202
308
 
203
309
  Switch.displayName = "Switch";
310
+ Switch.Group = SwitchGroup;