@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
@@ -59,20 +59,28 @@
59
59
  box-sizing: border-box;
60
60
  margin: 0;
61
61
 
62
- /* Box Geometry */
63
- min-height: var(--recursica_ui-kit_components_dropdown_properties_min-height);
62
+ /* Box Geometry
63
+ `!important` throughout: MUI's own `.MuiSelect-select`/`.MuiOutlinedInput-input` styled rules
64
+ (e.g. `minHeight: 1.4375em`, and outlined-variant default padding) are equal-specificity
65
+ single-class selectors injected by emotion at runtime, after this CSS module's own stylesheet
66
+ — on that tie they win by source order, which is what silently shrank the control below the
67
+ min-height token (measured 42.8px vs Mantine's 48px) even though the token was already wired
68
+ here. Same root cause as the notchedOutline double-border fix below. */
69
+ min-height: var(
70
+ --recursica_ui-kit_components_dropdown_properties_min-height
71
+ ) !important;
64
72
  padding-top: var(
65
73
  --recursica_ui-kit_components_dropdown_properties_vertical-padding
66
- );
74
+ ) !important;
67
75
  padding-bottom: var(
68
76
  --recursica_ui-kit_components_dropdown_properties_vertical-padding
69
- );
77
+ ) !important;
70
78
  padding-left: var(
71
79
  --recursica_ui-kit_components_dropdown_properties_horizontal-padding
72
- );
80
+ ) !important;
73
81
  padding-right: var(
74
82
  --recursica_ui-kit_components_dropdown_properties_horizontal-padding
75
- );
83
+ ) !important;
76
84
  border-radius: var(
77
85
  --recursica_ui-kit_components_dropdown_properties_border-radius
78
86
  );
@@ -125,23 +133,47 @@
125
133
  color: inherit;
126
134
  }
127
135
 
136
+ /* MUI's outlined variant renders its own `<fieldset class="MuiOutlinedInput-notchedOutline">` as a
137
+ separate absolutely-positioned sibling of `.input` — independent of the border we already draw
138
+ on `.input` above. Left alone, it shows its own default/focused/error border (theme grays/blue/
139
+ red) UNDERNEATH ours, which is what produced the double border (bug: static-error story showing
140
+ a leftover dark border under the red one) and MUI's own blue focus ring leaking in alongside the
141
+ Recursica focus box-shadow. We own the border entirely via `.input`, so this native outline is
142
+ never needed — always neutralize it. */
143
+ .root :global(.MuiOutlinedInput-notchedOutline) {
144
+ border: none !important;
145
+ }
146
+
128
147
  /* Dynamic Padding Overrides */
129
148
  .root[data-with-left-section] .input {
130
- padding-left: var(--input-left-section-size);
149
+ padding-left: var(--input-left-section-size) !important;
131
150
  }
132
151
 
133
152
  .root[data-with-right-section] .input {
134
- padding-right: var(--input-right-section-size);
153
+ padding-right: var(--input-right-section-size) !important;
135
154
  }
136
155
 
137
- /* Flexible End-Caps (Icons, actions) */
156
+ /* Flexible End-Caps (Icons, actions)
157
+ Absolutely positioned within `.root` (already `position: relative`), NOT flex siblings of
158
+ `.input` in the row. MUI passes `startAdornment`/`endAdornment` as ordinary flex children next
159
+ to the select div, so as siblings they'd add their own width on top of `.input`'s already-100%-
160
+ width box instead of sharing it — the reserved padding above only carves out space *inside*
161
+ `.input`'s own bordered box, so a sibling rendered outside that box overflows past the visible
162
+ border (bug: leading icon / the clear+chevron pair sat outside the control's right edge).
163
+ Absolute positioning is Mantine's own technique for the equivalent `Input.Section` slot, which
164
+ is why the mantine-adapter never had this bug. */
138
165
  .section {
166
+ position: absolute;
167
+ top: 0;
168
+ bottom: 0;
139
169
  display: flex;
140
170
  align-items: center;
141
- height: 100%;
171
+ gap: var(--recursica_ui-kit_components_dropdown_properties_icon-text-gap);
172
+ pointer-events: none;
142
173
  }
143
174
 
144
175
  .section[data-position="left"] {
176
+ left: 0;
145
177
  justify-content: flex-start;
146
178
  padding-left: var(
147
179
  --recursica_ui-kit_components_dropdown_properties_horizontal-padding
@@ -149,10 +181,14 @@
149
181
  }
150
182
 
151
183
  .section[data-position="right"] {
184
+ right: 0;
152
185
  justify-content: flex-end;
153
186
  padding-right: var(
154
187
  --recursica_ui-kit_components_dropdown_properties_horizontal-padding
155
188
  );
189
+ /* Unlike the left section (decorative icon only), this one can hold the interactive clear
190
+ button, so it needs to opt back into pointer events itself. */
191
+ pointer-events: auto;
156
192
  }
157
193
 
158
194
  .section :global(svg) {
@@ -169,6 +205,42 @@
169
205
  );
170
206
  }
171
207
 
208
+ /* Clear ("x") button rendered inside the right section when `clearable` and a value is present.
209
+ Sized/colored via the same icon tokens as the rest of the right section (rather than any
210
+ default browser/library button chrome), same treatment as the mantine-adapter's equivalent
211
+ fix to its native clear button (see DROPDOWN_IMPLEMENTATION_NOTES.md). */
212
+ .clearButton {
213
+ display: inline-flex;
214
+ align-items: center;
215
+ justify-content: center;
216
+ flex-shrink: 0;
217
+ width: var(--recursica_ui-kit_components_dropdown_properties_icon-size);
218
+ height: var(--recursica_ui-kit_components_dropdown_properties_icon-size);
219
+ padding: 0;
220
+ border: none;
221
+ border-radius: 50%;
222
+ background: transparent;
223
+ color: var(
224
+ --recursica_ui-kit_components_dropdown_properties_colors_trailing-icon
225
+ );
226
+ cursor: pointer;
227
+ outline: none;
228
+ }
229
+
230
+ .clearButton svg {
231
+ width: 100%;
232
+ height: 100%;
233
+ }
234
+
235
+ .clearButton:focus-visible {
236
+ box-shadow:
237
+ 0 0 0 var(--recursica_brand_states_focus_border-size)
238
+ var(--recursica_brand_states_focus_color),
239
+ 0 0 var(--recursica_brand_states_focus_blur)
240
+ var(--recursica_brand_states_focus_margin)
241
+ var(--recursica_brand_states_focus_color);
242
+ }
243
+
172
244
  /* -------------------------------------
173
245
  STATE CASCADE ARCHITECTURE
174
246
  -------------------------------------- */
@@ -256,6 +328,21 @@
256
328
  overflow: hidden;
257
329
  }
258
330
 
331
+ /* The visible top/bottom gap inside the open menu, before/after the option list, is MUI's own
332
+ `.MuiList-padding` default (8px) on the inner `<ul>` — not driven by any Recursica token. Wire
333
+ it to the same dropdown vertical-padding token the closed control itself uses, matching the
334
+ mantine-adapter fix (its `.dropdown` had the same gap, driven by Mantine's own untokenized
335
+ 4px `--combobox-padding` default). `!important` needed to beat MUI's own emotion-generated
336
+ `.MuiList-padding` rule at equal selector specificity. */
337
+ .dropdown :global(.MuiMenu-list) {
338
+ padding-top: var(
339
+ --recursica_ui-kit_components_dropdown_properties_vertical-padding
340
+ ) !important;
341
+ padding-bottom: var(
342
+ --recursica_ui-kit_components_dropdown_properties_vertical-padding
343
+ ) !important;
344
+ }
345
+
259
346
  .option {
260
347
  font-family: var(
261
348
  --recursica_ui-kit_components_dropdown_properties_text_font-family
@@ -277,10 +364,11 @@
277
364
  cursor: pointer;
278
365
  }
279
366
 
280
- /* No per-option selected/hovered tokens exist in the schema; use the generic overlay tint
281
- (same technique as the Mantine reference implementation) to highlight the active option. */
282
- .option[data-selected="true"],
283
- .option[data-combobox-selected="true"],
367
+ /* No per-option hovered token exists in the schema; use the generic overlay tint (same technique
368
+ as the Mantine reference implementation) to highlight a hovered-but-not-selected option.
369
+ `!important` is required here: MUI's own `.Mui-selected` (and `.Mui-selected:hover`) classes
370
+ carry their own default primary-blue tint at equal selector specificity, and without it that
371
+ default tint wins over this token-driven one. */
284
372
  .option[data-hovered="true"],
285
373
  .option:hover {
286
374
  background-color: color-mix(
@@ -288,5 +376,19 @@
288
376
  var(--recursica_brand_states_hover_color)
289
377
  calc(var(--recursica_brand_states_hover_opacity) * 100%),
290
378
  transparent
291
- );
379
+ ) !important;
380
+ }
381
+
382
+ /* No dedicated dropdown-option "selected" token exists in the schema either, but the menu-item
383
+ component's selected-state colors are the closest real token family for the same concept (a
384
+ selected row in a list) — reused here rather than the neutral hover tint above, which is what
385
+ previously made the selected option read as grey instead of Recursica's brand color. */
386
+ .option[data-selected="true"],
387
+ .option[data-combobox-selected="true"] {
388
+ background-color: var(
389
+ --recursica_ui-kit_components_menu-item_variants_selection-states_selected_properties_colors_background-color
390
+ ) !important;
391
+ color: var(
392
+ --recursica_ui-kit_components_menu-item_variants_selection-states_selected_properties_colors_text-color
393
+ ) !important;
292
394
  }
@@ -65,6 +65,27 @@ export const SearchableClearable: Story = {
65
65
  },
66
66
  };
67
67
 
68
+ export const WithLeadingIcon: Story = {
69
+ args: {
70
+ label: "Destination",
71
+ startAdornment: (
72
+ <svg
73
+ width="24"
74
+ height="24"
75
+ viewBox="0 0 24 24"
76
+ fill="none"
77
+ stroke="currentColor"
78
+ strokeWidth="2"
79
+ strokeLinecap="round"
80
+ strokeLinejoin="round"
81
+ >
82
+ <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
83
+ <circle cx="12" cy="10" r="3"></circle>
84
+ </svg>
85
+ ),
86
+ },
87
+ };
88
+
68
89
  export const StaticError: Story = {
69
90
  args: {
70
91
  error: "You must choose a valid destination.",
@@ -1,20 +1,30 @@
1
- import React, { forwardRef, ReactNode } from "react";
1
+ import React, { forwardRef, ReactNode, useEffect, useState } from "react";
2
2
  import {
3
3
  Select as MuiSelect,
4
4
  SelectProps as MuiSelectProps,
5
+ SelectChangeEvent,
5
6
  MenuItem,
6
7
  } from "@mui/material";
7
8
  import { type ReadOnlyControlProps } from "@recursica/adapter-common";
8
9
  import {
9
10
  filterStylingProps,
11
+ omitUnsupportedProps,
10
12
  type RecursicaOverStyled,
11
13
  } from "../../utils/filterStylingProps";
12
14
  import { type RecursicaFormControlWrapperProps } from "../FormControlWrapper/FormControlWrapper";
13
15
  import { WithReadOnlyWrapper } from "../ReadOnlyField/WithReadOnlyWrapper";
16
+ import { ChevronIcon, ClearIcon } from "./Dropdown.icons";
14
17
  import styles from "./Dropdown.module.css";
15
18
 
16
19
  import { type RecursicaDropdownProps as BaseRecursicaDropdownProps } from "@recursica/adapter-common";
17
20
 
21
+ // Swapped in for MUI's default `IconComponent` only when we're rendering our own combined
22
+ // clear+chevron `endAdornment` (see `showClear` below) — a stable reference so it isn't recreated
23
+ // every render.
24
+ function HiddenIcon() {
25
+ return null;
26
+ }
27
+
18
28
  export interface RecursicaDropdownProps
19
29
  extends Omit<
20
30
  MuiSelectProps,
@@ -27,9 +37,13 @@ export interface RecursicaDropdownProps
27
37
  | "ref"
28
38
  | "error"
29
39
  >,
40
+ // `onChange` is swept up by the `keyof React.HTMLAttributes<HTMLDivElement>` omission above
41
+ // (it's a standard DOM attribute name too) even though MUI's own `Select#onChange` has a
42
+ // completely different, non-DOM signature — add it back explicitly with MUI's real signature.
43
+ Pick<MuiSelectProps, "onChange">,
30
44
  Omit<
31
45
  RecursicaFormControlWrapperProps,
32
- "controlMaxWidth" | "controlMinWidth"
46
+ "controlMaxWidth" | "controlMinWidth" | "onChange"
33
47
  >,
34
48
  ReadOnlyControlProps,
35
49
  BaseRecursicaDropdownProps {}
@@ -66,19 +80,67 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
66
80
  emptyValueComponent,
67
81
  value,
68
82
  defaultValue,
83
+ onChange,
69
84
  data,
85
+ startAdornment,
70
86
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
71
87
  searchable, // Not natively supported by basic MUI Select, stubbed
72
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
73
- clearable, // Not natively supported by basic MUI Select, stubbed
88
+ clearable,
74
89
  ...rest
75
90
  } = props;
76
- const sanitizedProps = filterStylingProps(rest, overStyled);
91
+ // Props this component intentionally doesn't support — deleted at runtime so they can't leak
92
+ // through even if a caller forces them via plain JavaScript, bypassing the Omit<> above.
93
+ const UNSUPPORTED_PROPS = [
94
+ "size", // Recursica controls sizing via design tokens, not MUI's native small/medium size
95
+ "variant", // Recursica styles the naked select directly; MUI's standard/filled/outlined unused
96
+ ] as const satisfies readonly (keyof MuiSelectProps)[];
97
+
98
+ const sanitizedProps = omitUnsupportedProps(
99
+ filterStylingProps(rest, overStyled),
100
+ UNSUPPORTED_PROPS,
101
+ );
77
102
  const restRecord = sanitizedProps as Record<string, unknown>;
78
103
 
79
- // Delete prohibited sizing hooks
80
- delete restRecord["size"];
81
- delete restRecord["variant"];
104
+ // MUI's basic `Select` has no built-in "clearable" concept (unlike Mantine's `Select`, which
105
+ // manages its own internal value so it can reset itself to `null` on demand) — the clear
106
+ // button needs somewhere to write an empty value to, so the value is lifted here rather than
107
+ // left for MUI's own internal uncontrolled state.
108
+ const [internalValue, setInternalValue] = useState<unknown>(
109
+ () => value ?? defaultValue ?? "",
110
+ );
111
+
112
+ useEffect(() => {
113
+ if (value !== undefined) setInternalValue(value);
114
+ }, [value]);
115
+
116
+ const handleChange = (
117
+ event: SelectChangeEvent<unknown>,
118
+ child: ReactNode,
119
+ ) => {
120
+ setInternalValue(event.target.value);
121
+ (
122
+ onChange as
123
+ | ((event: SelectChangeEvent<unknown>, child: ReactNode) => void)
124
+ | undefined
125
+ )?.(event, child);
126
+ };
127
+
128
+ const hasValue =
129
+ internalValue !== "" &&
130
+ internalValue !== null &&
131
+ internalValue !== undefined;
132
+ const showClear = !!clearable && hasValue && !disabled && !readOnly;
133
+
134
+ const handleClear = (event: React.MouseEvent) => {
135
+ event.preventDefault();
136
+ event.stopPropagation();
137
+ setInternalValue("");
138
+ (
139
+ onChange as
140
+ | ((event: SelectChangeEvent<unknown>, child: ReactNode) => void)
141
+ | undefined
142
+ )?.({ target: { value: "" } } as SelectChangeEvent<unknown>, null);
143
+ };
82
144
 
83
145
  const injectedStyles = {
84
146
  ...((style as React.CSSProperties) || {}),
@@ -98,12 +160,11 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
98
160
  key={`${item}-${index}`}
99
161
  value={item}
100
162
  className={styles.option}
163
+ disableRipple
101
164
  // Dropdown.module.css's own selected-state tint (`.option[data-selected="true"]`)
102
165
  // needs this explicitly — MUI's own `Mui-selected` class carries its default primary-
103
166
  // color tint instead, which is what shows through without it.
104
- data-selected={
105
- item === (value ?? defaultValue) ? "true" : undefined
106
- }
167
+ data-selected={item === internalValue ? "true" : undefined}
107
168
  >
108
169
  {item}
109
170
  </MenuItem>
@@ -115,9 +176,8 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
115
176
  value={item.value}
116
177
  disabled={item.disabled}
117
178
  className={styles.option}
118
- data-selected={
119
- item.value === (value ?? defaultValue) ? "true" : undefined
120
- }
179
+ disableRipple
180
+ data-selected={item.value === internalValue ? "true" : undefined}
121
181
  >
122
182
  {item.label}
123
183
  </MenuItem>
@@ -125,6 +185,26 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
125
185
  });
126
186
  };
127
187
 
188
+ const wrappedStartAdornment = startAdornment ? (
189
+ <span className={styles.section} data-position="left">
190
+ {startAdornment}
191
+ </span>
192
+ ) : undefined;
193
+
194
+ const wrappedEndAdornment = showClear ? (
195
+ <span className={styles.section} data-position="right">
196
+ <button
197
+ type="button"
198
+ className={styles.clearButton}
199
+ aria-label="Clear"
200
+ onClick={handleClear}
201
+ >
202
+ <ClearIcon />
203
+ </button>
204
+ <ChevronIcon />
205
+ </span>
206
+ ) : undefined;
207
+
128
208
  return (
129
209
  <WithReadOnlyWrapper
130
210
  className={wrapperClass}
@@ -159,9 +239,10 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
159
239
  activeComponent={
160
240
  <MuiSelect
161
241
  ref={ref}
242
+ {...(sanitizedProps as unknown as MuiSelectProps)}
162
243
  disabled={disabled}
163
- value={value}
164
- defaultValue={defaultValue}
244
+ value={internalValue}
245
+ onChange={handleChange}
165
246
  error={!!error}
166
247
  required={required}
167
248
  displayEmpty
@@ -170,6 +251,9 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
170
251
  select: styles.input,
171
252
  icon: styles.icon,
172
253
  }}
254
+ IconComponent={showClear ? HiddenIcon : undefined}
255
+ startAdornment={wrappedStartAdornment}
256
+ endAdornment={wrappedEndAdornment}
173
257
  MenuProps={{
174
258
  classes: { paper: styles.dropdown },
175
259
  }}
@@ -180,12 +264,13 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
180
264
  // inputProps, which meant the error border never actually appeared on the Dropdown.)
181
265
  data-disabled={disabled ? "true" : undefined}
182
266
  data-error={error ? "true" : undefined}
267
+ data-with-left-section={startAdornment ? "true" : undefined}
268
+ data-with-right-section={showClear ? "true" : undefined}
183
269
  inputProps={{
184
270
  "data-disabled": disabled ? "true" : undefined,
185
271
  "data-error": error ? "true" : undefined,
186
272
  ...(restRecord.inputProps as Record<string, unknown>),
187
273
  }}
188
- {...(sanitizedProps as unknown as MuiSelectProps)}
189
274
  >
190
275
  {renderOptions()}
191
276
  </MuiSelect>
@@ -39,3 +39,10 @@ All Recursica components in the `@recursica/mui-adapter` package adhere strictly
39
39
  > - **Anti-override protection**: Rogues style injections (like inline `style` or arbitrary `className`) are automatically blocked by our prop layer unless `overStyled={true}` is explicitly provided.
40
40
  > - **No Direct Layers**: Do not pass a `layer` prop to this component. To place it on a specific visual layer, wrap it in a `<Layer layer={0|1|2|3}>` component natively.
41
41
  > - **Variables and Theming**: Styling is entirely determined by local CSS variables defined in `recursica_variables_scoped.css` and mapped in the component's CSS module.
42
+
43
+ ---
44
+
45
+ ## 4. Notes
46
+
47
+ - Pass `startAdornment` for a leading icon, and `clearable` (with a value present) to show a clear button — both render using the dropdown's own icon-color tokens, matching the mantine-adapter's `leftSection`/`clearable` behavior.
48
+ - `onChange` follows MUI's native `Select` signature: `(event: SelectChangeEvent, child: ReactNode) => void`.
@@ -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}
@@ -5,7 +5,13 @@
5
5
  and positioning (-arrowSize/2) calculations that cannot be CSS-driven. The default matches
6
6
  the Recursica beak-size token (16px). See COMPONENT_ISSUES.md for details.
7
7
  - All structural layout (display, position, overflow) is deferred to Mantine's native
8
- behavior. We only override visual design tokens (colors, typography, spacing, borders). */
8
+ behavior. We only override visual design tokens (colors, typography, spacing, borders).
9
+ - margin: 0 !important on .dropdown. Mui's Tooltip hardcodes a per-placement margin
10
+ (14px, reset to 0 only when arrow is enabled) on top of whatever offset the popper
11
+ "offset" modifier applies, so with withBeak=false the trigger-to-dropdown gap was
12
+ offset + 14px instead of just offset. There is no Recursica token for this spacing —
13
+ the gap is driven purely by the `offset` prop (a plain number, same as Mantine's
14
+ HoverCard), so we zero out Mui's extra margin to make offset the only source of gap. */
9
15
 
10
16
  /* ======================================
11
17
  DROPDOWN CONTAINER
@@ -72,14 +78,30 @@
72
78
  color: var(
73
79
  --recursica_ui-kit_components_hover-card-popover_properties_colors_content
74
80
  );
81
+
82
+ margin: 0 !important; /* HARDCODE: cancel Mui's built-in per-placement margin, see note above */
75
83
  }
76
84
 
77
85
  /* ======================================
78
86
  ARROW / BEAK
79
87
  ====================================== */
80
88
 
89
+ /* Mui's arrow is a rotated square (via ::before, background-color: currentColor)
90
+ sized in em units off font-size, not a bordered CSS triangle. `color` (not
91
+ border-color) is what actually fills it, which is why setting border-color here
92
+ previously had no visible effect and left it defaulting to the inherited text
93
+ color (solid black). Fill it with the same background-color token the card body
94
+ uses so it blends into the card exactly like Mantine's beak, and size it off the
95
+ beak-size token instead of Mui's default 1em/0.71em (font-size-relative) box. */
81
96
  .arrow {
82
- border-color: var(
83
- --recursica_ui-kit_components_hover-card-popover_properties_colors_border-color
97
+ color: var(
98
+ --recursica_ui-kit_components_hover-card-popover_properties_colors_background-color
99
+ );
100
+ width: var(
101
+ --recursica_ui-kit_components_hover-card-popover_properties_beak-size
84
102
  );
103
+ height: calc(
104
+ var(--recursica_ui-kit_components_hover-card-popover_properties_beak-size) *
105
+ 0.7071
106
+ ); /* = width / sqrt(2), same ratio Mui's default arrow uses */
85
107
  }
@@ -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;
@@ -271,4 +271,7 @@
271
271
  position: relative;
272
272
  z-index: 1;
273
273
  color: inherit;
274
+ /* HARDCODE: prevent the flex row from shrinking the chevron below its intrinsic 14px size
275
+ when the row is tight, which otherwise reads as the submenu indicator being clipped. */
276
+ flex-shrink: 0;
274
277
  }