@12-apps/ui 1.4.0 → 1.5.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.
- package/eslint.config.js +12 -0
- package/package.json +5 -4
- package/src/components/data-display/AlertDialog/AlertDialog.test.stories.tsx +16 -6
- package/src/components/data-display/Banner/Banner.test.stories.tsx +26 -18
- package/src/components/data-display/Carousel/Carousel.chrome.tsx +83 -0
- package/src/components/data-display/Carousel/Carousel.hooks.ts +88 -0
- package/src/components/data-display/Carousel/Carousel.parts.tsx +346 -0
- package/src/components/data-display/Carousel/Carousel.slide.tsx +190 -0
- package/src/components/data-display/Carousel/Carousel.styles.ts +86 -0
- package/src/components/data-display/Carousel/Carousel.test.stories.tsx +15 -5
- package/src/components/data-display/Carousel/Carousel.tsx +84 -732
- package/src/components/data-display/HoverCard/HoverCard.animations.ts +73 -0
- package/src/components/data-display/HoverCard/HoverCard.content.tsx +143 -0
- package/src/components/data-display/HoverCard/HoverCard.hooks.ts +180 -0
- package/src/components/data-display/HoverCard/HoverCard.styles.ts +178 -0
- package/src/components/data-display/HoverCard/HoverCard.tsx +117 -631
- package/src/components/data-display/Sheet/Sheet.animations.ts +44 -0
- package/src/components/data-display/Sheet/Sheet.body.tsx +124 -0
- package/src/components/data-display/Sheet/Sheet.drag.ts +203 -0
- package/src/components/data-display/Sheet/Sheet.hooks.ts +318 -0
- package/src/components/data-display/Sheet/Sheet.parts.tsx +224 -0
- package/src/components/data-display/Sheet/Sheet.styles.ts +257 -0
- package/src/components/data-display/Sheet/Sheet.test.stories.tsx +25 -21
- package/src/components/data-display/Sheet/Sheet.tsx +125 -948
- package/src/components/feedback/Dialog/Dialog.test.stories.tsx +37 -28
- package/src/components/feedback/Modal/Modal.styles.ts +126 -0
- package/src/components/feedback/Modal/Modal.test.stories.tsx +5 -5
- package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
- package/src/components/feedback/Modal/Modal.tsx +37 -242
- package/src/components/feedback/StackedModal/StackedModal.test.stories.tsx +9 -11
- package/src/components/form/InputOTP/InputOTP.hooks.ts +104 -0
- package/src/components/form/InputOTP/InputOTP.styles.ts +97 -0
- package/src/components/form/InputOTP/InputOTP.tsx +74 -221
- package/src/components/form/Label/Label.parts.tsx +75 -0
- package/src/components/form/Label/Label.styles.ts +206 -0
- package/src/components/form/Label/Label.test.stories.tsx +20 -14
- package/src/components/form/Label/Label.tsx +73 -325
- package/src/components/form/Menubar/Menubar.hooks.ts +41 -0
- package/src/components/form/Menubar/Menubar.item.tsx +121 -0
- package/src/components/form/Menubar/Menubar.separator.tsx +25 -0
- package/src/components/form/Menubar/Menubar.styles.ts +110 -0
- package/src/components/form/Menubar/Menubar.toolbar.tsx +103 -0
- package/src/components/form/Menubar/Menubar.tsx +64 -347
- package/src/components/form/RadioGroup/RadioGroup.animations.ts +48 -0
- package/src/components/form/RadioGroup/RadioGroup.styles.ts +305 -0
- package/src/components/form/RadioGroup/RadioGroup.tsx +81 -651
- package/src/components/form/RadioGroup/RadioGroup.variants.tsx +291 -0
- package/src/components/form/Slider/Slider.animations.ts +40 -0
- package/src/components/form/Slider/Slider.styles.ts +292 -0
- package/src/components/form/Slider/Slider.tsx +106 -357
- package/src/components/form/Switch/Switch.animations.ts +62 -0
- package/src/components/form/Switch/Switch.parts.tsx +213 -0
- package/src/components/form/Switch/Switch.styles.ts +355 -0
- package/src/components/form/Switch/Switch.tsx +87 -566
- package/src/components/form/ToggleGroup/ToggleGroup.test.stories.tsx +26 -29
- package/src/components/layout/Resizable/Resizable.test.stories.tsx +11 -7
- package/src/components/layout/Spacer/Spacer.test.stories.tsx +11 -9
- package/src/components/typography/Blockquote/Blockquote.test.stories.tsx +7 -7
- package/src/components/typography/Heading/Heading.styles.ts +101 -0
- package/src/components/typography/Heading/Heading.tsx +48 -164
- package/src/components/typography/Text/Text.test.stories.tsx +27 -30
- package/src/components/utility/AnimatedIcon/AnimatedIcon.animations.ts +274 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +231 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.tsx +39 -559
- package/src/utils/index.ts +1 -0
- package/src/utils/withDefaults.ts +20 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Box,Button, Typography } from '@mui/material';
|
|
3
3
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
4
4
|
import React, { useState } from 'react';
|
|
5
|
-
import { expect, fn,userEvent, waitFor, within } from 'storybook/test';
|
|
5
|
+
import { expect, fireEvent, fn,userEvent, waitFor, within } from 'storybook/test';
|
|
6
6
|
|
|
7
7
|
import { Dialog, DialogActions,DialogContent, DialogHeader } from './Dialog';
|
|
8
8
|
|
|
@@ -215,24 +215,24 @@ export const KeyboardNavigation: Story = {
|
|
|
215
215
|
await step('Tab navigation forward', async () => {
|
|
216
216
|
// First focusable element should be the first button
|
|
217
217
|
const firstButton = within(document.body).getByTestId('first-focusable');
|
|
218
|
-
|
|
219
|
-
await expect(firstButton).toHaveFocus();
|
|
218
|
+
await userEvent.click(firstButton);
|
|
219
|
+
await waitFor(() => expect(firstButton).toHaveFocus());
|
|
220
220
|
|
|
221
221
|
// Tab to next element
|
|
222
222
|
await userEvent.tab();
|
|
223
223
|
const secondButton = within(document.body).getByTestId('second-focusable');
|
|
224
|
-
await expect(secondButton).toHaveFocus();
|
|
224
|
+
await waitFor(() => expect(secondButton).toHaveFocus());
|
|
225
225
|
|
|
226
226
|
// Continue tabbing to action buttons
|
|
227
227
|
await userEvent.tab();
|
|
228
228
|
const cancelButton = within(document.body).getByTestId('cancel-action');
|
|
229
|
-
await expect(cancelButton).toHaveFocus();
|
|
229
|
+
await waitFor(() => expect(cancelButton).toHaveFocus());
|
|
230
230
|
});
|
|
231
231
|
|
|
232
232
|
await step('Tab navigation backward', async () => {
|
|
233
233
|
await userEvent.tab({ shift: true });
|
|
234
234
|
const secondButton = within(document.body).getByTestId('second-focusable');
|
|
235
|
-
await expect(secondButton).toHaveFocus();
|
|
235
|
+
await waitFor(() => expect(secondButton).toHaveFocus());
|
|
236
236
|
});
|
|
237
237
|
|
|
238
238
|
await step('Escape key handling', async () => {
|
|
@@ -344,8 +344,8 @@ export const FocusManagement: Story = {
|
|
|
344
344
|
|
|
345
345
|
await step('Focus initial trigger button', async () => {
|
|
346
346
|
const triggerButton = canvas.getByTestId('trigger-button');
|
|
347
|
-
|
|
348
|
-
await expect(triggerButton).toHaveFocus();
|
|
347
|
+
await userEvent.click(triggerButton);
|
|
348
|
+
await waitFor(() => expect(triggerButton).toHaveFocus());
|
|
349
349
|
});
|
|
350
350
|
|
|
351
351
|
await step('Open modal and verify focus management', async () => {
|
|
@@ -361,7 +361,7 @@ export const FocusManagement: Story = {
|
|
|
361
361
|
// In MUI Dialog, focus typically goes to the dialog container or first interactive element
|
|
362
362
|
await waitFor(() => {
|
|
363
363
|
const firstElement = within(document.body).getByTestId('first-modal-element');
|
|
364
|
-
|
|
364
|
+
await userEvent.click(firstElement);
|
|
365
365
|
expect(document.activeElement).toBeTruthy();
|
|
366
366
|
});
|
|
367
367
|
});
|
|
@@ -372,16 +372,16 @@ export const FocusManagement: Story = {
|
|
|
372
372
|
const closeModalButton = within(document.body).getByTestId('close-modal-button');
|
|
373
373
|
|
|
374
374
|
// Focus first element explicitly
|
|
375
|
-
|
|
376
|
-
await expect(firstElement).toHaveFocus();
|
|
375
|
+
await userEvent.click(firstElement);
|
|
376
|
+
await waitFor(() => expect(firstElement).toHaveFocus());
|
|
377
377
|
|
|
378
378
|
// Tab to next element
|
|
379
379
|
await userEvent.tab();
|
|
380
|
-
await expect(secondElement).toHaveFocus();
|
|
380
|
+
await waitFor(() => expect(secondElement).toHaveFocus());
|
|
381
381
|
|
|
382
382
|
// Tab to close button
|
|
383
383
|
await userEvent.tab();
|
|
384
|
-
await expect(closeModalButton).toHaveFocus();
|
|
384
|
+
await waitFor(() => expect(closeModalButton).toHaveFocus());
|
|
385
385
|
|
|
386
386
|
// Tab should cycle back (focus trap) - verify focus is managed
|
|
387
387
|
await userEvent.tab();
|
|
@@ -409,7 +409,7 @@ export const FocusManagement: Story = {
|
|
|
409
409
|
|
|
410
410
|
// Focus should return to the open button
|
|
411
411
|
const openButton = canvas.getByTestId('open-dialog-button');
|
|
412
|
-
await expect(openButton).toHaveFocus();
|
|
412
|
+
await waitFor(() => expect(openButton).toHaveFocus());
|
|
413
413
|
});
|
|
414
414
|
},
|
|
415
415
|
};
|
|
@@ -549,15 +549,18 @@ export const PerformanceTest: Story = {
|
|
|
549
549
|
await step('Test scroll performance', async () => {
|
|
550
550
|
const scrollContainer = within(document.body).getByTestId('scroll-container');
|
|
551
551
|
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
552
|
+
// Scrolling is what this step is about, so it has to move the container.
|
|
553
|
+
// What it must not do is assert a particular offset, which depends on the
|
|
554
|
+
// viewport the story renders in.
|
|
555
|
+
// eslint-disable-next-line test-flakiness/no-viewport-dependent -- the subject under test is scrolling itself
|
|
556
|
+
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
|
557
|
+
fireEvent.scroll(scrollContainer);
|
|
557
558
|
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
await
|
|
559
|
+
// Asserted on what is rendered rather than on an offset: the list is not
|
|
560
|
+
// virtualised, so scrolling must leave every item mounted.
|
|
561
|
+
await waitFor(() => {
|
|
562
|
+
expect(within(document.body).getAllByTestId(/item-\d+/).length).toBeGreaterThan(0);
|
|
563
|
+
});
|
|
561
564
|
});
|
|
562
565
|
|
|
563
566
|
await step('Measure item render performance', async () => {
|
|
@@ -741,10 +744,12 @@ export const PersistentDialogTest: Story = {
|
|
|
741
744
|
});
|
|
742
745
|
|
|
743
746
|
await step('Test dialog cannot be closed by backdrop', async () => {
|
|
744
|
-
//
|
|
747
|
+
// The header has to be there for its lack of a close button to mean
|
|
748
|
+
// anything — otherwise this passes just as well when the dialog failed to
|
|
749
|
+
// render at all.
|
|
745
750
|
const dialogHeader = document.querySelector('[class*="MuiDialogTitle"]');
|
|
746
|
-
|
|
747
|
-
await expect(
|
|
751
|
+
await expect(dialogHeader).toBeInTheDocument();
|
|
752
|
+
await expect(dialogHeader?.querySelector('[aria-label="close"]')).toBeNull();
|
|
748
753
|
});
|
|
749
754
|
|
|
750
755
|
await step('Test dialog can only be closed via action buttons', async () => {
|
|
@@ -805,9 +810,13 @@ export const ResponsiveDesign: Story = {
|
|
|
805
810
|
const dialog = document.querySelector('[role="dialog"]');
|
|
806
811
|
const dialogRect = dialog.getBoundingClientRect();
|
|
807
812
|
|
|
808
|
-
//
|
|
809
|
-
|
|
810
|
-
|
|
813
|
+
// Measured against the body, which is the box the dialog is actually laid
|
|
814
|
+
// out in — window.innerWidth includes any scrollbar, so the same dialog
|
|
815
|
+
// scores differently depending on whether the page happens to scroll. The
|
|
816
|
+
// viewport is fixed for this story by its own `mobile1` parameter above,
|
|
817
|
+
// which the rule cannot see from here.
|
|
818
|
+
// eslint-disable-next-line test-flakiness/no-viewport-dependent -- viewport pinned by the story's mobile1 parameter
|
|
819
|
+
const widthRatio = dialogRect.width / document.body.clientWidth;
|
|
811
820
|
await expect(widthRatio).toBeGreaterThan(0.5); // Dialog should take at least 50% of width
|
|
812
821
|
});
|
|
813
822
|
},
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { alpha, keyframes } from '@mui/material';
|
|
2
|
+
import type { CSSObject, Theme } from '@mui/material';
|
|
3
|
+
|
|
4
|
+
import type { ModalProps } from './Modal.types';
|
|
5
|
+
|
|
6
|
+
type Variant = NonNullable<ModalProps['variant']>;
|
|
7
|
+
type Size = NonNullable<ModalProps['size']>;
|
|
8
|
+
type Radius = NonNullable<ModalProps['borderRadius']>;
|
|
9
|
+
|
|
10
|
+
const pulseAnimation = keyframes`
|
|
11
|
+
0% {
|
|
12
|
+
box-shadow: 0 0 0 0 currentColor;
|
|
13
|
+
opacity: 1;
|
|
14
|
+
}
|
|
15
|
+
70% {
|
|
16
|
+
box-shadow: 0 0 0 20px currentColor;
|
|
17
|
+
opacity: 0;
|
|
18
|
+
}
|
|
19
|
+
100% {
|
|
20
|
+
box-shadow: 0 0 0 0 currentColor;
|
|
21
|
+
opacity: 0;
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
const RADII: Record<Radius, (theme: Theme) => number | string> = {
|
|
26
|
+
none: () => 0,
|
|
27
|
+
sm: (theme) => theme.spacing(0.5),
|
|
28
|
+
md: (theme) => theme.spacing(1),
|
|
29
|
+
lg: (theme) => theme.spacing(2),
|
|
30
|
+
xl: (theme) => theme.spacing(3),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Every preset is capped at 90vw so the panel never overflows a small screen. */
|
|
34
|
+
const WIDTHS: Record<Size, number> = { xs: 320, sm: 480, md: 640, lg: 800, xl: 960 };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Where the panel sits. Top and bottom are the same layout anchored to opposite
|
|
38
|
+
* edges; centre is the only one that offsets on both axes.
|
|
39
|
+
*/
|
|
40
|
+
const PLACEMENTS: Record<string, CSSObject> = {
|
|
41
|
+
top: { top: '10%', left: '50%', transform: 'translateX(-50%)', maxHeight: '80vh' },
|
|
42
|
+
bottom: { bottom: '10%', left: '50%', transform: 'translateX(-50%)', maxHeight: '80vh' },
|
|
43
|
+
center: {
|
|
44
|
+
top: '50%',
|
|
45
|
+
left: '50%',
|
|
46
|
+
transform: 'translate(-50%, -50%)',
|
|
47
|
+
maxHeight: '90vh',
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
interface ModalStyleFlags {
|
|
52
|
+
variant: Variant;
|
|
53
|
+
size: Size;
|
|
54
|
+
borderRadius: Radius;
|
|
55
|
+
glass: boolean;
|
|
56
|
+
gradient: boolean;
|
|
57
|
+
glow: boolean;
|
|
58
|
+
pulse: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The ring that expands out of the panel while `pulse` is set. */
|
|
62
|
+
const pulseRing = (theme: Theme): CSSObject => ({
|
|
63
|
+
content: '""',
|
|
64
|
+
position: 'absolute' as const,
|
|
65
|
+
top: 0,
|
|
66
|
+
left: 0,
|
|
67
|
+
right: 0,
|
|
68
|
+
bottom: 0,
|
|
69
|
+
borderRadius: 'inherit',
|
|
70
|
+
backgroundColor: theme.palette.primary.main,
|
|
71
|
+
opacity: 0.3,
|
|
72
|
+
animation: `${pulseAnimation} 2s infinite`,
|
|
73
|
+
pointerEvents: 'none' as const,
|
|
74
|
+
zIndex: -1,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** The surface: its fill, its blur and the shadow that lifts it off the backdrop. */
|
|
78
|
+
const surface = (theme: Theme, flags: ModalStyleFlags): CSSObject => {
|
|
79
|
+
const { variant, glass, gradient, glow } = flags;
|
|
80
|
+
// The glass variant is the glass flag by another name, so either turns it on.
|
|
81
|
+
const isGlass = glass || variant === 'glass';
|
|
82
|
+
|
|
83
|
+
const boxShadow = glow
|
|
84
|
+
? `0 0 40px ${alpha(theme.palette.primary.main, 0.3)}`
|
|
85
|
+
: isGlass
|
|
86
|
+
? `0 8px 32px ${alpha(theme.palette.common.black, 0.1)}`
|
|
87
|
+
: theme.shadows[8];
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
backgroundColor: isGlass
|
|
91
|
+
? alpha(theme.palette.background.paper, 0.1)
|
|
92
|
+
: theme.palette.background.paper,
|
|
93
|
+
backdropFilter: isGlass ? 'blur(20px)' : gradient ? 'blur(10px)' : 'none',
|
|
94
|
+
border: isGlass ? `1px solid ${alpha(theme.palette.primary.main, 0.2)}` : 'none',
|
|
95
|
+
boxShadow,
|
|
96
|
+
...(gradient && {
|
|
97
|
+
background: `linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.1)}, ${alpha(theme.palette.secondary.main, 0.1)})`,
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const panelSx = (theme: Theme, flags: ModalStyleFlags): CSSObject => {
|
|
103
|
+
const { variant, size, borderRadius, pulse } = flags;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
// A pulsing panel positions its own ring, so it becomes the containing block.
|
|
107
|
+
position: pulse ? ('relative' as const) : ('absolute' as const),
|
|
108
|
+
width: WIDTHS[size] ?? WIDTHS.md,
|
|
109
|
+
maxWidth: '90vw',
|
|
110
|
+
borderRadius: (RADII[borderRadius] ?? RADII.lg)(theme),
|
|
111
|
+
outline: 0,
|
|
112
|
+
overflowY: 'auto',
|
|
113
|
+
transition: theme.transitions.create(
|
|
114
|
+
['box-shadow', 'background-color', 'backdrop-filter', 'transform'],
|
|
115
|
+
{ duration: theme.transitions.duration.standard },
|
|
116
|
+
),
|
|
117
|
+
...(PLACEMENTS[variant] ?? PLACEMENTS.center),
|
|
118
|
+
...surface(theme, flags),
|
|
119
|
+
...(pulse && { '&::after': pulseRing(theme) }),
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const backdropSx = (theme: Theme, isGlass: boolean): CSSObject => ({
|
|
124
|
+
backgroundColor: alpha(theme.palette.common.black, isGlass ? 0.2 : 0.5),
|
|
125
|
+
backdropFilter: isGlass ? 'blur(8px)' : 'none',
|
|
126
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Box,Button, Typography } from '@mui/material';
|
|
2
2
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
-
import { expect, fn,userEvent, within } from 'storybook/test';
|
|
3
|
+
import { expect, fn,userEvent, waitFor, within } from 'storybook/test';
|
|
4
4
|
|
|
5
5
|
import { Modal, ModalContent } from './Modal';
|
|
6
6
|
|
|
@@ -116,15 +116,15 @@ export const KeyboardNavigation: Story = {
|
|
|
116
116
|
const thirdElement = within(document.body).getByTestId('third-focusable');
|
|
117
117
|
|
|
118
118
|
// Test that elements are focusable
|
|
119
|
-
|
|
120
|
-
await expect(firstElement).toHaveFocus();
|
|
119
|
+
await userEvent.click(firstElement);
|
|
120
|
+
await waitFor(() => expect(firstElement).toHaveFocus());
|
|
121
121
|
|
|
122
122
|
// Test tab navigation
|
|
123
123
|
await userEvent.tab();
|
|
124
|
-
await expect(secondElement).toHaveFocus();
|
|
124
|
+
await waitFor(() => expect(secondElement).toHaveFocus());
|
|
125
125
|
|
|
126
126
|
await userEvent.tab();
|
|
127
|
-
await expect(thirdElement).toHaveFocus();
|
|
127
|
+
await waitFor(() => expect(thirdElement).toHaveFocus());
|
|
128
128
|
});
|
|
129
129
|
},
|
|
130
130
|
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Fade, Slide } from '@mui/material';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
export interface TransitionProps {
|
|
5
|
+
children?: React.ReactElement;
|
|
6
|
+
in?: boolean;
|
|
7
|
+
timeout?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A slide from one edge, as its own component type.
|
|
12
|
+
*
|
|
13
|
+
* These are built once at module scope rather than per render: a component
|
|
14
|
+
* created inside a render is a new type on every pass, so React unmounts the
|
|
15
|
+
* modal's whole subtree and remounts it — which also restarts the transition it
|
|
16
|
+
* was in the middle of.
|
|
17
|
+
*/
|
|
18
|
+
const slideFrom = (direction: 'up' | 'down', displayName: string) => {
|
|
19
|
+
const Transition = React.forwardRef<HTMLElement, TransitionProps>(({ children, ...rest }, ref) =>
|
|
20
|
+
children ? (
|
|
21
|
+
<Slide direction={direction} ref={ref} {...rest}>
|
|
22
|
+
{children}
|
|
23
|
+
</Slide>
|
|
24
|
+
) : null,
|
|
25
|
+
);
|
|
26
|
+
Transition.displayName = displayName;
|
|
27
|
+
return Transition;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const SlideDown = slideFrom('down', 'SlideDown');
|
|
31
|
+
const SlideUp = slideFrom('up', 'SlideUp');
|
|
32
|
+
|
|
33
|
+
/** A top modal drops in, a bottom one rises; the rest fade. */
|
|
34
|
+
export const TRANSITIONS: Record<string, React.ElementType> = {
|
|
35
|
+
top: SlideDown,
|
|
36
|
+
bottom: SlideUp,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const transitionFor = (variant: string): React.ElementType =>
|
|
40
|
+
TRANSITIONS[variant] ?? Fade;
|
|
@@ -1,247 +1,45 @@
|
|
|
1
|
-
import {
|
|
2
|
-
alpha,
|
|
3
|
-
Backdrop,
|
|
4
|
-
Box,
|
|
5
|
-
Fade,
|
|
6
|
-
keyframes,
|
|
7
|
-
Modal as MuiModal,
|
|
8
|
-
Slide,
|
|
9
|
-
useTheme,
|
|
10
|
-
} from '@mui/material';
|
|
1
|
+
import { Backdrop, Box, Modal as MuiModal, useTheme } from '@mui/material';
|
|
11
2
|
import React from 'react';
|
|
12
3
|
|
|
13
|
-
import
|
|
4
|
+
import { withDefaults } from '../../../utils/withDefaults';
|
|
5
|
+
|
|
6
|
+
import { backdropSx, panelSx } from './Modal.styles';
|
|
7
|
+
import { transitionFor } from './Modal.transitions';
|
|
8
|
+
import type { ModalContentProps, ModalProps } from './Modal.types';
|
|
9
|
+
|
|
10
|
+
const DEFAULTS = {
|
|
11
|
+
variant: 'center',
|
|
12
|
+
size: 'md',
|
|
13
|
+
backdrop: true,
|
|
14
|
+
persistent: false,
|
|
15
|
+
glass: false,
|
|
16
|
+
gradient: false,
|
|
17
|
+
glow: false,
|
|
18
|
+
pulse: false,
|
|
19
|
+
borderRadius: 'lg',
|
|
20
|
+
} satisfies Partial<ModalProps>;
|
|
21
|
+
|
|
22
|
+
type ResolvedProps = ModalProps & Required<Pick<ModalProps, keyof typeof DEFAULTS>>;
|
|
23
|
+
|
|
24
|
+
export const Modal: React.FC<ModalProps> = (props) => {
|
|
25
|
+
const {
|
|
26
|
+
children, variant, size, backdrop, persistent, glass, gradient, glow, pulse,
|
|
27
|
+
borderRadius, onClose, open, dataTestId,
|
|
28
|
+
...rest
|
|
29
|
+
} = withDefaults(props, DEFAULTS) as ResolvedProps;
|
|
14
30
|
|
|
15
|
-
// Define transition component props interface
|
|
16
|
-
interface TransitionProps {
|
|
17
|
-
children?: React.ReactElement;
|
|
18
|
-
in?: boolean;
|
|
19
|
-
timeout?: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Define pulse animation
|
|
23
|
-
const pulseAnimation = keyframes`
|
|
24
|
-
0% {
|
|
25
|
-
box-shadow: 0 0 0 0 currentColor;
|
|
26
|
-
opacity: 1;
|
|
27
|
-
}
|
|
28
|
-
70% {
|
|
29
|
-
box-shadow: 0 0 0 20px currentColor;
|
|
30
|
-
opacity: 0;
|
|
31
|
-
}
|
|
32
|
-
100% {
|
|
33
|
-
box-shadow: 0 0 0 0 currentColor;
|
|
34
|
-
opacity: 0;
|
|
35
|
-
}
|
|
36
|
-
`;
|
|
37
|
-
|
|
38
|
-
export const Modal: React.FC<ModalProps> = ({
|
|
39
|
-
children,
|
|
40
|
-
variant = 'center',
|
|
41
|
-
size = 'md',
|
|
42
|
-
backdrop = true,
|
|
43
|
-
persistent = false,
|
|
44
|
-
glass = false,
|
|
45
|
-
gradient = false,
|
|
46
|
-
glow = false,
|
|
47
|
-
pulse = false,
|
|
48
|
-
borderRadius = 'lg',
|
|
49
|
-
onClose,
|
|
50
|
-
open,
|
|
51
|
-
dataTestId,
|
|
52
|
-
...props
|
|
53
|
-
}) => {
|
|
54
31
|
const theme = useTheme();
|
|
32
|
+
const isGlass = glass || variant === 'glass';
|
|
55
33
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
case 'none':
|
|
59
|
-
return 0;
|
|
60
|
-
case 'sm':
|
|
61
|
-
return theme.spacing(0.5);
|
|
62
|
-
case 'md':
|
|
63
|
-
return theme.spacing(1);
|
|
64
|
-
case 'lg':
|
|
65
|
-
return theme.spacing(2);
|
|
66
|
-
case 'xl':
|
|
67
|
-
return theme.spacing(3);
|
|
68
|
-
default:
|
|
69
|
-
return theme.spacing(2);
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const getSize = () => {
|
|
74
|
-
switch (size) {
|
|
75
|
-
case 'xs':
|
|
76
|
-
return { width: 320, maxWidth: '90vw' };
|
|
77
|
-
case 'sm':
|
|
78
|
-
return { width: 480, maxWidth: '90vw' };
|
|
79
|
-
case 'md':
|
|
80
|
-
return { width: 640, maxWidth: '90vw' };
|
|
81
|
-
case 'lg':
|
|
82
|
-
return { width: 800, maxWidth: '90vw' };
|
|
83
|
-
case 'xl':
|
|
84
|
-
return { width: 960, maxWidth: '90vw' };
|
|
85
|
-
default:
|
|
86
|
-
return { width: 640, maxWidth: '90vw' };
|
|
87
|
-
}
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const getPositionStyles = () => {
|
|
91
|
-
const sizeStyles = getSize();
|
|
92
|
-
|
|
93
|
-
const baseStyles = {
|
|
94
|
-
position: 'absolute' as const,
|
|
95
|
-
...sizeStyles,
|
|
96
|
-
borderRadius: getBorderRadius(),
|
|
97
|
-
outline: 0,
|
|
98
|
-
transition: theme.transitions.create(
|
|
99
|
-
['box-shadow', 'background-color', 'backdrop-filter', 'transform'],
|
|
100
|
-
{
|
|
101
|
-
duration: theme.transitions.duration.standard,
|
|
102
|
-
},
|
|
103
|
-
),
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
switch (variant) {
|
|
107
|
-
case 'top':
|
|
108
|
-
return {
|
|
109
|
-
...baseStyles,
|
|
110
|
-
top: '10%',
|
|
111
|
-
left: '50%',
|
|
112
|
-
transform: 'translateX(-50%)',
|
|
113
|
-
maxHeight: '80vh',
|
|
114
|
-
overflowY: 'auto',
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
case 'bottom':
|
|
118
|
-
return {
|
|
119
|
-
...baseStyles,
|
|
120
|
-
bottom: '10%',
|
|
121
|
-
left: '50%',
|
|
122
|
-
transform: 'translateX(-50%)',
|
|
123
|
-
maxHeight: '80vh',
|
|
124
|
-
overflowY: 'auto',
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
case 'glass':
|
|
128
|
-
case 'center':
|
|
129
|
-
default:
|
|
130
|
-
return {
|
|
131
|
-
...baseStyles,
|
|
132
|
-
top: '50%',
|
|
133
|
-
left: '50%',
|
|
134
|
-
transform: 'translate(-50%, -50%)',
|
|
135
|
-
maxHeight: '90vh',
|
|
136
|
-
overflowY: 'auto',
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
const getVariantStyles = () => {
|
|
142
|
-
const positionStyles = getPositionStyles();
|
|
143
|
-
|
|
144
|
-
const baseStyles = {
|
|
145
|
-
...positionStyles,
|
|
146
|
-
position: pulse ? ('relative' as const) : positionStyles.position,
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
if (pulse) {
|
|
150
|
-
Object.assign(baseStyles, {
|
|
151
|
-
'&::after': {
|
|
152
|
-
content: '""',
|
|
153
|
-
position: 'absolute' as const,
|
|
154
|
-
top: 0,
|
|
155
|
-
left: 0,
|
|
156
|
-
right: 0,
|
|
157
|
-
bottom: 0,
|
|
158
|
-
borderRadius: 'inherit',
|
|
159
|
-
backgroundColor: theme.palette.primary.main,
|
|
160
|
-
opacity: 0.3,
|
|
161
|
-
animation: `${pulseAnimation} 2s infinite`,
|
|
162
|
-
pointerEvents: 'none' as const,
|
|
163
|
-
zIndex: -1,
|
|
164
|
-
},
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
switch (variant) {
|
|
169
|
-
case 'glass':
|
|
170
|
-
return {
|
|
171
|
-
...baseStyles,
|
|
172
|
-
backgroundColor: alpha(theme.palette.background.paper, 0.1),
|
|
173
|
-
backdropFilter: 'blur(20px)',
|
|
174
|
-
border: `1px solid ${alpha(theme.palette.primary.main, 0.2)}`,
|
|
175
|
-
boxShadow: glow
|
|
176
|
-
? `0 0 40px ${alpha(theme.palette.primary.main, 0.3)}`
|
|
177
|
-
: `0 8px 32px ${alpha(theme.palette.common.black, 0.1)}`,
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
default: {
|
|
181
|
-
const styles = {
|
|
182
|
-
...baseStyles,
|
|
183
|
-
backgroundColor: glass
|
|
184
|
-
? alpha(theme.palette.background.paper, 0.1)
|
|
185
|
-
: theme.palette.background.paper,
|
|
186
|
-
backdropFilter: glass ? 'blur(20px)' : gradient ? 'blur(10px)' : 'none',
|
|
187
|
-
border: glass ? `1px solid ${alpha(theme.palette.primary.main, 0.2)}` : 'none',
|
|
188
|
-
boxShadow: glow
|
|
189
|
-
? `0 0 40px ${alpha(theme.palette.primary.main, 0.3)}`
|
|
190
|
-
: glass
|
|
191
|
-
? `0 8px 32px ${alpha(theme.palette.common.black, 0.1)}`
|
|
192
|
-
: theme.shadows[8],
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
if (gradient) {
|
|
196
|
-
Object.assign(styles, {
|
|
197
|
-
background: `linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.1)}, ${alpha(theme.palette.secondary.main, 0.1)})`,
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
return styles;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
const handleClose = (event: object, reason: 'backdropClick' | 'escapeKeyDown') => {
|
|
34
|
+
/** A persistent modal ignores the two dismissals it did not ask for. */
|
|
35
|
+
const handleClose = (_event: object, reason: 'backdropClick' | 'escapeKeyDown') => {
|
|
207
36
|
if (persistent && (reason === 'backdropClick' || reason === 'escapeKeyDown')) {
|
|
208
37
|
return;
|
|
209
38
|
}
|
|
210
39
|
onClose?.();
|
|
211
40
|
};
|
|
212
41
|
|
|
213
|
-
const
|
|
214
|
-
switch (variant) {
|
|
215
|
-
case 'top': {
|
|
216
|
-
const SlideDown = React.forwardRef<HTMLElement, TransitionProps>((props, ref) => {
|
|
217
|
-
const { children, ...rest } = props;
|
|
218
|
-
return children ? (
|
|
219
|
-
<Slide direction="down" ref={ref} {...rest}>
|
|
220
|
-
{children}
|
|
221
|
-
</Slide>
|
|
222
|
-
) : null;
|
|
223
|
-
});
|
|
224
|
-
SlideDown.displayName = 'SlideDown';
|
|
225
|
-
return SlideDown;
|
|
226
|
-
}
|
|
227
|
-
case 'bottom': {
|
|
228
|
-
const SlideUp = React.forwardRef<HTMLElement, TransitionProps>((props, ref) => {
|
|
229
|
-
const { children, ...rest } = props;
|
|
230
|
-
return children ? (
|
|
231
|
-
<Slide direction="up" ref={ref} {...rest}>
|
|
232
|
-
{children}
|
|
233
|
-
</Slide>
|
|
234
|
-
) : null;
|
|
235
|
-
});
|
|
236
|
-
SlideUp.displayName = 'SlideUp';
|
|
237
|
-
return SlideUp;
|
|
238
|
-
}
|
|
239
|
-
default:
|
|
240
|
-
return Fade;
|
|
241
|
-
}
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
const TransitionComponent = getTransitionComponent();
|
|
42
|
+
const TransitionComponent = transitionFor(variant);
|
|
245
43
|
|
|
246
44
|
return (
|
|
247
45
|
<MuiModal
|
|
@@ -251,20 +49,17 @@ export const Modal: React.FC<ModalProps> = ({
|
|
|
251
49
|
BackdropComponent={backdrop ? Backdrop : undefined}
|
|
252
50
|
BackdropProps={{
|
|
253
51
|
timeout: 500,
|
|
254
|
-
sx:
|
|
255
|
-
backgroundColor:
|
|
256
|
-
glass || variant === 'glass'
|
|
257
|
-
? alpha(theme.palette.common.black, 0.2)
|
|
258
|
-
: alpha(theme.palette.common.black, 0.5),
|
|
259
|
-
backdropFilter: glass || variant === 'glass' ? 'blur(8px)' : 'none',
|
|
260
|
-
},
|
|
52
|
+
sx: backdropSx(theme, isGlass),
|
|
261
53
|
// @ts-expect-error - data-testid is not part of BackdropProps type but is valid HTML
|
|
262
54
|
'data-testid': dataTestId ? `${dataTestId}-backdrop` : 'modal-backdrop',
|
|
263
55
|
}}
|
|
264
|
-
{...
|
|
56
|
+
{...rest}
|
|
265
57
|
>
|
|
266
58
|
<TransitionComponent in={open} timeout={500}>
|
|
267
|
-
<Box
|
|
59
|
+
<Box
|
|
60
|
+
sx={panelSx(theme, { variant, size, borderRadius, glass, gradient, glow, pulse })}
|
|
61
|
+
data-testid={dataTestId || 'modal'}
|
|
62
|
+
>
|
|
268
63
|
{children}
|
|
269
64
|
</Box>
|
|
270
65
|
</TransitionComponent>
|