@recursica/mui-adapter 0.26.0 → 0.28.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 (56) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/index.d.ts +83 -1
  3. package/dist/mui-adapter.cjs +73 -73
  4. package/dist/mui-adapter.cjs.map +1 -1
  5. package/dist/mui-adapter.css +1 -1
  6. package/dist/mui-adapter.js +9400 -9255
  7. package/dist/mui-adapter.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/components/Autocomplete/Autocomplete.tsx +46 -34
  10. package/src/components/Avatar/Avatar.tsx +11 -21
  11. package/src/components/Badge/Badge.tsx +9 -13
  12. package/src/components/Breadcrumb/Breadcrumb.module.css +10 -5
  13. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +0 -6
  14. package/src/components/Breadcrumb/Breadcrumb.tsx +13 -18
  15. package/src/components/Button/Button.tsx +17 -11
  16. package/src/components/Card/Card.tsx +9 -19
  17. package/src/components/Checkbox/Checkbox.tsx +22 -26
  18. package/src/components/Checkbox/CheckboxGroup.tsx +3 -3
  19. package/src/components/Chip/Chip.tsx +12 -19
  20. package/src/components/Dropdown/BareDropdown.tsx +14 -8
  21. package/src/components/Dropdown/Dropdown.icons.tsx +33 -0
  22. package/src/components/Dropdown/Dropdown.module.css +117 -15
  23. package/src/components/Dropdown/Dropdown.stories.tsx +21 -0
  24. package/src/components/Dropdown/Dropdown.tsx +102 -17
  25. package/src/components/Dropdown/USAGE.md +7 -0
  26. package/src/components/FileInput/FileInput.tsx +1 -1
  27. package/src/components/FileUpload/FileUpload.tsx +1 -1
  28. package/src/components/FormControlLayout/FormControlLayout.tsx +1 -1
  29. package/src/components/FormControlWrapper/FormControlWrapper.tsx +1 -1
  30. package/src/components/HoverCard/HoverCard.module.css +25 -3
  31. package/src/components/HoverCard/HoverCard.tsx +13 -21
  32. package/src/components/Menu/Menu.module.css +3 -0
  33. package/src/components/Menu/Menu.stories.tsx +128 -5
  34. package/src/components/Menu/Menu.tsx +15 -7
  35. package/src/components/Modal/Modal.tsx +12 -6
  36. package/src/components/NumberInput/NumberInput.tsx +30 -15
  37. package/src/components/Pagination/Pagination.tsx +5 -15
  38. package/src/components/Panel/Panel.tsx +17 -25
  39. package/src/components/Popover/Popover.tsx +10 -18
  40. package/src/components/Radio/Radio.tsx +21 -26
  41. package/src/components/Radio/RadioGroup.tsx +3 -4
  42. package/src/components/ReadOnlyField/ReadOnlyTextField.tsx +1 -1
  43. package/src/components/SegmentedControl/SegmentedControl.tsx +20 -25
  44. package/src/components/Slider/Slider.tsx +40 -43
  45. package/src/components/Stepper/Stepper.tsx +34 -14
  46. package/src/components/Switch/Switch.tsx +33 -49
  47. package/src/components/Switch/SwitchGroup.tsx +3 -3
  48. package/src/components/Tabs/Tabs.tsx +12 -4
  49. package/src/components/TextArea/TextArea.tsx +30 -7
  50. package/src/components/TextField/TextField.tsx +30 -26
  51. package/src/components/Timeline/Timeline.tsx +9 -1
  52. package/src/components/Timeline/TimelineItem.tsx +2 -2
  53. package/src/components/Toast/Toast.tsx +14 -23
  54. package/src/components/Tooltip/Tooltip.tsx +14 -27
  55. package/src/components/TransferList/TransferList.tsx +1 -1
  56. package/src/utils/filterStylingProps.ts +4 -0
@@ -12,6 +12,7 @@ import type { Meta, StoryObj } from "@storybook/react";
12
12
  import { Menu, MenuItem, MenuDivider } from "./Menu";
13
13
  import { Button } from "../Button";
14
14
  import { ListSubheader } from "@mui/material";
15
+ import styles from "./Menu.module.css";
15
16
 
16
17
  const SettingsIcon = (props: React.ComponentProps<"svg">) => (
17
18
  <svg
@@ -123,6 +124,56 @@ const ArrowsIcon = (props: React.ComponentProps<"svg">) => (
123
124
  </svg>
124
125
  );
125
126
 
127
+ const ChevronRightIcon = (props: React.ComponentProps<"svg">) => (
128
+ <svg
129
+ xmlns="http://www.w3.org/2000/svg"
130
+ width="14"
131
+ height="14"
132
+ viewBox="0 0 24 24"
133
+ fill="none"
134
+ stroke="currentColor"
135
+ strokeWidth="2"
136
+ strokeLinecap="round"
137
+ strokeLinejoin="round"
138
+ {...props}
139
+ >
140
+ <polyline points="9 18 15 12 9 6" />
141
+ </svg>
142
+ );
143
+
144
+ // mui-adapter's Menu is monolithic (see IMPLEMENTATION_NOTES.md) — there is no
145
+ // Menu.Sub composable, so a submenu is just a MenuItem that anchors its own
146
+ // nested Menu, mirroring how the top-level trigger manages its own anchorEl.
147
+ interface SubMenuTriggerProps {
148
+ label: string;
149
+ children: React.ReactNode;
150
+ }
151
+
152
+ const SubMenuTrigger = ({ label, children }: SubMenuTriggerProps) => {
153
+ const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
154
+
155
+ return (
156
+ <>
157
+ <MenuItem onClick={(event) => setAnchorEl(event.currentTarget)}>
158
+ {label}
159
+ <ChevronRightIcon
160
+ className={styles.chevron}
161
+ style={{ marginLeft: "auto", paddingLeft: 8 }}
162
+ />
163
+ </MenuItem>
164
+ <Menu
165
+ anchorEl={anchorEl}
166
+ open={Boolean(anchorEl)}
167
+ onClose={() => setAnchorEl(null)}
168
+ anchorOrigin={{ vertical: "top", horizontal: "right" }}
169
+ transformOrigin={{ vertical: "top", horizontal: "left" }}
170
+ >
171
+ {children}
172
+ </Menu>
173
+ </>
174
+ );
175
+ };
176
+
126
177
  type MenuStoryArgs = Record<string, unknown>;
127
178
 
128
179
  const meta: Meta = {
@@ -225,9 +276,84 @@ export const WithDisabledItems: Story = {
225
276
  },
226
277
  };
227
278
 
228
- export const HoverTrigger: Story = {
279
+ export const WithSubmenus: Story = {
229
280
  render: (args) => (
230
281
  <InteractiveMenu {...args}>
282
+ <MenuItem>Dashboard</MenuItem>
283
+ <SubMenuTrigger label="Products">
284
+ <MenuItem>All products</MenuItem>
285
+ <MenuItem>Categories</MenuItem>
286
+ <MenuItem>Tags</MenuItem>
287
+ </SubMenuTrigger>
288
+ <SubMenuTrigger label="Orders">
289
+ <MenuItem>Open</MenuItem>
290
+ <MenuItem>Completed</MenuItem>
291
+ <MenuItem>Cancelled</MenuItem>
292
+ </SubMenuTrigger>
293
+ </InteractiveMenu>
294
+ ),
295
+ args: {
296
+ opened: true,
297
+ // Match mantine's explicit `width: 200` for this story so the row has room for the
298
+ // "Products"/"Orders" label plus the submenu chevron without clipping it.
299
+ slotProps: { paper: { style: { minWidth: 200 } } },
300
+ },
301
+ };
302
+
303
+ // mui-adapter's Menu has no native hover-trigger support (unlike Mantine's `trigger` prop),
304
+ // so this story implements open-on-hover itself: hovering the target opens the menu, and a
305
+ // short close delay (mirroring Mantine's `closeDelay`) keeps it open while the pointer moves
306
+ // from the target into the dropdown, since MUI's Menu renders in a portal outside the
307
+ // wrapper's DOM subtree.
308
+ const HoverMenu = ({ children, ...args }: InteractiveMenuProps) => {
309
+ const [open, setOpen] = useState(false);
310
+ const buttonRef = useRef<HTMLButtonElement>(null);
311
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
312
+ undefined,
313
+ );
314
+
315
+ const cancelClose = () => {
316
+ if (closeTimer.current) clearTimeout(closeTimer.current);
317
+ };
318
+ const scheduleClose = () => {
319
+ closeTimer.current = setTimeout(() => setOpen(false), 300);
320
+ };
321
+
322
+ return (
323
+ <div>
324
+ <Button
325
+ ref={buttonRef}
326
+ variant="solid"
327
+ onClick={() => setOpen((prev) => !prev)}
328
+ onMouseEnter={() => {
329
+ cancelClose();
330
+ setOpen(true);
331
+ }}
332
+ onMouseLeave={scheduleClose}
333
+ >
334
+ Hover or Click
335
+ </Button>
336
+ <Menu
337
+ {...args}
338
+ anchorEl={buttonRef.current}
339
+ open={open}
340
+ onClose={() => setOpen(false)}
341
+ slotProps={{
342
+ paper: {
343
+ onMouseEnter: cancelClose,
344
+ onMouseLeave: scheduleClose,
345
+ },
346
+ }}
347
+ >
348
+ {children}
349
+ </Menu>
350
+ </div>
351
+ );
352
+ };
353
+
354
+ export const HoverTrigger: Story = {
355
+ render: (args) => (
356
+ <HoverMenu {...args}>
231
357
  <MenuItem>
232
358
  <SettingsIcon style={{ marginRight: 8 }} /> Settings
233
359
  </MenuItem>
@@ -237,9 +363,6 @@ export const HoverTrigger: Story = {
237
363
  <MenuItem>
238
364
  <ImageIcon style={{ marginRight: 8 }} /> Gallery
239
365
  </MenuItem>
240
- </InteractiveMenu>
366
+ </HoverMenu>
241
367
  ),
242
- args: {
243
- opened: true,
244
- },
245
368
  };
@@ -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
- className={`${styles.dropdown} ${className || ""}`}
31
- classes={{
32
- paper: styles.dropdown,
33
- list: styles.dropdown,
34
- }}
41
+ className={className}
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