@recursica/mui-adapter 0.21.1 → 0.22.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 (65) hide show
  1. package/ARCHITECTURE.md +3 -0
  2. package/CHANGELOG.md +52 -0
  3. package/dist/index.d.ts +90 -35
  4. package/dist/mui-adapter.cjs +68 -68
  5. package/dist/mui-adapter.cjs.map +1 -1
  6. package/dist/mui-adapter.css +1 -1
  7. package/dist/mui-adapter.js +5660 -5582
  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/Card/Card.tsx +5 -5
  23. package/src/components/Checkbox/Checkbox.module.css +17 -21
  24. package/src/components/Checkbox/Checkbox.tsx +24 -18
  25. package/src/components/Chip/Chip.module.css +16 -7
  26. package/src/components/Chip/Chip.tsx +1 -1
  27. package/src/components/Flex/Flex.tsx +1 -1
  28. package/src/components/FormControlWrapper/FORMCONTROLWRAPPER_IMPLEMENTATION_NOTES.md +35 -0
  29. package/src/components/FormControlWrapper/FormControlWrapper.tsx +24 -8
  30. package/src/components/Grid/Grid.tsx +1 -1
  31. package/src/components/Group/Group.tsx +1 -1
  32. package/src/components/HoverCard/HoverCard.tsx +1 -1
  33. package/src/components/Label/Label.module.css +15 -2
  34. package/src/components/Label/Label.tsx +7 -6
  35. package/src/components/Menu/Menu.tsx +1 -1
  36. package/src/components/Modal/Modal.tsx +1 -1
  37. package/src/components/NumberInput/NumberInput.tsx +1 -1
  38. package/src/components/Pagination/Pagination.tsx +1 -1
  39. package/src/components/Panel/Panel.tsx +1 -1
  40. package/src/components/Radio/Radio.module.css +33 -38
  41. package/src/components/Radio/Radio.tsx +58 -44
  42. package/src/components/Radio/RadioGroup.tsx +5 -0
  43. package/src/components/SegmentedControl/SegmentedControl.module.css +0 -7
  44. package/src/components/SegmentedControl/SegmentedControl.tsx +4 -8
  45. package/src/components/Slider/Slider.tsx +1 -1
  46. package/src/components/Stack/Stack.tsx +1 -1
  47. package/src/components/Stepper/Stepper.tsx +3 -3
  48. package/src/components/Switch/SWITCH_IMPLEMENTATION_NOTES.md +123 -0
  49. package/src/components/Switch/Switch.module.css +152 -89
  50. package/src/components/Switch/Switch.tsx +140 -33
  51. package/src/components/Switch/SwitchGroup.tsx +37 -9
  52. package/src/components/Table/Table.tsx +1 -1
  53. package/src/components/Tabs/IMPLEMENTATION_NOTES.md +10 -0
  54. package/src/components/Tabs/Tabs.module.css +120 -43
  55. package/src/components/Tabs/Tabs.stories.tsx +5 -2
  56. package/src/components/Tabs/Tabs.tsx +1 -1
  57. package/src/components/Tabs/USAGE.md +27 -11
  58. package/src/components/TextField/TextField.tsx +1 -1
  59. package/src/components/TimePicker/TimePicker.tsx +1 -1
  60. package/src/components/Timeline/Timeline.tsx +1 -1
  61. package/src/components/Timeline/TimelineItem.tsx +2 -2
  62. package/src/components/Toast/Toast.tsx +4 -4
  63. package/src/components/Tooltip/Tooltip.tsx +1 -1
  64. package/src/components/Tree/Tree.tsx +1 -1
  65. package/src/types/mantine.d.ts +0 -7
@@ -86,8 +86,14 @@
86
86
  );
87
87
  }
88
88
 
89
- /* Base explicit reset masking underlying framework defaults */
89
+ /* Base explicit reset masking underlying framework defaults.
90
+ display: flex + centering: the checkmark icon renders as a nested child of this box
91
+ (see Checkbox.tsx — icon/checkedIcon are combined nodes, icon inside .input), so this
92
+ box has to center it itself rather than relying on a parent's centering. */
90
93
  .input {
94
+ display: flex;
95
+ align-items: center;
96
+ justify-content: center;
91
97
  cursor: pointer; /* HARDCODE: core interaction behavior */
92
98
  appearance: none; /* HARDCODE: reset native widget */
93
99
  box-sizing: border-box; /* HARDCODE: ensures size includes borders */
@@ -199,7 +205,14 @@
199
205
  );
200
206
  }
201
207
 
208
+ /* display: flex (not the browser default block) blockifies the native inline <label>
209
+ child per the CSS Display spec's flex-item blockification rule — matches Mantine's
210
+ own labelWrapper (inline-flex), whose flex context does the same to its label.
211
+ Without it, the label stays inline and its line box picks up extra half-leading
212
+ height, visually shifting it below vertical center against the checkbox. */
202
213
  .labelWrapper {
214
+ display: flex;
215
+ flex-direction: column;
203
216
  padding: 0; /* HARDCODE: structural layout reset */
204
217
  margin: 0; /* HARDCODE: structural layout reset */
205
218
  flex: 1;
@@ -250,26 +263,9 @@
250
263
  );
251
264
  }
252
265
 
253
- .description {
254
- margin-top: 5px; /* HARDCODE: mantine default */
255
- color: #868e96; /* HARDCODE: mantine default dimmed */
256
- font-size: 12px; /* HARDCODE: mantine default xs */
257
- line-height: 1.2;
258
- }
259
-
260
- .error {
261
- margin-top: 5px; /* HARDCODE: mantine default */
262
- color: #fa5252; /* HARDCODE: mantine default error */
263
- font-size: 12px; /* HARDCODE: mantine default xs */
264
- line-height: 1.2;
265
- }
266
-
267
- .description[data-disabled="true"],
268
- .error[data-disabled="true"] {
269
- opacity: var(
270
- --recursica_ui-kit_components_checkbox-item_variants_states_disabled_properties_opacity
271
- );
272
- }
266
+ /* description/error text renders via AssistiveElement directly (see Checkbox.tsx) —
267
+ it owns its own tokenized styling. AssistiveElement has no disabled state and
268
+ must never dim. */
273
269
 
274
270
  /* CheckboxGroup Layouts */
275
271
  .groupRoot {
@@ -17,6 +17,7 @@ import {
17
17
  FormControlLayout,
18
18
  type FormControlLayoutProps,
19
19
  } from "../FormControlLayout/FormControlLayout";
20
+ import { AssistiveElement } from "../AssistiveElement/AssistiveElement";
20
21
 
21
22
  import styles from "./Checkbox.module.css";
22
23
 
@@ -96,6 +97,12 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
96
97
  const isChecked = isGrouped
97
98
  ? (groupContext.value || []).includes(restRecord.value)
98
99
  : !!(restRecord.checked ?? restRecord.defaultChecked);
100
+ // Only force controlled mode when we actually own the source of truth (grouped) or the
101
+ // caller explicitly passed `checked` (controlled). A plain `defaultChecked`/neither-prop
102
+ // usage must stay uncontrolled — otherwise `checked` gets pinned to a value that never
103
+ // updates, and MUI's native input silently snaps back after every click. Mirrors Switch's
104
+ // identical `isGrouped ? { checked: ... } : {}` pattern (Switch never had this bug).
105
+ const isControlled = isGrouped || restRecord.checked !== undefined;
99
106
 
100
107
  if (readOnly && !!readOnlyComponent) {
101
108
  const ReadOnlyComp = readOnlyComponent;
@@ -166,8 +173,9 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
166
173
  classes={mergedClassNames}
167
174
  disabled={readOnly || disabled || isGroupReadOnly}
168
175
  {...(sanitizedProps as unknown as MuiCheckboxProps)}
169
- checked={isChecked as boolean}
176
+ {...(isControlled ? { checked: isChecked as boolean } : {})}
170
177
  onChange={handleChange}
178
+ disableRipple
171
179
  sx={label ? { padding: 0 } : undefined}
172
180
  icon={<div className={styles.input} />}
173
181
  checkedIcon={
@@ -204,25 +212,23 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
204
212
  >
205
213
  {label as React.ReactNode}
206
214
  </label>
207
- {description && (
208
- <div
209
- className={styles.description}
210
- data-disabled={
211
- readOnly || disabled || isGroupReadOnly ? true : undefined
212
- }
213
- >
214
- {description}
215
- </div>
216
- )}
217
- {error && (
218
- <div
219
- className={styles.error}
220
- data-disabled={
221
- readOnly || disabled || isGroupReadOnly ? true : undefined
222
- }
215
+ {/* description/error are mutually exclusive — error takes precedence */}
216
+ {error ? (
217
+ <AssistiveElement
218
+ assistiveVariant="error"
219
+ assistiveWithIcon={false}
223
220
  >
224
221
  {error}
225
- </div>
222
+ </AssistiveElement>
223
+ ) : (
224
+ description && (
225
+ <AssistiveElement
226
+ assistiveVariant="help"
227
+ assistiveWithIcon={false}
228
+ >
229
+ {description}
230
+ </AssistiveElement>
231
+ )
226
232
  )}
227
233
  </div>
228
234
  </div>
@@ -128,8 +128,6 @@
128
128
  background-color: var(--chip-bg);
129
129
  border-color: var(--chip-border);
130
130
  color: var(--chip-text);
131
- min-width: var(--recursica_ui-kit_components_chip_properties_min-width);
132
- max-width: var(--recursica_ui-kit_components_chip_properties_max-width);
133
131
  box-shadow: var(--recursica_ui-kit_components_chip_properties_elevation);
134
132
 
135
133
  transition: all 0.2s ease;
@@ -195,11 +193,12 @@
195
193
  cursor: not-allowed;
196
194
  }
197
195
 
198
- /* Override Mantine's built-in check icon colors and size if we keep it */
199
- .mantineIconWrapper.mantineIconWrapper {
196
+ /* Selected-state check icon colors and size */
197
+ .checkIconWrapper.checkIconWrapper {
200
198
  color: var(--chip-icon);
201
199
  width: var(--recursica_ui-kit_components_chip_properties_icon-size);
202
200
  height: var(--recursica_ui-kit_components_chip_properties_icon-size);
201
+ margin-left: 0;
203
202
  margin-right: var(
204
203
  --recursica_ui-kit_components_chip_properties_icon-text-gap
205
204
  );
@@ -219,13 +218,21 @@
219
218
  white-space: nowrap;
220
219
  }
221
220
 
222
- .leadingIcon {
221
+ /* Doubled selector for specificity parity with MUI's own compound
222
+ `.MuiChip-root .MuiChip-icon` rule, which otherwise wins outright on a
223
+ plain single-class selector and leaks its default margin/color. */
224
+ .leadingIcon.leadingIcon {
223
225
  display: inline-flex;
224
226
  align-items: center;
225
227
  justify-content: center;
226
228
  color: var(--chip-icon);
227
229
  width: var(--recursica_ui-kit_components_chip_properties_icon-size);
228
230
  height: var(--recursica_ui-kit_components_chip_properties_icon-size);
231
+ /* Reset MUI's default icon margin, keep the icon-text gap */
232
+ margin-left: 0;
233
+ margin-right: var(
234
+ --recursica_ui-kit_components_chip_properties_icon-text-gap
235
+ );
229
236
  }
230
237
 
231
238
  .leadingIcon svg {
@@ -233,7 +240,9 @@
233
240
  height: 100%;
234
241
  }
235
242
 
236
- .removeIconWrapper {
243
+ /* Doubled selector for specificity parity with MUI's own compound
244
+ `.MuiChip-root .MuiChip-deleteIcon` rule (same reasoning as .leadingIcon). */
245
+ .removeIconWrapper.removeIconWrapper {
237
246
  display: inline-flex;
238
247
  align-items: center;
239
248
  justify-content: center;
@@ -247,7 +256,7 @@
247
256
  margin: 0;
248
257
  }
249
258
 
250
- .removeIconWrapper:focus-visible {
259
+ .removeIconWrapper.removeIconWrapper:focus-visible {
251
260
  box-shadow: 0 0 0 2px var(--chip-border); /* HARDCODE: Focus indication */
252
261
  }
253
262
 
@@ -109,7 +109,7 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
109
109
  {...sanitizedProps}
110
110
  icon={
111
111
  checked ? (
112
- <span className={styles.mantineIconWrapper} aria-hidden>
112
+ <span className={styles.checkIconWrapper} aria-hidden>
113
113
  <CheckIcon />
114
114
  </span>
115
115
  ) : icon ? (
@@ -54,8 +54,8 @@ export const Flex = forwardRef<HTMLDivElement, FlexProps>(function Flex(
54
54
  return (
55
55
  <MUIBox
56
56
  ref={ref}
57
- display="flex"
58
57
  {...safeProps}
58
+ display="flex"
59
59
  gap={resolvedGap}
60
60
  rowGap={resolvedRowGap}
61
61
  columnGap={resolvedColumnGap}
@@ -0,0 +1,35 @@
1
+ # FormControlWrapper Implementation Notes
2
+
3
+ ## Architectural Philosophy
4
+
5
+ Unlike the Mantine adapter (which tears out `Input.Wrapper` entirely), this adapter still
6
+ renders MUI's native `FormControl` as the root — MUI's own `disabled`/`error`/`focused`
7
+ cascading via `FormControlContext` to descendants is useful and kept. What's rebuilt on top is
8
+ label tracking, ARIA generation, and the help/error text block, mirroring the Mantine adapter's
9
+ own approach for consistency across frameworks.
10
+
11
+ ### 1. Self-generated `id` fallback
12
+
13
+ MUI's `FormControl` doesn't generate or require an `id` itself — every ARIA relationship below
14
+ depends on having one. `React.useId()` provides a fallback (`recursica-fc-${generatedId}`)
15
+ whenever the caller doesn't pass their own `id`, matching the Mantine adapter, so the aria
16
+ wiring below fires by default instead of only when a caller happens to supply an `id`.
17
+
18
+ ### 2. The `cloneElement` ARIA map
19
+
20
+ Same reasoning as the Mantine adapter: `aria-labelledby`/`aria-describedby`/`aria-errormessage`
21
+ are generated from the resolved `id` and injected onto the child field via `React.cloneElement`,
22
+ since there's no shared context gluing the field to this wrapper's label/assistive text.
23
+
24
+ - `aria-describedby` and `aria-errormessage` use **separate ids** (`${id}-assistive` /
25
+ `${id}-error`) rather than one shared id — help text and error text are never both visible
26
+ at once (error takes priority, same as Mantine), but keeping the ids distinct means each
27
+ attribute only ever points at content that's actually relevant to it.
28
+ - Neither is set if the child already carries its own explicit `aria-describedby`/
29
+ `aria-errormessage` — `cloneElement` never overwrites a caller's own value.
30
+
31
+ ### 3. Strict `AssistiveElement` coupling
32
+
33
+ Same as Mantine: no generic MUI `FormHelperText` used directly here. `FormControlWrapper`
34
+ renders `<AssistiveElement>` itself, resolving to `"error"` or `"help"` depending on whether
35
+ `error` is set, and passing whichever id (`errorId`/`assistiveId`) matches.
@@ -1,4 +1,4 @@
1
- import React from "react";
1
+ import React, { useId } from "react";
2
2
  import { FormControl, type FormControlProps } from "@mui/material";
3
3
  import { filterStylingProps } from "../../utils/filterStylingProps";
4
4
  import { Label } from "../Label/Label";
@@ -37,7 +37,7 @@ export const FormControlWrapper = React.forwardRef<
37
37
  required,
38
38
  disabled,
39
39
  focused,
40
- id,
40
+ id: userProvidedId,
41
41
 
42
42
  formLayout = "stacked",
43
43
  labelSize = "default",
@@ -57,19 +57,35 @@ export const FormControlWrapper = React.forwardRef<
57
57
 
58
58
  const sanitizedProps = filterStylingProps(rest, overStyled);
59
59
 
60
+ // Generate a reliable ID when the caller doesn't provide one — matches the Mantine adapter,
61
+ // and is what makes the aria wiring below actually fire by default instead of only when a
62
+ // caller happens to pass their own `id`.
63
+ const generatedId = useId();
64
+ const id = userProvidedId || `recursica-fc-${generatedId}`;
65
+
60
66
  // Map descriptions fallback exactly like Mantine
61
67
  const finalAssistiveText = assistiveText || description || helperText;
62
68
 
63
- // ARIA Bindings
64
- const labelId = id ? `${id}-label` : undefined;
65
- const descriptionId = id ? `${id}-description` : undefined;
69
+ // ARIA Bindings — separate ids for help text and error text (matching Mantine) so each can
70
+ // be referenced independently via `aria-describedby`/`aria-errormessage` rather than sharing
71
+ // one id for both.
72
+ const labelId = `${id}-label`;
73
+ const assistiveId = finalAssistiveText ? `${id}-assistive` : undefined;
74
+ const errorId = error ? `${id}-error` : undefined;
66
75
 
67
- // Wrap children safely cloning aria hooks down natively
76
+ // Wrap children safely cloning aria hooks down natively. Never clobber a child's own
77
+ // explicit aria-describedby/aria-errormessage, matching Mantine's guard.
68
78
  const mappedChildren = React.Children.map(children, (child) => {
69
79
  if (React.isValidElement(child)) {
80
+ const childProps = child.props as Record<string, unknown>;
70
81
  return React.cloneElement(child, {
71
82
  "aria-labelledby": labelId,
72
- "aria-describedby": finalAssistiveText ? descriptionId : undefined,
83
+ ...(assistiveId && !childProps["aria-describedby"]
84
+ ? { "aria-describedby": assistiveId }
85
+ : {}),
86
+ ...(errorId && !childProps["aria-errormessage"]
87
+ ? { "aria-errormessage": errorId }
88
+ : {}),
73
89
  } as React.HTMLAttributes<HTMLElement>);
74
90
  }
75
91
  return child;
@@ -117,7 +133,7 @@ export const FormControlWrapper = React.forwardRef<
117
133
  {/* Append native assistive block dynamically below the input */}
118
134
  {(finalAssistiveText || error) && (
119
135
  <AssistiveElement
120
- id={descriptionId}
136
+ id={error ? errorId : assistiveId}
121
137
  assistiveVariant={error ? "error" : "help"}
122
138
  assistiveWithIcon={assistiveWithIcon}
123
139
  >
@@ -152,11 +152,11 @@ const GridBase = forwardRef<HTMLDivElement, GridProps>(function Grid(
152
152
  <GridContext.Provider value={{ grow }}>
153
153
  <MuiGrid
154
154
  ref={ref}
155
+ {...(safeProps as unknown as MuiGridProps)}
155
156
  container
156
157
  columns={columns}
157
158
  spacing={resolvedGap}
158
159
  className={styles.root}
159
- {...(safeProps as unknown as MuiGridProps)}
160
160
  style={{
161
161
  justifyContent: justify,
162
162
  alignItems: align,
@@ -46,6 +46,7 @@ export const Group = forwardRef<HTMLDivElement, GroupProps>(function Group(
46
46
  return (
47
47
  <MUIStack
48
48
  ref={ref}
49
+ {...safeProps}
49
50
  direction="row"
50
51
  flexWrap={wrap || "wrap"}
51
52
  justifyContent={justify}
@@ -55,7 +56,6 @@ export const Group = forwardRef<HTMLDivElement, GroupProps>(function Group(
55
56
  ...(resolvedRowGap ? { rowGap: resolvedRowGap } : {}),
56
57
  ...(resolvedColumnGap ? { columnGap: resolvedColumnGap } : {}),
57
58
  }}
58
- {...safeProps}
59
59
  >
60
60
  {children}
61
61
  </MUIStack>
@@ -85,6 +85,7 @@ const HoverCardBase = function HoverCard({
85
85
 
86
86
  return (
87
87
  <MuiTooltip
88
+ {...(sanitizedProps as unknown as Record<string, unknown>)}
88
89
  title={dropdownNode}
89
90
  placement={position as unknown as MuiTooltipProps["placement"]}
90
91
  arrow={withBeak}
@@ -104,7 +105,6 @@ const HoverCardBase = function HoverCard({
104
105
  },
105
106
  }}
106
107
  classes={mergedClassNames as unknown as MuiTooltipProps["classes"]}
107
- {...(sanitizedProps as unknown as Record<string, unknown>)}
108
108
  >
109
109
  {/* Tooltip requires a single valid React element child that accepts a ref */}
110
110
  {isValidElement(targetNode) ? targetNode : <span>{targetNode}</span>}
@@ -48,6 +48,7 @@
48
48
 
49
49
  .innerLayout {
50
50
  display: flex;
51
+ flex-wrap: wrap;
51
52
  align-items: center;
52
53
  gap: var(
53
54
  --recursica_ui-kit_components_label_properties_label-optional-text-gap
@@ -74,8 +75,8 @@
74
75
  display: none !important;
75
76
  }
76
77
 
77
- /* MUI propagates .Mui-error to the label automatically on FormControl error.
78
- We explicitly override this bleed to prevent the main label text from turning red,
78
+ /* MUI propagates .Mui-error to the label automatically on FormControl error.
79
+ We explicitly override this bleed to prevent the main label text from turning red,
79
80
  while allowing the asterisk to take the error color! */
80
81
  .root:global(.Mui-error) {
81
82
  color: var(--recursica_ui-kit_components_label_properties_colors_text-color);
@@ -86,6 +87,15 @@
86
87
  );
87
88
  }
88
89
 
90
+ /* Same bleed, different trigger: MUI's FormControl propagates .Mui-focused to this label
91
+ whenever any descendant form control (e.g. a grouped Switch/Checkbox) receives focus,
92
+ turning the whole label MUI's primary blue via InputLabel's own default styling. No
93
+ Recursica design calls for a focus-reactive label color, so it's reset here — same fix as
94
+ the .Mui-error case above, just for the focused trigger instead of the error one. */
95
+ .root:global(.Mui-focused) {
96
+ color: var(--recursica_ui-kit_components_label_properties_colors_text-color);
97
+ }
98
+
89
99
  .endNodes {
90
100
  display: flex;
91
101
  align-items: center;
@@ -95,6 +105,9 @@
95
105
  }
96
106
 
97
107
  .optionalText {
108
+ /* Forces the optional text onto its own row below the label text/end nodes,
109
+ mirroring mantine-adapter's flex-basis: 100% wrap behavior. */
110
+ flex-basis: 100%;
98
111
  color: inherit;
99
112
  font-family: var(
100
113
  --recursica_ui-kit_components_label_properties_optional-text_font-family
@@ -46,9 +46,9 @@ export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
46
46
  return (
47
47
  <InputLabel
48
48
  ref={ref}
49
+ {...(sanitizedProps as InputLabelProps)}
49
50
  shrink={true}
50
51
  className={className ? `${styles.root} ${className}` : styles.root}
51
- {...(sanitizedProps as InputLabelProps)}
52
52
  >
53
53
  <div className={styles.innerLayout}>
54
54
  <span className={styles.textWrapper}>
@@ -60,11 +60,6 @@ export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
60
60
  {labelActionArea && (
61
61
  <span className={styles.actionArea}>{labelActionArea}</span>
62
62
  )}
63
- {!required && labelOptionalText && !labelActionArea && (
64
- <span className={styles.optionalText}>
65
- {labelOptionalText === true ? "Optional" : labelOptionalText}
66
- </span>
67
- )}
68
63
  {labelWithEditIcon && (
69
64
  <button
70
65
  type="button"
@@ -76,6 +71,12 @@ export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
76
71
  </button>
77
72
  )}
78
73
  </div>
74
+
75
+ {!required && labelOptionalText && !labelActionArea && (
76
+ <span className={styles.optionalText}>
77
+ {labelOptionalText === true ? "(optional)" : labelOptionalText}
78
+ </span>
79
+ )}
79
80
  </div>
80
81
  </InputLabel>
81
82
  );
@@ -26,12 +26,12 @@ export const Menu = forwardRef<HTMLDivElement, MenuProps>(function Menu(
26
26
  return (
27
27
  <MuiMenu
28
28
  ref={ref}
29
+ {...(sanitizedProps as MuiMenuProps)}
29
30
  className={`${styles.dropdown} ${className || ""}`}
30
31
  classes={{
31
32
  paper: styles.dropdown,
32
33
  list: styles.dropdown,
33
34
  }}
34
- {...(sanitizedProps as MuiMenuProps)}
35
35
  />
36
36
  );
37
37
  });
@@ -77,11 +77,11 @@ const ModalRoot = forwardRef<HTMLDivElement, ModalProps>(function ModalRoot(
77
77
  return (
78
78
  <MuiDialog
79
79
  ref={ref}
80
+ {...(sanitizedProps as MuiDialogProps)}
80
81
  className={`${styles.root} ${className || ""}`}
81
82
  classes={{
82
83
  paper: styles.inner,
83
84
  }}
84
- {...(sanitizedProps as MuiDialogProps)}
85
85
  open={
86
86
  opened ||
87
87
  ((sanitizedProps as Record<string, unknown>).open as boolean) ||
@@ -137,6 +137,7 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
137
137
  /* Naked Input execution safely decoupled from Mui's macro Input.Wrapper DOM hooks */
138
138
  <MuiNumberInput
139
139
  ref={ref}
140
+ {...(sanitizedProps as unknown as MuiNumberInputProps)}
140
141
  classes={mergedClassNames}
141
142
  disabled={disabled}
142
143
  value={value}
@@ -154,7 +155,6 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
154
155
  step,
155
156
  ...(restRecord.inputProps as Record<string, unknown>),
156
157
  }}
157
- {...(sanitizedProps as unknown as MuiNumberInputProps)}
158
158
  />
159
159
  }
160
160
  />
@@ -63,13 +63,13 @@ const _Pagination = forwardRef<HTMLDivElement, PaginationProps>(
63
63
  return (
64
64
  <div ref={ref} className={stylingParams.className}>
65
65
  <MuiPagination
66
+ {...(sanitizedProps as MuiPaginationProps)}
66
67
  count={total}
67
68
  showFirstButton={withEdges}
68
69
  showLastButton={withEdges}
69
70
  hidePrevButton={withControls === false ? true : undefined}
70
71
  hideNextButton={withControls === false ? true : undefined}
71
72
  classes={stylingParams.classNames}
72
- {...(sanitizedProps as MuiPaginationProps)}
73
73
  />
74
74
  </div>
75
75
  );
@@ -124,7 +124,7 @@ export const PanelFooter = forwardRef<HTMLDivElement, PanelFooterProps>(
124
124
  ? `${styles.footer} ${classNameProp}`
125
125
  : styles.footer;
126
126
 
127
- return <div ref={ref} className={finalClassName} {...sanitizedProps} />;
127
+ return <div ref={ref} {...sanitizedProps} className={finalClassName} />;
128
128
  },
129
129
  );
130
130
  PanelFooter.displayName = "PanelFooter";
@@ -70,7 +70,7 @@
70
70
  );
71
71
  }
72
72
 
73
- .root .inner {
73
+ .inner {
74
74
  display: flex;
75
75
  align-items: center;
76
76
  justify-content: center;
@@ -82,7 +82,7 @@
82
82
  --recursica_ui-kit_components_radio-button-item_properties_text_font-size
83
83
  );
84
84
  /* Mathematically syncs the physical input radio bounding block visually with the center of the text's first line.
85
- NOTE: Since the Figma line-height token resolves to an 'em' length (e.g. 1.05em), we CANNOT multiply it by font-size in CSS.
85
+ NOTE: Since the Figma line-height token resolves to an 'em' length (e.g. 1.05em), we CANNOT multiply it by font-size in CSS.
86
86
  Instead, it naturally evaluates to the correct height because we applied the font-size above. */
87
87
  margin-top: calc(
88
88
  (
@@ -95,10 +95,16 @@
95
95
  );
96
96
  }
97
97
 
98
- /* Base explicit reset masking underlying framework defaults */
99
- .root .radio {
98
+ /* Base explicit reset masking underlying framework defaults.
99
+ MUI's native <input type="radio"> stays invisible (SwitchBase overlays it with
100
+ opacity: 0) — it is not the visual circle. The circle is drawn by our own
101
+ icon/checkedIcon node (see Radio.tsx), a sibling of the input, so it's styled
102
+ here directly rather than through MUI's `classes` prop (MUI has no "radio" slot). */
103
+ .radio {
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: center;
100
107
  cursor: pointer; /* HARDCODE: core interaction behavior */
101
- appearance: none; /* HARDCODE: reset native widget */
102
108
  box-sizing: border-box; /* HARDCODE: ensures size includes borders */
103
109
  width: var(--recursica_ui-kit_components_radio-button_properties_size);
104
110
  height: var(--recursica_ui-kit_components_radio-button_properties_size);
@@ -121,7 +127,7 @@
121
127
  }
122
128
 
123
129
  /* Checked State */
124
- .root .radio:checked {
130
+ .radioChecked {
125
131
  background-color: var(
126
132
  --recursica_ui-kit_components_radio-button_variants_selection-states_selected_properties_colors_background-color
127
133
  );
@@ -130,11 +136,14 @@
130
136
  );
131
137
  }
132
138
 
133
- /* Disabled State (colors now differ per selection state, so each combination is mapped explicitly) */
134
- .root .radio:disabled {
139
+ /* Disabled State (colors now differ per selection state; MUI applies its `disabled`
140
+ classKey to the same root element as `root`, so `.root.disabled` targets the
141
+ actual disabled instance and cascades to the decorative icon div inside it —
142
+ mirrors Checkbox's split). */
143
+ .root.disabled .radio {
135
144
  cursor: not-allowed; /* HARDCODE: disabled state default */
136
145
  }
137
- .root .radio:disabled:not(:checked) {
146
+ .root.disabled .radio:not(.radioChecked) {
138
147
  opacity: var(
139
148
  --recursica_ui-kit_components_radio-button_variants_selection-states_unselected_variants_states_disabled_properties_opacity
140
149
  );
@@ -145,7 +154,7 @@
145
154
  --recursica_ui-kit_components_radio-button_variants_selection-states_unselected_variants_states_disabled_properties_colors_border-color
146
155
  );
147
156
  }
148
- .root .radio:checked:disabled {
157
+ .root.disabled .radioChecked {
149
158
  opacity: var(
150
159
  --recursica_ui-kit_components_radio-button_variants_selection-states_selected_variants_states_disabled_properties_opacity
151
160
  );
@@ -157,29 +166,32 @@
157
166
  );
158
167
  }
159
168
 
160
- .root .icon {
161
- position: absolute;
162
- inset: 0;
163
- margin: auto;
169
+ .icon {
164
170
  pointer-events: none;
165
171
  width: var(--recursica_ui-kit_components_radio-button_properties_icon-size);
166
172
  height: var(--recursica_ui-kit_components_radio-button_properties_icon-size);
167
173
  }
168
174
  /* No icon-color token exists for the unselected state (no icon is visible then) */
169
- .root .radio:checked + .iconWrapper .icon,
170
- .root .radio:checked + .icon {
175
+ .radioChecked .icon {
171
176
  color: var(
172
177
  --recursica_ui-kit_components_radio-button_variants_selection-states_selected_properties_colors_icon-color
173
178
  );
174
179
  }
175
- .root .radio:checked:disabled + .iconWrapper .icon,
176
- .root .radio:checked:disabled + .icon {
180
+ .root.disabled .radioChecked .icon {
177
181
  color: var(
178
182
  --recursica_ui-kit_components_radio-button_variants_selection-states_selected_variants_states_disabled_properties_colors_icon-color
179
183
  );
180
184
  }
181
185
 
182
186
  .root .labelWrapper {
187
+ /* display: flex (not the browser default block) blockifies the native inline
188
+ <label> child per the CSS Display spec's flex-item blockification rule —
189
+ matches Mantine's own labelWrapper (inline-flex), whose flex context does
190
+ the same to its label. Without it, the label stays inline and its line box
191
+ picks up extra half-leading height, visually shifting it below vertical
192
+ center against the circle. */
193
+ display: flex;
194
+ flex-direction: column;
183
195
  padding: 0; /* HARDCODE: structural layout reset */
184
196
  margin: 0; /* HARDCODE: structural layout reset */
185
197
  flex: 1;
@@ -230,23 +242,6 @@
230
242
  );
231
243
  }
232
244
 
233
- .root .description {
234
- margin-top: 5px; /* HARDCODE: mantine default */
235
- color: #868e96; /* HARDCODE: mantine default dimmed */
236
- font-size: 12px; /* HARDCODE: mantine default xs */
237
- line-height: 1.2;
238
- }
239
-
240
- .root .error {
241
- margin-top: 5px; /* HARDCODE: mantine default */
242
- color: #fa5252; /* HARDCODE: mantine default error */
243
- font-size: 12px; /* HARDCODE: mantine default xs */
244
- line-height: 1.2;
245
- }
246
-
247
- .root .description[data-disabled="true"],
248
- .root .error[data-disabled="true"] {
249
- opacity: var(
250
- --recursica_ui-kit_components_radio-button-item_variants_states_disabled_properties_opacity
251
- );
252
- }
245
+ /* description/error text renders via AssistiveElement directly (see Radio.tsx) —
246
+ it owns its own tokenized styling. AssistiveElement has no disabled state and
247
+ must never dim. */