@recursica/mui-adapter 0.33.0 → 0.34.1

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.
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.33.0",
16
+ "version": "0.34.1",
17
17
  "publishConfig": {
18
18
  "access": "public"
19
19
  },
@@ -59,7 +59,9 @@
59
59
  "lint": "eslint .",
60
60
  "storybook": "storybook dev -p 6012",
61
61
  "analyze-tokens": "analyze-tokens --css @recursica/official-release/recursica_variables_scoped.css --dir src/components --output token-analysis.json",
62
- "prebuild": "npm run analyze-tokens"
62
+ "prebuild": "npm run analyze-tokens",
63
+ "adapter-tester": "adapter-tester --serve",
64
+ "adapter-tester:automated": "adapter-tester"
63
65
  },
64
66
  "devDependencies": {
65
67
  "@chromatic-com/storybook": "^5.1.1",
@@ -67,6 +69,8 @@
67
69
  "@mui/lab": "^7.0.1-beta.25",
68
70
  "@mui/x-date-pickers": "^9.11.0",
69
71
  "@mui/x-tree-view": "^9.11.0",
72
+ "@playwright/test": "^1.53.0",
73
+ "@recursica/adapter-tester": "*",
70
74
  "@recursica/recursica-postcss-vars": "*",
71
75
  "@recursica/storybook-template": "*",
72
76
  "@recursica/token-analyzer": "*",
@@ -102,7 +106,7 @@
102
106
  "vitest": "^3.2.4"
103
107
  },
104
108
  "dependencies": {
105
- "@recursica/adapter-common": "^0.24.0",
109
+ "@recursica/adapter-common": "^0.25.0",
106
110
  "@recursica/official-release": "^2.8.0",
107
111
  "dayjs": "^1.11.21"
108
112
  },
@@ -120,6 +120,31 @@ export const ErrorState: Story = {
120
120
  },
121
121
  };
122
122
 
123
+ export const OpenedCalendar: Story = {
124
+ args: {
125
+ label: "Meeting Date",
126
+ assistiveText: "Calendar rendered open by default for styling review.",
127
+ // MUI X's DatePicker supports a controlled `open` prop directly; pairing it with a
128
+ // no-op `onClose` keeps the calendar open with no click interaction needed — same
129
+ // intent as the mantine-adapter's `OpenedCalendar` story.
130
+ open: true,
131
+ onClose: () => {},
132
+ // Fixed (not computed) so the selected-day fill is visible on load, alongside the
133
+ // today marker, for styling review. Local-component constructor, not an ISO date
134
+ // string — `new Date("2026-08-26")` parses as UTC midnight, which renders as the
135
+ // 25th in any timezone behind UTC.
136
+ defaultValue: new Date(2026, 7, 26),
137
+ },
138
+ parameters: {
139
+ docs: {
140
+ description: {
141
+ story:
142
+ "The calendar dropdown renders open by default so its styling can be reviewed without a click interaction.",
143
+ },
144
+ },
145
+ },
146
+ };
147
+
123
148
  export const StaticReadOnly: Story = {
124
149
  args: {
125
150
  label: "Static ReadOnly Review",
@@ -2,3 +2,4 @@
2
2
 
3
3
  - **Compositional API Dropped:** Mantine uses `<Menu.Target>`, `<Menu.Dropdown>`, `<Menu.Item>`, etc., and manages state natively via React context within `<Menu>`. MUI's API is fully monolithic.
4
4
  - **Monolithic API Adopted:** Following architectural review, we have abandoned the fabricated context wrappers for `mui-adapter`. We now natively export `Menu`, `MenuItem`, and `MenuDivider` wrapping their `@mui/material` counterparts. Developers are expected to manage `anchorEl` state themselves, just like native MUI. Storybook tests have been updated to simulate this open state so visual regressions still cover the dropdown menu visually.
5
+ - **`maxHeight` Override:** `<Menu maxHeight={...}>` overrides the token-driven dropdown max-height with an explicit pixel (or other CSS length) value — the one deliberate exception to "no inline design tokens in TSX" (see `COMPONENT_DEV_GUIDE.md`). Implemented by merging `maxHeight` into `slotProps.paper.style`, alongside any caller-supplied `slotProps`, rather than a CSS module change; the CSS module's token-driven `max-height` stays untouched when the prop isn't passed.
@@ -300,6 +300,43 @@ export const WithSubmenus: Story = {
300
300
  },
301
301
  };
302
302
 
303
+ export const WithMaxHeight: Story = {
304
+ render: (args) => (
305
+ <InteractiveMenu {...args}>
306
+ <MenuItem>
307
+ <SettingsIcon style={{ marginRight: 8 }} /> Settings
308
+ </MenuItem>
309
+ <MenuItem>
310
+ <MessageIcon style={{ marginRight: 8 }} /> Messages
311
+ </MenuItem>
312
+ <MenuItem>
313
+ <ImageIcon style={{ marginRight: 8 }} /> Gallery
314
+ </MenuItem>
315
+ <MenuItem>
316
+ <SearchIcon style={{ marginRight: 8 }} /> Search
317
+ </MenuItem>
318
+ <MenuItem>
319
+ <ArrowsIcon style={{ marginRight: 8 }} /> Transfer my data
320
+ </MenuItem>
321
+ <MenuItem>
322
+ <TrashIcon style={{ marginRight: 8 }} /> Delete my account
323
+ </MenuItem>
324
+ </InteractiveMenu>
325
+ ),
326
+ args: {
327
+ opened: true,
328
+ maxHeight: 160,
329
+ },
330
+ parameters: {
331
+ docs: {
332
+ description: {
333
+ story:
334
+ "`maxHeight` overrides the token-driven dropdown max-height with an explicit pixel value, scrolling the item list once it's exceeded.",
335
+ },
336
+ },
337
+ },
338
+ };
339
+
303
340
  // mui-adapter's Menu has no native hover-trigger support (unlike Mantine's `trigger` prop),
304
341
  // so this story implements open-on-hover itself: hovering the target opens the menu, and a
305
342
  // short close delay (mirroring Mantine's `closeDelay`) keeps it open while the pointer moves
@@ -1,4 +1,4 @@
1
- import { forwardRef } from "react";
1
+ import { forwardRef, type CSSProperties } from "react";
2
2
  import {
3
3
  Menu as MuiMenu,
4
4
  type MenuProps as MuiMenuProps,
@@ -19,27 +19,47 @@ import { type RecursicaMenuProps } from "@recursica/adapter-common";
19
19
  export type MenuProps = RecursicaOverStyled<MuiMenuProps & RecursicaMenuProps>;
20
20
 
21
21
  export const Menu = forwardRef<HTMLDivElement, MenuProps>(function Menu(
22
- { overStyled = false, className, ...rest },
22
+ { overStyled = false, className, maxHeight, ...rest },
23
23
  ref,
24
24
  ) {
25
25
  const sanitizedProps = filterStylingProps(rest, overStyled);
26
+ const restRecord = sanitizedProps as Record<string, unknown>;
26
27
 
27
28
  const mergedClassNames = mergeClassNames(
28
29
  {
29
30
  paper: styles.dropdown,
30
31
  list: styles.dropdown,
31
32
  },
32
- (sanitizedProps as Record<string, unknown>).classes as
33
- | Partial<Record<string, string>>
34
- | undefined,
33
+ restRecord.classes as Partial<Record<string, string>> | undefined,
35
34
  );
36
35
 
36
+ // `maxHeight` is a caller-supplied override of the token-driven dropdown max-height, applied
37
+ // to the Paper slot's inline style — an explicit per-instance escape hatch, not a design token.
38
+ const callerSlotProps = restRecord.slotProps as
39
+ | { paper?: Record<string, unknown> }
40
+ | undefined;
41
+ const mergedSlotProps = maxHeight
42
+ ? {
43
+ ...callerSlotProps,
44
+ paper: {
45
+ ...callerSlotProps?.paper,
46
+ style: {
47
+ ...(callerSlotProps?.paper?.style as CSSProperties | undefined),
48
+ maxHeight,
49
+ },
50
+ },
51
+ }
52
+ : callerSlotProps;
53
+
37
54
  return (
38
55
  <MuiMenu
39
56
  ref={ref}
40
57
  {...(sanitizedProps as MuiMenuProps)}
41
58
  className={className}
42
59
  classes={mergedClassNames}
60
+ {...(mergedSlotProps
61
+ ? { slotProps: mergedSlotProps as MuiMenuProps["slotProps"] }
62
+ : {})}
43
63
  />
44
64
  );
45
65
  });
@@ -45,3 +45,9 @@ All Recursica components in the `@recursica/mui-adapter` package adhere strictly
45
45
  > - **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.
46
46
  > - **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.
47
47
  > - **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.
48
+
49
+ ---
50
+
51
+ ## 4. Notes
52
+
53
+ - `maxHeight` on `<Menu>` overrides the dropdown's token-driven max-height with an explicit pixel (or other CSS length) value, e.g. `<Menu maxHeight={320}>`. It's a per-instance escape hatch, not a design token — leave it unset to use the token default.
@@ -1,3 +1,14 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Close button is styled to match the Button component's text/icon-only variant, so it reuses
3
+ * the same global hover/focus state tokens Button.module.css exempts.
4
+ * recursica-allow-brand: --recursica_brand_states_focus_blur
5
+ * recursica-allow-brand: --recursica_brand_states_focus_border-size
6
+ * recursica-allow-brand: --recursica_brand_states_focus_color
7
+ * recursica-allow-brand: --recursica_brand_states_focus_margin
8
+ * recursica-allow-brand: --recursica_brand_states_hover_color
9
+ * recursica-allow-brand: --recursica_brand_states_hover_opacity
10
+ */
11
+
1
12
  .root {
2
13
  }
3
14
 
@@ -40,6 +51,9 @@
40
51
  }
41
52
 
42
53
  .header {
54
+ display: flex; /* HARDCODE: puts the title and close button side-by-side so the title has a bounded width to truncate against */
55
+ align-items: center;
56
+ justify-content: space-between;
43
57
  padding: var(
44
58
  --recursica_ui-kit_components_modal_properties_header-footer-vertical-padding
45
59
  )
@@ -50,6 +64,12 @@
50
64
  }
51
65
 
52
66
  .title {
67
+ flex: 1 1 auto; /* HARDCODE: let the title claim the space between the header edge and the close button */
68
+ min-width: 0; /* HARDCODE: required for text-overflow ellipsis to take effect on a flex child */
69
+ overflow: hidden;
70
+ white-space: nowrap;
71
+ text-overflow: ellipsis;
72
+
53
73
  color: var(--recursica_ui-kit_components_modal_properties_colors_title);
54
74
 
55
75
  /* Direct Figma Typography Mapping */
@@ -173,5 +193,87 @@
173
193
  }
174
194
 
175
195
  .close {
176
- /* Inherits standard icon button tokens natively, but we can override if required */
196
+ /* Matches the Button component's text-variant, icon-only, small-size visual treatment
197
+ (see Button.module.css) so the modal close control looks like a Recursica Button
198
+ rather than MUI's native IconButton. */
199
+ box-sizing: border-box;
200
+ display: flex;
201
+ align-items: center;
202
+ justify-content: center;
203
+ position: relative;
204
+ overflow: hidden;
205
+ transition: all 0.2s ease;
206
+
207
+ height: var(
208
+ --recursica_ui-kit_components_button_variants_sizes_small_properties_height
209
+ );
210
+ min-width: var(
211
+ --recursica_ui-kit_components_button_variants_content_icon-only_variants_sizes_small_properties_min-width
212
+ );
213
+ padding: 0
214
+ var(
215
+ --recursica_ui-kit_components_button_variants_content_icon-only_variants_sizes_small_properties_horizontal-padding
216
+ );
217
+ border-radius: var(
218
+ --recursica_ui-kit_components_button_variants_content_icon-only_variants_sizes_small_properties_border-radius
219
+ );
220
+
221
+ border-style: solid;
222
+ border-width: var(
223
+ --recursica_ui-kit_components_button_variants_styles_text_properties_border-size
224
+ );
225
+ border-color: var(
226
+ --recursica_ui-kit_components_button_variants_styles_text_properties_colors_border-color
227
+ );
228
+ background-color: var(
229
+ --recursica_ui-kit_components_button_variants_styles_text_properties_colors_background-color
230
+ );
231
+ color: var(
232
+ --recursica_ui-kit_components_button_variants_styles_text_properties_colors_icon-color
233
+ );
234
+ }
235
+
236
+ .close svg {
237
+ position: relative;
238
+ z-index: 1;
239
+ width: var(
240
+ --recursica_ui-kit_components_button_variants_sizes_small_properties_icon
241
+ );
242
+ height: var(
243
+ --recursica_ui-kit_components_button_variants_sizes_small_properties_icon
244
+ );
245
+ }
246
+
247
+ .close::after {
248
+ content: "";
249
+ position: absolute;
250
+ inset: 0;
251
+ border-radius: inherit;
252
+ z-index: 0;
253
+ pointer-events: none;
254
+ transition: opacity 150ms ease;
255
+ opacity: 0;
256
+ background-color: var(--recursica_brand_states_hover_color);
257
+ }
258
+ .close:hover:not(:disabled)::after {
259
+ opacity: var(--recursica_brand_states_hover_opacity);
260
+ }
261
+
262
+ /* MuiIconButton injects its own `:hover` background via emotion at render time, which lands in
263
+ the DOM after this stylesheet and wins the tie at equal specificity — same class of conflict
264
+ documented in DatePicker.module.css's error/disabled state overrides. `!important` keeps the
265
+ button's own background transparent so only the `::after` overlay above renders the hover
266
+ feedback, matching Button's single-overlay hover treatment exactly. */
267
+ .close:hover {
268
+ background-color: transparent !important;
269
+ }
270
+
271
+ .close:focus-visible {
272
+ outline: none;
273
+ box-shadow:
274
+ 0 0 0 var(--recursica_brand_states_focus_border-size)
275
+ var(--recursica_brand_states_focus_color),
276
+ 0 0 var(--recursica_brand_states_focus_blur)
277
+ var(--recursica_brand_states_focus_margin)
278
+ var(--recursica_brand_states_focus_color);
177
279
  }
@@ -19,7 +19,8 @@ export default meta;
19
19
  type Story = StoryObj<typeof Modal>;
20
20
 
21
21
  const DefaultWrapper = (args: ModalProps) => {
22
- const [opened, setOpened] = useState(false);
22
+ // Starts opened so the modal is visible without pressing a button first.
23
+ const [opened, setOpened] = useState(true);
23
24
  return (
24
25
  <>
25
26
  <Modal {...args} opened={opened} onClose={() => setOpened(false)}>
@@ -43,8 +44,33 @@ export const Default: Story = {
43
44
  render: (args) => <DefaultWrapper {...args} />,
44
45
  };
45
46
 
47
+ const LongTitleWrapper = (args: ModalProps) => {
48
+ const [opened, setOpened] = useState(true);
49
+ return (
50
+ <>
51
+ <Modal {...args} opened={opened} onClose={() => setOpened(false)}>
52
+ The title above is longer than the header can display, so it truncates
53
+ with an ellipsis instead of wrapping onto a second line.
54
+ <Modal.Footer>
55
+ <Button onClick={() => setOpened(false)}>Got it</Button>
56
+ </Modal.Footer>
57
+ </Modal>
58
+ <Button onClick={() => setOpened(true)}>Open Modal</Button>
59
+ </>
60
+ );
61
+ };
62
+
63
+ export const LongTitle: Story = {
64
+ args: {
65
+ title:
66
+ "This Modal Title Is Deliberately Long Enough To Exceed The Available Header Width",
67
+ },
68
+ render: (args) => <LongTitleWrapper {...args} />,
69
+ };
70
+
46
71
  const ScrollingWrapper = (args: ModalProps) => {
47
- const [opened, setOpened] = useState(false);
72
+ // Starts opened so the modal is visible without pressing a button first.
73
+ const [opened, setOpened] = useState(true);
48
74
  return (
49
75
  <>
50
76
  <Modal {...args} opened={opened} onClose={() => setOpened(false)}>