@recursica/mui-adapter 0.26.0 → 0.27.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 (46) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.d.ts +82 -0
  3. package/dist/mui-adapter.cjs +73 -73
  4. package/dist/mui-adapter.cjs.map +1 -1
  5. package/dist/mui-adapter.js +9164 -9078
  6. package/dist/mui-adapter.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/components/Autocomplete/Autocomplete.tsx +46 -34
  9. package/src/components/Avatar/Avatar.tsx +11 -21
  10. package/src/components/Badge/Badge.tsx +9 -13
  11. package/src/components/Breadcrumb/Breadcrumb.tsx +12 -18
  12. package/src/components/Button/Button.tsx +17 -11
  13. package/src/components/Card/Card.tsx +9 -19
  14. package/src/components/Checkbox/Checkbox.tsx +22 -26
  15. package/src/components/Checkbox/CheckboxGroup.tsx +3 -3
  16. package/src/components/Chip/Chip.tsx +12 -19
  17. package/src/components/Dropdown/BareDropdown.tsx +14 -8
  18. package/src/components/Dropdown/Dropdown.tsx +13 -6
  19. package/src/components/FileInput/FileInput.tsx +1 -1
  20. package/src/components/FileUpload/FileUpload.tsx +1 -1
  21. package/src/components/FormControlLayout/FormControlLayout.tsx +1 -1
  22. package/src/components/FormControlWrapper/FormControlWrapper.tsx +1 -1
  23. package/src/components/HoverCard/HoverCard.tsx +13 -21
  24. package/src/components/Menu/Menu.tsx +14 -6
  25. package/src/components/Modal/Modal.tsx +12 -6
  26. package/src/components/NumberInput/NumberInput.tsx +30 -15
  27. package/src/components/Pagination/Pagination.tsx +5 -15
  28. package/src/components/Panel/Panel.tsx +17 -25
  29. package/src/components/Popover/Popover.tsx +10 -18
  30. package/src/components/Radio/Radio.tsx +21 -26
  31. package/src/components/Radio/RadioGroup.tsx +3 -4
  32. package/src/components/ReadOnlyField/ReadOnlyTextField.tsx +1 -1
  33. package/src/components/SegmentedControl/SegmentedControl.tsx +20 -25
  34. package/src/components/Slider/Slider.tsx +40 -43
  35. package/src/components/Stepper/Stepper.tsx +34 -14
  36. package/src/components/Switch/Switch.tsx +33 -49
  37. package/src/components/Switch/SwitchGroup.tsx +3 -3
  38. package/src/components/Tabs/Tabs.tsx +12 -4
  39. package/src/components/TextArea/TextArea.tsx +30 -7
  40. package/src/components/TextField/TextField.tsx +30 -26
  41. package/src/components/Timeline/Timeline.tsx +9 -1
  42. package/src/components/Timeline/TimelineItem.tsx +2 -2
  43. package/src/components/Toast/Toast.tsx +14 -23
  44. package/src/components/Tooltip/Tooltip.tsx +14 -27
  45. package/src/components/TransferList/TransferList.tsx +1 -1
  46. package/src/utils/filterStylingProps.ts +4 -0
@@ -5,6 +5,7 @@ import {
5
5
  } from "@mui/material";
6
6
  import {
7
7
  filterStylingProps,
8
+ mergeClassNames,
8
9
  type RecursicaOverStyled,
9
10
  } from "../../utils/filterStylingProps";
10
11
  import styles from "./HoverCard.module.css";
@@ -35,27 +36,18 @@ const HoverCardBase = function HoverCard({
35
36
  overStyled,
36
37
  );
37
38
 
38
- // Bind CSS module classes to Mui's internal classNames API
39
- const mergedClassNames: Partial<Record<string, string>> = {
40
- tooltip: styles.dropdown,
41
- arrow: styles.arrow,
42
- };
43
-
44
- const classNamesProp = (sanitizedProps as Record<string, unknown>).classNames;
45
- if (
46
- classNamesProp &&
47
- typeof classNamesProp === "object" &&
48
- !Array.isArray(classNamesProp)
49
- ) {
50
- const o = classNamesProp as Record<string, string>;
51
- Object.keys(o).forEach((key) => {
52
- if (mergedClassNames[key]) {
53
- mergedClassNames[key] = `${mergedClassNames[key]} ${o[key]}`;
54
- } else {
55
- mergedClassNames[key] = o[key];
56
- }
57
- });
58
- }
39
+ // Bind CSS module classes to Mui's internal classNames API. Note MUI's actual prop is
40
+ // "classes", not "classNames" (that's Mantine's naming) — this used to read the wrong key,
41
+ // silently no-op-ing any caller-supplied classes.
42
+ const mergedClassNames = mergeClassNames(
43
+ {
44
+ tooltip: styles.dropdown,
45
+ arrow: styles.arrow,
46
+ },
47
+ (sanitizedProps as Record<string, unknown>).classes as
48
+ | Partial<Record<string, string>>
49
+ | undefined,
50
+ );
59
51
 
60
52
  // Find Target and Dropdown children
61
53
  let targetNode: React.ReactNode = null;
@@ -9,6 +9,7 @@ import {
9
9
  } from "@mui/material";
10
10
  import {
11
11
  filterStylingProps,
12
+ mergeClassNames,
12
13
  type RecursicaOverStyled,
13
14
  } from "../../utils/filterStylingProps";
14
15
  import styles from "./Menu.module.css";
@@ -23,15 +24,22 @@ export const Menu = forwardRef<HTMLDivElement, MenuProps>(function Menu(
23
24
  ) {
24
25
  const sanitizedProps = filterStylingProps(rest, overStyled);
25
26
 
27
+ const mergedClassNames = mergeClassNames(
28
+ {
29
+ paper: styles.dropdown,
30
+ list: styles.dropdown,
31
+ },
32
+ (sanitizedProps as Record<string, unknown>).classes as
33
+ | Partial<Record<string, string>>
34
+ | undefined,
35
+ );
36
+
26
37
  return (
27
38
  <MuiMenu
28
39
  ref={ref}
29
40
  {...(sanitizedProps as MuiMenuProps)}
30
41
  className={`${styles.dropdown} ${className || ""}`}
31
- classes={{
32
- paper: styles.dropdown,
33
- list: styles.dropdown,
34
- }}
42
+ classes={mergedClassNames}
35
43
  />
36
44
  );
37
45
  });
@@ -47,8 +55,8 @@ export const MenuItem = forwardRef<HTMLLIElement, MenuItemProps>(
47
55
  return (
48
56
  <MuiMenuItem
49
57
  ref={ref}
50
- className={`${styles.item} ${className || ""}`}
51
58
  {...(sanitizedProps as MuiMenuItemProps)}
59
+ className={`${styles.item} ${className || ""}`}
52
60
  />
53
61
  );
54
62
  },
@@ -65,8 +73,8 @@ export const MenuDivider = forwardRef<HTMLHRElement, MenuDividerProps>(
65
73
  return (
66
74
  <MuiDivider
67
75
  ref={ref}
68
- className={`${styles.divider} ${className || ""}`}
69
76
  {...(sanitizedProps as MuiDividerProps)}
77
+ className={`${styles.divider} ${className || ""}`}
70
78
  />
71
79
  );
72
80
  },
@@ -12,6 +12,7 @@ import {
12
12
  } from "@mui/material";
13
13
  import {
14
14
  filterStylingProps,
15
+ mergeClassNames,
15
16
  type RecursicaOverStyled,
16
17
  } from "../../utils/filterStylingProps";
17
18
  import styles from "./Modal.module.css";
@@ -58,6 +59,13 @@ const ModalRoot = forwardRef<HTMLDivElement, ModalProps>(function ModalRoot(
58
59
  ) {
59
60
  const sanitizedProps = filterStylingProps(rest, overStyled);
60
61
 
62
+ const mergedClassNames = mergeClassNames(
63
+ { paper: styles.inner },
64
+ (sanitizedProps as Record<string, unknown>).classes as
65
+ | Partial<Record<string, string>>
66
+ | undefined,
67
+ );
68
+
61
69
  let footer: React.ReactNode = null;
62
70
  const bodyChildren: React.ReactNode[] = [];
63
71
 
@@ -79,9 +87,7 @@ const ModalRoot = forwardRef<HTMLDivElement, ModalProps>(function ModalRoot(
79
87
  ref={ref}
80
88
  {...(sanitizedProps as MuiDialogProps)}
81
89
  className={`${styles.root} ${className || ""}`}
82
- classes={{
83
- paper: styles.inner,
84
- }}
90
+ classes={mergedClassNames}
85
91
  open={
86
92
  opened ||
87
93
  ((sanitizedProps as Record<string, unknown>).open as boolean) ||
@@ -124,8 +130,8 @@ const ModalTitle = forwardRef<HTMLDivElement, ModalTitleProps>(
124
130
  <MuiDialogTitle
125
131
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
126
132
  ref={ref as any}
127
- className={`${styles.title} ${className || ""}`}
128
133
  {...(sanitizedProps as MuiDialogTitleProps)}
134
+ className={`${styles.title} ${className || ""}`}
129
135
  />
130
136
  );
131
137
  },
@@ -174,11 +180,11 @@ const ModalBody = forwardRef<HTMLDivElement, ModalBodyProps>(function ModalBody(
174
180
  internalRef as React.MutableRefObject<HTMLDivElement | null>
175
181
  ).current = node as HTMLDivElement;
176
182
  }}
183
+ {...(sanitizedProps as MuiDialogContentProps)}
177
184
  onScroll={() => checkScroll()}
178
185
  data-scrolled-top={scrolledTop || undefined}
179
186
  data-scrolled-bottom={scrolledBottom || undefined}
180
187
  className={`${styles.content} ${styles.scrollArea} ${className || ""}`}
181
- {...(sanitizedProps as MuiDialogContentProps)}
182
188
  >
183
189
  {children}
184
190
  </MuiDialogContent>
@@ -201,8 +207,8 @@ const ModalFooter = forwardRef<HTMLDivElement, ModalFooterProps>(
201
207
  <MuiDialogActions
202
208
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
203
209
  ref={ref as any}
204
- className={`${styles.footer} ${className || ""}`}
205
210
  {...(sanitizedProps as MuiDialogActionsProps)}
211
+ className={`${styles.footer} ${className || ""}`}
206
212
  />
207
213
  );
208
214
  },
@@ -6,6 +6,8 @@ import {
6
6
  import { type ReadOnlyControlProps } from "@recursica/adapter-common";
7
7
  import {
8
8
  filterStylingProps,
9
+ omitUnsupportedProps,
10
+ mergeClassNames,
9
11
  type RecursicaOverStyled,
10
12
  } from "../../utils/filterStylingProps";
11
13
  import { type RecursicaFormControlWrapperProps } from "../FormControlWrapper/FormControlWrapper";
@@ -73,22 +75,33 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
73
75
  ...rest
74
76
  } = props;
75
77
 
76
- const sanitizedProps = filterStylingProps(rest, overStyled);
77
- const restRecord = sanitizedProps as Record<string, unknown>;
78
+ // Props this component intentionally doesn't support — deleted at runtime so they can't leak
79
+ // through even if a caller forces them via plain JavaScript, bypassing the Omit<> above.
80
+ const UNSUPPORTED_PROPS = [
81
+ "size", // Recursica controls sizing via design tokens, not MUI's native small/medium size
82
+ "variant", // Recursica styles the naked input directly; MUI's standard/filled/outlined unused
83
+ ] as const satisfies readonly (keyof MuiNumberInputProps)[];
78
84
 
79
- // Delete prohibited sizing hooks from bypassing variables natively
80
- delete restRecord["size"];
81
- delete restRecord["variant"];
82
- delete restRecord["radius"];
85
+ const sanitizedProps = omitUnsupportedProps(
86
+ filterStylingProps(rest, overStyled),
87
+ UNSUPPORTED_PROPS,
88
+ );
89
+ const restRecord = sanitizedProps as Record<string, unknown>;
83
90
 
84
- // Securely map core native blocks down ensuring nested CSS modules map precisely
85
- const mergedClassNames: Partial<Record<string, string>> = {
86
- wrapper: styles.root, // The nested Input internal relative wrapper bounding box
87
- input: styles.input,
88
- section: styles.section,
89
- controls: styles.controls,
90
- control: styles.control,
91
- };
91
+ // Securely map core native blocks down ensuring nested CSS modules map precisely. This
92
+ // previously always overwrote the caller's `classes` prop wholesale (no merge at all) —
93
+ // now merged per-slot via mergeClassNames so a caller-supplied slot value extends rather
94
+ // than replaces ours.
95
+ const mergedClassNames = mergeClassNames(
96
+ {
97
+ wrapper: styles.root, // The nested Input internal relative wrapper bounding box
98
+ input: styles.input,
99
+ section: styles.section,
100
+ controls: styles.controls,
101
+ control: styles.control,
102
+ },
103
+ restRecord.classes as Partial<Record<string, string>> | undefined,
104
+ );
92
105
 
93
106
  const wrapperClass = className
94
107
  ? `${styles.layoutOverride} ${className}`
@@ -138,7 +151,9 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
138
151
  <MuiNumberInput
139
152
  ref={ref}
140
153
  {...(sanitizedProps as unknown as MuiNumberInputProps)}
141
- classes={mergedClassNames}
154
+ classes={
155
+ mergedClassNames as unknown as MuiNumberInputProps["classes"]
156
+ }
142
157
  disabled={disabled}
143
158
  value={value}
144
159
  defaultValue={defaultValue}
@@ -5,6 +5,7 @@ import {
5
5
  } from "@mui/material";
6
6
  import {
7
7
  filterStylingProps,
8
+ mergeClassNames,
8
9
  type RecursicaOverStyled,
9
10
  } from "../../utils/filterStylingProps";
10
11
  import styles from "./Pagination.module.css";
@@ -19,21 +20,10 @@ function usePaginationClassNames(restRecord: Record<string, unknown>): {
19
20
  className: string;
20
21
  classNames: Partial<Record<string, string>>;
21
22
  } {
22
- const mergedClassNames: Partial<Record<string, string>> = {
23
- root: styles.root,
24
- ul: styles.ul,
25
- };
26
-
27
- const classNamesProp = restRecord.classNames;
28
- if (
29
- classNamesProp &&
30
- typeof classNamesProp === "object" &&
31
- !Array.isArray(classNamesProp)
32
- ) {
33
- const o = classNamesProp as Partial<Record<string, string>>;
34
- mergedClassNames.root = o.root ? `${styles.root} ${o.root}` : styles.root;
35
- mergedClassNames.ul = o.ul ? `${styles.ul} ${o.ul}` : styles.ul;
36
- }
23
+ const mergedClassNames = mergeClassNames(
24
+ { root: styles.root, ul: styles.ul },
25
+ restRecord.classNames as Partial<Record<string, string>> | undefined,
26
+ );
37
27
 
38
28
  const classNameProp = restRecord.className as string | undefined;
39
29
  const finalClass = classNameProp
@@ -5,6 +5,7 @@ import {
5
5
  } from "@mui/material";
6
6
  import {
7
7
  filterStylingProps,
8
+ mergeClassNames,
8
9
  type RecursicaOverStyled,
9
10
  } from "../../utils/filterStylingProps";
10
11
  import styles from "./Panel.module.css";
@@ -63,30 +64,21 @@ const PanelBase = function Panel({
63
64
  }: PanelProps) {
64
65
  const sanitizedProps = filterStylingProps(rest, overStyled);
65
66
 
66
- // Bind CSS module classes to Mui's internal classNames API
67
- const mergedClassNames: Partial<Record<string, string>> = {
68
- content: styles.content,
69
- header: styles.header,
70
- title: wrapHeaderText ? styles.titleTruncate : styles.title,
71
- body: styles.body,
72
- inner: styles.inner,
73
- };
74
-
75
- const classNamesProp = (sanitizedProps as Record<string, unknown>).classNames;
76
- if (
77
- classNamesProp &&
78
- typeof classNamesProp === "object" &&
79
- !Array.isArray(classNamesProp)
80
- ) {
81
- const o = classNamesProp as Record<string, string>;
82
- Object.keys(o).forEach((key) => {
83
- if (mergedClassNames[key]) {
84
- mergedClassNames[key] = `${mergedClassNames[key]} ${o[key]}`;
85
- } else {
86
- mergedClassNames[key] = o[key];
87
- }
88
- });
89
- }
67
+ // Bind CSS module classes to Mui's internal classNames API. Note MUI's actual prop is
68
+ // "classes", not "classNames" (that's Mantine's naming) — this used to read the wrong key,
69
+ // silently no-op-ing any caller-supplied classes.
70
+ const mergedClassNames = mergeClassNames(
71
+ {
72
+ content: styles.content,
73
+ header: styles.header,
74
+ title: wrapHeaderText ? styles.titleTruncate : styles.title,
75
+ body: styles.body,
76
+ inner: styles.inner,
77
+ },
78
+ (sanitizedProps as Record<string, unknown>).classes as
79
+ | Partial<Record<string, string>>
80
+ | undefined,
81
+ );
90
82
 
91
83
  return (
92
84
  <MuiDrawer
@@ -94,7 +86,7 @@ const PanelBase = function Panel({
94
86
  keepMounted={keepMounted}
95
87
  open={Boolean(opened)}
96
88
  {...(sanitizedProps as unknown as MuiDrawerProps)}
97
- classes={mergedClassNames}
89
+ classes={mergedClassNames as unknown as MuiDrawerProps["classes"]}
98
90
  />
99
91
  );
100
92
  };
@@ -11,6 +11,7 @@ import {
11
11
  } from "@mui/material";
12
12
  import {
13
13
  filterStylingProps,
14
+ mergeClassNames,
14
15
  type RecursicaOverStyled,
15
16
  } from "../../utils/filterStylingProps";
16
17
  import styles from "./Popover.module.css";
@@ -85,24 +86,15 @@ const PopoverBase = function Popover({
85
86
  );
86
87
 
87
88
  // Bind CSS module classes to Mui's internal classNames API
88
- const mergedClassNames: Partial<Record<string, string>> = {
89
- tooltip: styles.dropdown,
90
- arrow: styles.arrow,
91
- };
92
-
93
- const classesProp = (sanitizedProps as Record<string, unknown>).classes;
94
- if (
95
- classesProp &&
96
- typeof classesProp === "object" &&
97
- !Array.isArray(classesProp)
98
- ) {
99
- const o = classesProp as Record<string, string>;
100
- Object.keys(o).forEach((key) => {
101
- mergedClassNames[key] = mergedClassNames[key]
102
- ? `${mergedClassNames[key]} ${o[key]}`
103
- : o[key];
104
- });
105
- }
89
+ const mergedClassNames = mergeClassNames(
90
+ {
91
+ tooltip: styles.dropdown,
92
+ arrow: styles.arrow,
93
+ },
94
+ (sanitizedProps as Record<string, unknown>).classes as
95
+ | Partial<Record<string, string>>
96
+ | undefined,
97
+ );
106
98
 
107
99
  const [internalOpened, setInternalOpened] = useState(defaultOpened);
108
100
  const isControlled = opened !== undefined;
@@ -7,6 +7,8 @@ import { type ReadOnlyControlProps } from "@recursica/adapter-common";
7
7
  import { RadioGroup } from "./RadioGroup";
8
8
  import {
9
9
  filterStylingProps,
10
+ omitUnsupportedProps,
11
+ mergeClassNames,
10
12
  type RecursicaOverStyled,
11
13
  } from "../../utils/filterStylingProps";
12
14
  import {
@@ -72,13 +74,19 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
72
74
  style,
73
75
  ...rest
74
76
  } = props;
75
- const sanitizedProps = filterStylingProps(rest, overStyled);
77
+ // Props this component intentionally doesn't support — deleted at runtime so they can't leak
78
+ // through even if a caller forces them via plain JavaScript, bypassing the Omit<> above.
79
+ const UNSUPPORTED_PROPS = [
80
+ "size", // Recursica controls sizing via design tokens, not MUI's native small/medium size
81
+ "color", // Colors are token-driven; MUI's native palette isn't exposed
82
+ ] as const satisfies readonly (keyof MuiRadioProps)[];
83
+
84
+ const sanitizedProps = omitUnsupportedProps(
85
+ filterStylingProps(rest, overStyled),
86
+ UNSUPPORTED_PROPS,
87
+ );
76
88
  const restRecord = sanitizedProps as Record<string, unknown>;
77
89
 
78
- // Actively delete dimension bindings that bypass the abstraction
79
- delete restRecord["size"];
80
- delete restRecord["color"];
81
-
82
90
  // NOTE: MUI's actual prop is "classes", not "classNames" (that's Mantine's naming) — this
83
91
  // was reading the wrong key and silently doing nothing. Fixed. Also "body"/"inner"/"radio"/
84
92
  // "icon"/"labelWrapper"/"label" below were never real MUI `classes` slots (MUI's Radio only
@@ -89,27 +97,14 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
89
97
  // only the label showed. Fixed by keeping only the real slots here and drawing the circle as
90
98
  // our own combined icon/checkedIcon node instead (see radioNode below), same pattern already
91
99
  // used by Checkbox.
92
- const mergedClassNames: Partial<Record<string, string>> = {
93
- root: styles.root,
94
- checked: styles.checked,
95
- disabled: styles.disabled,
96
- };
97
-
98
- const classesProp = restRecord.classes;
99
- if (
100
- classesProp &&
101
- typeof classesProp === "object" &&
102
- !Array.isArray(classesProp)
103
- ) {
104
- const o = classesProp as Partial<Record<string, string>>;
105
- mergedClassNames.root = o.root ? `${styles.root} ${o.root}` : styles.root;
106
- mergedClassNames.checked = o.checked
107
- ? `${styles.checked} ${o.checked}`
108
- : styles.checked;
109
- mergedClassNames.disabled = o.disabled
110
- ? `${styles.disabled} ${o.disabled}`
111
- : styles.disabled;
112
- }
100
+ const mergedClassNames = mergeClassNames(
101
+ {
102
+ root: styles.root,
103
+ checked: styles.checked,
104
+ disabled: styles.disabled,
105
+ },
106
+ restRecord.classes as Partial<Record<string, string>> | undefined,
107
+ );
113
108
 
114
109
  const classNameProp = restRecord.className as string | undefined;
115
110
  const finalClass = classNameProp
@@ -73,11 +73,10 @@ export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
73
73
  onChange,
74
74
  ...rest
75
75
  } = props;
76
+ // NOTE: MuiRadioGroupProps (via FormGroupProps) has no native "size" (or other
77
+ // Recursica-unsupported) prop to leak through here — nothing to add to an UNSUPPORTED_PROPS
78
+ // list beyond what filterStylingProps already blocks.
76
79
  const sanitizedProps = filterStylingProps(rest, overStyled);
77
- const restRecord = sanitizedProps as Record<string, unknown>;
78
-
79
- // Delete prohibited sizing hooks from bypassing the variables
80
- delete restRecord["size"];
81
80
 
82
81
  return (
83
82
  <WithReadOnlyWrapper
@@ -27,12 +27,12 @@ export const ReadOnlyTextField: React.FC<ReadOnlyTextFieldProps> = ({
27
27
  return (
28
28
  <Box
29
29
  component="p"
30
+ {...sanitizedProps}
30
31
  className={
31
32
  classNameProp
32
33
  ? `${styles.textField} ${classNameProp}`
33
34
  : styles.textField
34
35
  }
35
- {...sanitizedProps}
36
36
  >
37
37
  {value != null
38
38
  ? React.isValidElement(value)
@@ -6,6 +6,8 @@ import {
6
6
  } from "@mui/material";
7
7
  import {
8
8
  filterStylingProps,
9
+ omitUnsupportedProps,
10
+ mergeClassNames,
9
11
  type RecursicaOverStyled,
10
12
  } from "../../utils/filterStylingProps";
11
13
  import styles from "./SegmentedControl.module.css";
@@ -34,27 +36,14 @@ function useSegmentedControlClassNames(restRecord: Record<string, unknown>): {
34
36
  className: string;
35
37
  classNames: Partial<Record<string, string>>;
36
38
  } {
37
- const mergedClassNames: Partial<Record<string, string>> = {
38
- root: styles.root,
39
- control: styles.control,
40
- label: styles.label,
41
- };
42
-
43
- const classNamesProp = restRecord.classNames;
44
- if (
45
- classNamesProp &&
46
- typeof classNamesProp === "object" &&
47
- !Array.isArray(classNamesProp)
48
- ) {
49
- const o = classNamesProp as Partial<Record<string, string>>;
50
- mergedClassNames.root = o.root ? `${styles.root} ${o.root}` : styles.root;
51
- mergedClassNames.control = o.control
52
- ? `${styles.control} ${o.control}`
53
- : styles.control;
54
- mergedClassNames.label = o.label
55
- ? `${styles.label} ${o.label}`
56
- : styles.label;
57
- }
39
+ const mergedClassNames = mergeClassNames(
40
+ {
41
+ root: styles.root,
42
+ control: styles.control,
43
+ label: styles.label,
44
+ },
45
+ restRecord.classNames as Partial<Record<string, string>> | undefined,
46
+ );
58
47
 
59
48
  const classNameProp = restRecord.className as string | undefined;
60
49
  const finalClass = classNameProp
@@ -77,11 +66,17 @@ const _SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(
77
66
  },
78
67
  ref,
79
68
  ) {
80
- const sanitizedProps = filterStylingProps(rest, overStyled);
81
- const restRecord = sanitizedProps as Record<string, unknown>;
69
+ // Props this component intentionally doesn't support — deleted at runtime so they can't leak
70
+ // through even if a caller forces them via plain JavaScript, bypassing the Omit<> above.
71
+ const UNSUPPORTED_PROPS = [
72
+ "disabled", // Recursica controls per-item disabled state via `data.disabled`, not the group
73
+ ] as const satisfies readonly (keyof MuiSegmentedControlProps)[];
82
74
 
83
- // Explicitly prevent consumers from bypassing the disabled restriction
84
- delete restRecord["disabled"];
75
+ const sanitizedProps = omitUnsupportedProps(
76
+ filterStylingProps(rest, overStyled),
77
+ UNSUPPORTED_PROPS,
78
+ );
79
+ const restRecord = sanitizedProps as Record<string, unknown>;
85
80
 
86
81
  const stylingParams = useSegmentedControlClassNames(restRecord);
87
82
 
@@ -6,6 +6,8 @@ import {
6
6
  import { type ReadOnlyControlProps } from "@recursica/adapter-common";
7
7
  import {
8
8
  filterStylingProps,
9
+ omitUnsupportedProps,
10
+ mergeClassNames,
9
11
  type RecursicaOverStyled,
10
12
  } from "../../utils/filterStylingProps";
11
13
  import { type RecursicaFormControlWrapperProps } from "../FormControlWrapper/FormControlWrapper";
@@ -137,13 +139,18 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
137
139
  setInputValue(resolvedValue.toString());
138
140
  };
139
141
 
140
- const sanitizedProps = filterStylingProps(rest, overStyled);
141
- const restRecord = sanitizedProps as Record<string, unknown>;
142
+ // Props this component intentionally doesn't support — deleted at runtime so they can't leak
143
+ // through even if a caller forces them via plain JavaScript, bypassing the Omit<> above.
144
+ const UNSUPPORTED_PROPS = [
145
+ "size", // Recursica controls sizing via design tokens, not MUI's native small/medium size
146
+ "color", // Colors are token-driven; MUI's native palette isn't exposed
147
+ ] as const satisfies readonly (keyof MuiSliderProps)[];
142
148
 
143
- // Delete prohibited sizing hooks from bypassing variables natively
144
- delete restRecord["size"];
145
- delete restRecord["color"];
146
- delete restRecord["wrapperProps"];
149
+ const sanitizedProps = omitUnsupportedProps(
150
+ filterStylingProps(rest, overStyled),
151
+ UNSUPPORTED_PROPS,
152
+ );
153
+ const restRecord = sanitizedProps as Record<string, unknown>;
147
154
 
148
155
  // MUI always programmatically re-focuses its hidden native input on pointer interaction,
149
156
  // which browsers' `:focus-visible` heuristic treats as keyboard-visible — unlike Mantine's
@@ -175,43 +182,33 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
175
182
  externalOnKeyDown?.(e);
176
183
  };
177
184
 
178
- // Securely map core native blocks down ensuring nested CSS modules map precisely
179
- const mergedClassNames: Partial<Record<string, string>> = {
180
- root: styles.sliderRoot,
181
- rail: styles.sliderTrack,
182
- track: styles.sliderBar,
183
- thumb: styles.sliderThumb,
184
- mark: styles.sliderMark,
185
- markLabel: styles.sliderMarkLabel,
186
- valueLabel: styles.sliderTooltip,
187
- };
188
-
189
- const classNamesProp = restRecord.classNames;
190
- if (
191
- classNamesProp &&
192
- typeof classNamesProp === "object" &&
193
- !Array.isArray(classNamesProp)
194
- ) {
195
- const o = classNamesProp as Partial<Record<string, string>>;
196
- mergedClassNames.root = o.root
197
- ? `${styles.sliderRoot} ${o.root}`
198
- : styles.sliderRoot;
199
- mergedClassNames.rail = o.track
200
- ? `${styles.sliderTrack} ${o.track}`
201
- : styles.sliderTrack;
202
- mergedClassNames.track = o.bar
203
- ? `${styles.sliderBar} ${o.bar}`
204
- : styles.sliderBar;
205
- mergedClassNames.thumb = o.thumb
206
- ? `${styles.sliderThumb} ${o.thumb}`
207
- : styles.sliderThumb;
208
- mergedClassNames.mark = o.mark
209
- ? `${styles.sliderMark} ${o.mark}`
210
- : styles.sliderMark;
211
- mergedClassNames.markLabel = o.markLabel
212
- ? `${styles.sliderMarkLabel} ${o.markLabel}`
213
- : styles.sliderMarkLabel;
214
- }
185
+ // Securely map core native blocks down ensuring nested CSS modules map precisely. Note MUI's
186
+ // actual prop is "classes", not "classNames" (that's Mantine's naming) — this used to read
187
+ // the wrong key, silently no-op-ing any caller-supplied classes. The caller-facing slot names
188
+ // below (root/track/bar/thumb/mark/markLabel) mirror the mantine-adapter's own Slider
189
+ // classNames slots, translated to MUI's real classes slot names (root/rail/track/thumb/...).
190
+ const callerClasses = restRecord.classes as
191
+ | Partial<Record<string, string>>
192
+ | undefined;
193
+ const mergedClassNames = mergeClassNames(
194
+ {
195
+ root: styles.sliderRoot,
196
+ rail: styles.sliderTrack,
197
+ track: styles.sliderBar,
198
+ thumb: styles.sliderThumb,
199
+ mark: styles.sliderMark,
200
+ markLabel: styles.sliderMarkLabel,
201
+ valueLabel: styles.sliderTooltip,
202
+ },
203
+ callerClasses && {
204
+ root: callerClasses.root,
205
+ rail: callerClasses.track,
206
+ track: callerClasses.bar,
207
+ thumb: callerClasses.thumb,
208
+ mark: callerClasses.mark,
209
+ markLabel: callerClasses.markLabel,
210
+ },
211
+ );
215
212
 
216
213
  const wrapperClass = className
217
214
  ? `${styles.layoutOverride} ${className}`