@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/CHANGELOG.md +18 -0
- package/dist/index.d.ts +6 -0
- package/dist/mui-adapter.cjs +1 -1
- package/dist/mui-adapter.cjs.map +1 -1
- package/dist/mui-adapter.css +1 -1
- package/dist/mui-adapter.js +18 -7
- package/dist/mui-adapter.js.map +1 -1
- package/package.json +7 -3
- package/src/components/DatePicker/DatePicker.stories.tsx +25 -0
- package/src/components/Menu/IMPLEMENTATION_NOTES.md +1 -0
- package/src/components/Menu/Menu.stories.tsx +37 -0
- package/src/components/Menu/Menu.tsx +25 -5
- package/src/components/Menu/USAGE.md +6 -0
- package/src/components/Modal/Modal.module.css +103 -1
- package/src/components/Modal/Modal.stories.tsx +28 -2
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
-
|
|
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)}>
|