@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
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "url": "git+https://github.com/borderux/recursica.git",
14
14
  "directory": "packages/mui-adapter"
15
15
  },
16
- "version": "0.26.0",
16
+ "version": "0.27.0",
17
17
  "publishConfig": {
18
18
  "access": "public"
19
19
  },
@@ -8,6 +8,8 @@ import {
8
8
  import { type ReadOnlyControlProps } from "@recursica/adapter-common";
9
9
  import {
10
10
  filterStylingProps,
11
+ omitUnsupportedProps,
12
+ mergeClassNames,
11
13
  type RecursicaOverStyled,
12
14
  } from "../../utils/filterStylingProps";
13
15
  import { type RecursicaFormControlWrapperProps } from "../FormControlWrapper/FormControlWrapper";
@@ -77,42 +79,50 @@ export const Autocomplete = forwardRef<HTMLInputElement, AutocompleteProps>(
77
79
  ListboxProps,
78
80
  ...rest
79
81
  } = props;
80
- const sanitizedProps = filterStylingProps(rest, overStyled);
81
- const restRecord = sanitizedProps as Record<string, unknown>;
82
-
83
- // Delete prohibited sizing hooks from bypassing variables natively
84
- delete restRecord["size"];
85
- delete restRecord["variant"];
86
- delete restRecord["radius"];
82
+ // Props this component intentionally doesn't support — deleted at runtime so they can't leak
83
+ // through even if a caller forces them via plain JavaScript, bypassing the Omit<> above.
84
+ const UNSUPPORTED_PROPS = [
85
+ "size", // Recursica controls sizing via design tokens, not MUI's native small/medium size
86
+ ] as const satisfies readonly (keyof MuiAutocompleteProps<
87
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
+ any,
89
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
+ any,
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
92
+ any,
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
94
+ any,
95
+ "div"
96
+ >)[];
87
97
 
88
- // Securely map core native blocks down ensuring nested CSS modules map precisely
89
- const mergedClassNames: Partial<Record<string, string>> = {
90
- root: styles.root,
91
- inputRoot: styles.input, // Map Mantine .input (wrapper) to MUI's inputRoot
92
- listbox: styles.dropdown, // Map Mantine .dropdown to MUI's listbox
93
- option: styles.option,
94
- };
98
+ const sanitizedProps = omitUnsupportedProps(
99
+ filterStylingProps(rest, overStyled),
100
+ UNSUPPORTED_PROPS,
101
+ );
102
+ const restRecord = sanitizedProps as Record<string, unknown>;
95
103
 
96
- const classNamesProp = restRecord.classNames;
97
- if (
98
- classNamesProp &&
99
- typeof classNamesProp === "object" &&
100
- !Array.isArray(classNamesProp)
101
- ) {
102
- const o = classNamesProp as Partial<Record<string, string>>;
103
- mergedClassNames.root = o.wrapper
104
- ? `${styles.root} ${o.wrapper}`
105
- : styles.root;
106
- mergedClassNames.inputRoot = o.input
107
- ? `${styles.input} ${o.input}`
108
- : styles.input;
109
- mergedClassNames.listbox = o.dropdown
110
- ? `${styles.dropdown} ${o.dropdown}`
111
- : styles.dropdown;
112
- mergedClassNames.option = o.option
113
- ? `${styles.option} ${o.option}`
114
- : styles.option;
115
- }
104
+ // Securely map core native blocks down ensuring nested CSS modules map precisely. Note MUI's
105
+ // actual prop is "classes", not "classNames" (that's Mantine's naming) — this used to read
106
+ // the wrong key, silently no-op-ing any caller-supplied classes. The caller-facing slot names
107
+ // below (wrapper/input/dropdown/option) mirror the mantine-adapter's own Autocomplete
108
+ // classNames slots, translated to MUI's real classes slot names (root/inputRoot/listbox/option).
109
+ const callerClasses = restRecord.classes as
110
+ | Partial<Record<string, string>>
111
+ | undefined;
112
+ const mergedClassNames = mergeClassNames(
113
+ {
114
+ root: styles.root,
115
+ inputRoot: styles.input, // Map Mantine .input (wrapper) to MUI's inputRoot
116
+ listbox: styles.dropdown, // Map Mantine .dropdown to MUI's listbox
117
+ option: styles.option,
118
+ },
119
+ callerClasses && {
120
+ root: callerClasses.wrapper,
121
+ inputRoot: callerClasses.input,
122
+ listbox: callerClasses.dropdown,
123
+ option: callerClasses.option,
124
+ },
125
+ );
116
126
 
117
127
  const wrapperClass = className
118
128
  ? `${styles.layoutOverride} ${className}`
@@ -148,6 +158,8 @@ export const Autocomplete = forwardRef<HTMLInputElement, AutocompleteProps>(
148
158
  /* Naked Input execution safely decoupled from Mui's macro Input.Wrapper DOM hooks */
149
159
  <MuiAutocomplete
150
160
  ref={ref}
161
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
162
+ {...(sanitizedProps as any)}
151
163
  freeSolo
152
164
  disableClearable
153
165
  classes={mergedClassNames}
@@ -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 "./Avatar.module.css";
@@ -42,27 +43,16 @@ const _Avatar = forwardRef<HTMLDivElement, AvatarProps>(function Avatar(
42
43
  computedStyle = "icon";
43
44
  }
44
45
 
45
- const mergedClassNames: Partial<Record<string, string>> = {
46
- root: styles.root,
47
- image: styles.image,
48
- placeholder: styles.placeholder,
49
- };
50
-
51
- const classNamesProp = restRecord.classNames;
52
- if (
53
- classNamesProp &&
54
- typeof classNamesProp === "object" &&
55
- !Array.isArray(classNamesProp)
56
- ) {
57
- const o = classNamesProp as Partial<Record<string, string>>;
58
- mergedClassNames.root = o.root ? `${styles.root} ${o.root}` : styles.root;
59
- mergedClassNames.image = o.image
60
- ? `${styles.image} ${o.image}`
61
- : styles.image;
62
- mergedClassNames.placeholder = o.placeholder
63
- ? `${styles.placeholder} ${o.placeholder}`
64
- : styles.placeholder;
65
- }
46
+ // Note MUI's actual prop is "classes", not "classNames" (that's Mantine's naming) — this
47
+ // used to read the wrong key, silently no-op-ing any caller-supplied classes.
48
+ const mergedClassNames = mergeClassNames(
49
+ {
50
+ root: styles.root,
51
+ image: styles.image,
52
+ placeholder: styles.placeholder,
53
+ },
54
+ restRecord.classes as Partial<Record<string, string>> | undefined,
55
+ );
66
56
 
67
57
  const classNameProp = restRecord.className as string | undefined;
68
58
 
@@ -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 "./Badge.module.css";
@@ -24,19 +25,14 @@ const _Badge = forwardRef<HTMLDivElement, BadgeProps>(function Badge(
24
25
  // External layout props like margins are safely preserved.
25
26
  const sanitizedProps = filterStylingProps(rest, overStyled);
26
27
 
27
- const mergedClassNames: Partial<Record<string, string>> = {
28
- root: styles.root,
29
- };
30
-
31
- const classNamesProp = (sanitizedProps as Record<string, unknown>).classNames;
32
- if (
33
- classNamesProp &&
34
- typeof classNamesProp === "object" &&
35
- !Array.isArray(classNamesProp)
36
- ) {
37
- const o = classNamesProp as Partial<Record<string, string>>;
38
- mergedClassNames.root = o.root ? `${styles.root} ${o.root}` : styles.root;
39
- }
28
+ // Note MUI's actual prop is "classes", not "classNames" (that's Mantine's naming) — this
29
+ // used to read the wrong key, silently no-op-ing any caller-supplied classes.
30
+ const mergedClassNames = mergeClassNames(
31
+ { root: styles.root },
32
+ (sanitizedProps as Record<string, unknown>).classes as
33
+ | Partial<Record<string, string>>
34
+ | undefined,
35
+ );
40
36
 
41
37
  const classNameProp = (sanitizedProps as Record<string, unknown>)
42
38
  .className as string | undefined;
@@ -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 "./Breadcrumb.module.css";
@@ -22,24 +23,17 @@ export const Breadcrumb = forwardRef<HTMLDivElement, BreadcrumbProps>(
22
23
  overStyled,
23
24
  );
24
25
 
25
- const mergedClassNames: Partial<Record<string, string>> = {
26
- root: styles.root,
27
- separator: styles.separator,
28
- };
29
-
30
- const classNamesProp = (sanitizedProps as Record<string, unknown>)
31
- .classNames;
32
- if (
33
- classNamesProp &&
34
- typeof classNamesProp === "object" &&
35
- !Array.isArray(classNamesProp)
36
- ) {
37
- const o = classNamesProp as Partial<Record<string, string>>;
38
- mergedClassNames.root = o.root ? `${styles.root} ${o.root}` : styles.root;
39
- mergedClassNames.separator = o.separator
40
- ? `${styles.separator} ${o.separator}`
41
- : styles.separator;
42
- }
26
+ // Note MUI's actual prop is "classes", not "classNames" (that's Mantine's naming) — this
27
+ // used to read the wrong key, silently no-op-ing any caller-supplied classes.
28
+ const mergedClassNames = mergeClassNames(
29
+ {
30
+ root: styles.root,
31
+ separator: styles.separator,
32
+ },
33
+ (sanitizedProps as Record<string, unknown>).classes as
34
+ | Partial<Record<string, string>>
35
+ | undefined,
36
+ );
43
37
 
44
38
  const classNameProp = (sanitizedProps as Record<string, unknown>)
45
39
  .className as string | undefined;
@@ -6,6 +6,8 @@ import {
6
6
  } from "@mui/material";
7
7
  import {
8
8
  filterStylingProps,
9
+ omitUnsupportedProps,
10
+ withCallerOverride,
9
11
  type RecursicaOverStyled,
10
12
  } from "../../utils/filterStylingProps";
11
13
  import styles from "./Button.module.css";
@@ -54,11 +56,17 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
54
56
  },
55
57
  ref,
56
58
  ) {
57
- const sanitizedProps = filterStylingProps(rest, overStyled);
58
- const restRecord = sanitizedProps as Record<string, unknown>;
59
+ // Props this component intentionally doesn't support — deleted at runtime so they can't leak
60
+ // through even if a caller forces them via plain JavaScript, bypassing the Omit<> above.
61
+ const UNSUPPORTED_PROPS = [
62
+ "color", // Recursica handles colors internally via tokens
63
+ ] as const satisfies readonly (keyof MuiButtonProps)[];
59
64
 
60
- // Explicitly delete blocked semantic expansion dimension props that MUI uses natively
61
- delete restRecord["color"]; // Recursica handles colors internally via tokens
65
+ const sanitizedProps = omitUnsupportedProps(
66
+ filterStylingProps(rest, overStyled),
67
+ UNSUPPORTED_PROPS,
68
+ );
69
+ const restRecord = sanitizedProps as Record<string, unknown>;
62
70
 
63
71
  const hasStartIcon = !!icon || !!restRecord["startIcon"];
64
72
  const hasEndIcon = !!restRecord["endIcon"];
@@ -87,18 +95,16 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
87
95
  const finalClass = classNameProp
88
96
  ? `${styles.root} ${classNameProp}`
89
97
  : styles.root;
90
- // className is merged explicitly above — don't let the {...sanitizedProps} spread below
91
- // silently overwrite finalClass with just the caller's own class (same bug class as
92
- // mui-adapter's Dropdown/BareDropdown.tsx had).
93
- delete restRecord["className"];
94
98
 
95
99
  // We don't map Recursica variant/size to MUI's because we want to completely disable MUI's native
96
100
  // variant logic (e.g., elevation, shadows) and style everything strictly through our CSS Modules.
97
101
  // However, Mui Button requires some string, but we can just leave it as standard or ignore since
98
102
  // our CSS resets its properties anyway, but to be clean we just don't pass variant to MUI.
99
103
 
100
- const resolvedLoaderSize =
101
- loaderSize ?? (size === "small" ? "small" : "default");
104
+ const resolvedLoaderSize = withCallerOverride(
105
+ size === "small" ? "small" : "default",
106
+ loaderSize,
107
+ );
102
108
 
103
109
  let loadingIndicator = restRecord.loadingIndicator as React.ReactNode;
104
110
  if (useRecursicaLoader) {
@@ -110,6 +116,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
110
116
  return (
111
117
  <MuiButton
112
118
  ref={ref}
119
+ {...sanitizedProps}
113
120
  disableRipple
114
121
  disableElevation
115
122
  className={finalClass}
@@ -131,7 +138,6 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
131
138
  data-size={size}
132
139
  data-content={contentType}
133
140
  {...(restRecord.loading ? { "data-loading": "true" } : {})}
134
- {...sanitizedProps}
135
141
  disabled={!!restRecord.disabled || !!restRecord.loading}
136
142
  >
137
143
  <span className={styles.labelText}>{children}</span>
@@ -2,6 +2,7 @@ import { forwardRef } from "react";
2
2
  import { Card as MuiCard, type CardProps as MuiCardProps } from "@mui/material";
3
3
  import {
4
4
  filterStylingProps,
5
+ mergeClassNames,
5
6
  type RecursicaOverStyled,
6
7
  } from "../../utils/filterStylingProps";
7
8
  import styles from "./Card.module.css";
@@ -51,25 +52,14 @@ const CardBase = forwardRef<HTMLDivElement, CardProps>(function Card(
51
52
  }
52
53
  });
53
54
 
54
- const mergedClassNames: Partial<Record<string, string>> = {
55
- root: styles.root,
56
- };
57
-
58
- const classNamesProp = (sanitizedProps as Record<string, unknown>).classNames;
59
- if (
60
- classNamesProp &&
61
- typeof classNamesProp === "object" &&
62
- !Array.isArray(classNamesProp)
63
- ) {
64
- const o = classNamesProp as Record<string, string>;
65
- Object.keys(o).forEach((key) => {
66
- if (mergedClassNames[key]) {
67
- mergedClassNames[key] = `${mergedClassNames[key]} ${o[key]}`;
68
- } else {
69
- mergedClassNames[key] = o[key];
70
- }
71
- });
72
- }
55
+ // Note MUI's actual prop is "classes", not "classNames" (that's Mantine's naming) — this
56
+ // used to read the wrong key, silently no-op-ing any caller-supplied classes.
57
+ const mergedClassNames = mergeClassNames(
58
+ { root: styles.root },
59
+ (sanitizedProps as Record<string, unknown>).classes as
60
+ | Partial<Record<string, string>>
61
+ | undefined,
62
+ );
73
63
 
74
64
  const classNameProp = (sanitizedProps as Record<string, unknown>)
75
65
  .className as string | undefined;
@@ -7,6 +7,8 @@ import { type ReadOnlyControlProps } from "@recursica/adapter-common";
7
7
  import { CheckboxGroup, CheckboxGroupContext } from "./CheckboxGroup";
8
8
  import {
9
9
  filterStylingProps,
10
+ omitUnsupportedProps,
11
+ mergeClassNames,
10
12
  type RecursicaOverStyled,
11
13
  } from "../../utils/filterStylingProps";
12
14
  import {
@@ -58,33 +60,27 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
58
60
  ...rest
59
61
  } = props;
60
62
 
61
- const sanitizedProps = filterStylingProps(rest, overStyled);
62
- const restRecord = sanitizedProps as Record<string, unknown>;
63
-
64
- delete restRecord["size"];
65
- delete restRecord["color"];
63
+ // Props this component intentionally doesn't support — deleted at runtime so they can't leak
64
+ // through even if a caller forces them via plain JavaScript, bypassing the Omit<> above.
65
+ const UNSUPPORTED_PROPS = [
66
+ "size", // Recursica controls sizing via design tokens, not MUI's native small/medium size
67
+ "color", // Colors are token-driven; MUI's native palette isn't exposed
68
+ ] as const satisfies readonly (keyof MuiCheckboxProps)[];
66
69
 
67
- const mergedClassNames: Partial<Record<string, string>> = {
68
- root: styles.root,
69
- checked: styles.checked,
70
- disabled: styles.disabled,
71
- };
70
+ const sanitizedProps = omitUnsupportedProps(
71
+ filterStylingProps(rest, overStyled),
72
+ UNSUPPORTED_PROPS,
73
+ );
74
+ const restRecord = sanitizedProps as Record<string, unknown>;
72
75
 
73
- const classesProp = restRecord.classes;
74
- if (
75
- classesProp &&
76
- typeof classesProp === "object" &&
77
- !Array.isArray(classesProp)
78
- ) {
79
- const o = classesProp as Partial<Record<string, string>>;
80
- mergedClassNames.root = o.root ? `${styles.root} ${o.root}` : styles.root;
81
- mergedClassNames.checked = o.checked
82
- ? `${styles.checked} ${o.checked}`
83
- : styles.checked;
84
- mergedClassNames.disabled = o.disabled
85
- ? `${styles.disabled} ${o.disabled}`
86
- : styles.disabled;
87
- }
76
+ const mergedClassNames = mergeClassNames(
77
+ {
78
+ root: styles.root,
79
+ checked: styles.checked,
80
+ disabled: styles.disabled,
81
+ },
82
+ restRecord.classes as Partial<Record<string, string>> | undefined,
83
+ );
88
84
 
89
85
  const classNameProp = restRecord.className as string | undefined;
90
86
  const finalClass = classNameProp
@@ -172,10 +168,10 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
172
168
  const checkboxNode = (
173
169
  <MuiCheckbox
174
170
  ref={ref}
171
+ {...(sanitizedProps as unknown as MuiCheckboxProps)}
175
172
  className={!label ? `${finalClass} ${styles.inner}` : styles.inner}
176
173
  classes={mergedClassNames}
177
174
  disabled={readOnly || disabled || isGroupReadOnly}
178
- {...(sanitizedProps as unknown as MuiCheckboxProps)}
179
175
  {...(isControlled ? { checked: isChecked as boolean } : {})}
180
176
  onChange={handleChange}
181
177
  disableRipple
@@ -75,12 +75,12 @@ export const CheckboxGroup = forwardRef<HTMLDivElement, CheckboxGroupProps>(
75
75
  row,
76
76
  ...rest
77
77
  } = props;
78
+ // NOTE: this component's props surface (HTMLAttributes<HTMLDivElement>-based) has no native
79
+ // "size" (or other Recursica-unsupported) prop to leak through here — nothing to add to an
80
+ // UNSUPPORTED_PROPS list beyond what filterStylingProps already blocks.
78
81
  const sanitizedProps = filterStylingProps(rest, overStyled);
79
82
  const restRecord = sanitizedProps as Record<string, unknown>;
80
83
 
81
- // Delete prohibited sizing hooks
82
- delete restRecord["size"];
83
-
84
84
  const handleChange = (_event: React.SyntheticEvent, childValue: any) => {
85
85
  if (onChange) {
86
86
  onChange(childValue);
@@ -2,6 +2,7 @@ import React, { forwardRef } from "react";
2
2
  import { Chip as MuiChip, type ChipProps as MuiChipProps } from "@mui/material";
3
3
  import {
4
4
  filterStylingProps,
5
+ mergeClassNames,
5
6
  type RecursicaOverStyled,
6
7
  } from "../../utils/filterStylingProps";
7
8
  import styles from "./Chip.module.css";
@@ -75,25 +76,17 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
75
76
  const sanitizedProps = filterStylingProps(rest, overStyled);
76
77
  const restRecord = sanitizedProps as Record<string, unknown>;
77
78
 
78
- const mergedClassNames: Partial<Record<string, string>> = {
79
- root: styles.root,
80
- label: styles.label,
81
- icon: styles.leadingIcon,
82
- deleteIcon: styles.removeIconWrapper,
83
- };
84
-
85
- const classNamesProp = restRecord.classNames;
86
- if (
87
- classNamesProp &&
88
- typeof classNamesProp === "object" &&
89
- !Array.isArray(classNamesProp)
90
- ) {
91
- const o = classNamesProp as Partial<Record<string, string>>;
92
- mergedClassNames.root = o.root ? `${styles.root} ${o.root}` : styles.root;
93
- mergedClassNames.label = o.label
94
- ? `${styles.label} ${o.label}`
95
- : styles.label;
96
- }
79
+ // Note MUI's actual prop is "classes", not "classNames" (that's Mantine's naming) — this
80
+ // used to read the wrong key, silently no-op-ing any caller-supplied classes.
81
+ const mergedClassNames = mergeClassNames(
82
+ {
83
+ root: styles.root,
84
+ label: styles.label,
85
+ icon: styles.leadingIcon,
86
+ deleteIcon: styles.removeIconWrapper,
87
+ },
88
+ restRecord.classes as Partial<Record<string, string>> | undefined,
89
+ );
97
90
 
98
91
  const classNameProp = restRecord.className as string | undefined;
99
92
  const finalClass = classNameProp
@@ -6,6 +6,7 @@ import {
6
6
  } from "@mui/material";
7
7
  import {
8
8
  filterStylingProps,
9
+ omitUnsupportedProps,
9
10
  type RecursicaOverStyled,
10
11
  } from "../../utils/filterStylingProps";
11
12
  import styles from "./Dropdown.module.css";
@@ -52,14 +53,19 @@ export const BareDropdown = forwardRef<
52
53
  error,
53
54
  ...rest
54
55
  } = props;
55
- const sanitizedProps = filterStylingProps(rest, overStyled);
56
- const restRecord = sanitizedProps as Record<string, unknown>;
56
+ // Props this component intentionally doesn't support — deleted at runtime so they can't leak
57
+ // through even if a caller forces them via plain JavaScript, bypassing the Omit<> above.
58
+ const UNSUPPORTED_PROPS = [
59
+ "size", // Recursica controls sizing via design tokens, not MUI's native small/medium size
60
+ "variant", // Recursica styles the naked select directly; MUI's standard/filled/outlined unused
61
+ "classes", // Recursica computes its own classes object below; caller's would silently clobber it
62
+ ] as const satisfies readonly (keyof MuiSelectProps)[];
57
63
 
58
- delete restRecord["size"];
59
- delete restRecord["variant"];
60
- // className is merged explicitly below — don't let the spread further down silently overwrite
61
- // styles.root with just the caller's own class.
62
- delete restRecord["className"];
64
+ const sanitizedProps = omitUnsupportedProps(
65
+ filterStylingProps(rest, overStyled),
66
+ UNSUPPORTED_PROPS,
67
+ );
68
+ const restRecord = sanitizedProps as Record<string, unknown>;
63
69
 
64
70
  const mergedClassName = className
65
71
  ? `${styles.root} ${className}`
@@ -100,6 +106,7 @@ export const BareDropdown = forwardRef<
100
106
  return (
101
107
  <MuiSelect
102
108
  ref={ref}
109
+ {...(sanitizedProps as unknown as MuiSelectProps)}
103
110
  disabled={disabled}
104
111
  value={value}
105
112
  defaultValue={defaultValue}
@@ -125,7 +132,6 @@ export const BareDropdown = forwardRef<
125
132
  "data-error": error ? "true" : undefined,
126
133
  ...(restRecord.inputProps as Record<string, unknown>),
127
134
  }}
128
- {...(sanitizedProps as unknown as MuiSelectProps)}
129
135
  >
130
136
  {renderOptions()}
131
137
  </MuiSelect>
@@ -7,6 +7,7 @@ import {
7
7
  import { type ReadOnlyControlProps } from "@recursica/adapter-common";
8
8
  import {
9
9
  filterStylingProps,
10
+ omitUnsupportedProps,
10
11
  type RecursicaOverStyled,
11
12
  } from "../../utils/filterStylingProps";
12
13
  import { type RecursicaFormControlWrapperProps } from "../FormControlWrapper/FormControlWrapper";
@@ -73,12 +74,18 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
73
74
  clearable, // Not natively supported by basic MUI Select, stubbed
74
75
  ...rest
75
76
  } = props;
76
- const sanitizedProps = filterStylingProps(rest, overStyled);
77
- const restRecord = sanitizedProps as Record<string, unknown>;
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
+ "variant", // Recursica styles the naked select directly; MUI's standard/filled/outlined unused
82
+ ] as const satisfies readonly (keyof MuiSelectProps)[];
78
83
 
79
- // Delete prohibited sizing hooks
80
- delete restRecord["size"];
81
- delete restRecord["variant"];
84
+ const sanitizedProps = omitUnsupportedProps(
85
+ filterStylingProps(rest, overStyled),
86
+ UNSUPPORTED_PROPS,
87
+ );
88
+ const restRecord = sanitizedProps as Record<string, unknown>;
82
89
 
83
90
  const injectedStyles = {
84
91
  ...((style as React.CSSProperties) || {}),
@@ -159,6 +166,7 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
159
166
  activeComponent={
160
167
  <MuiSelect
161
168
  ref={ref}
169
+ {...(sanitizedProps as unknown as MuiSelectProps)}
162
170
  disabled={disabled}
163
171
  value={value}
164
172
  defaultValue={defaultValue}
@@ -185,7 +193,6 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
185
193
  "data-error": error ? "true" : undefined,
186
194
  ...(restRecord.inputProps as Record<string, unknown>),
187
195
  }}
188
- {...(sanitizedProps as unknown as MuiSelectProps)}
189
196
  >
190
197
  {renderOptions()}
191
198
  </MuiSelect>
@@ -308,6 +308,7 @@ export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
308
308
  >
309
309
  <div
310
310
  ref={ref}
311
+ {...restRecord}
311
312
  className={styles.root}
312
313
  role="button"
313
314
  aria-label={browseLabel}
@@ -323,7 +324,6 @@ export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
323
324
  onDragLeave={interactive ? handleDragLeave : undefined}
324
325
  onDragOver={interactive ? handleDragOver : undefined}
325
326
  onDrop={interactive ? handleDrop : undefined}
326
- {...restRecord}
327
327
  >
328
328
  <span className={styles.leadingIcon} aria-hidden>
329
329
  {icon ?? <UploadIcon />}
@@ -267,10 +267,10 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
267
267
  >
268
268
  <div
269
269
  ref={ref}
270
+ {...restRecord}
270
271
  className={styles.root}
271
272
  data-disabled={disabled ? "true" : undefined}
272
273
  data-error={effectiveError ? "true" : undefined}
273
- {...restRecord}
274
274
  >
275
275
  {!readOnly && (
276
276
  <div
@@ -32,6 +32,7 @@ export const FormControlLayout = React.forwardRef<
32
32
  return (
33
33
  <div
34
34
  ref={ref}
35
+ {...sanitizedProps}
35
36
  className={className ? `${styles.root} ${className}` : styles.root}
36
37
  data-form-layout={formLayout}
37
38
  style={
@@ -45,7 +46,6 @@ export const FormControlLayout = React.forwardRef<
45
46
  : {}),
46
47
  } as React.CSSProperties
47
48
  }
48
- {...sanitizedProps}
49
49
  >
50
50
  {/*
51
51
  The left section strictly enforces the Label boundary sizes
@@ -94,6 +94,7 @@ export const FormControlWrapper = React.forwardRef<
94
94
  return (
95
95
  <FormControl
96
96
  ref={ref}
97
+ {...(sanitizedProps as FormControlProps)}
97
98
  error={!!error}
98
99
  required={required}
99
100
  disabled={disabled}
@@ -103,7 +104,6 @@ export const FormControlWrapper = React.forwardRef<
103
104
  data-error={error ? "true" : undefined}
104
105
  data-disabled={disabled ? "true" : undefined}
105
106
  data-focused={focused ? "true" : undefined}
106
- {...(sanitizedProps as FormControlProps)}
107
107
  >
108
108
  <FormControlLayout
109
109
  formLayout={formLayout}