@12-apps/ui 1.4.0 → 1.4.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 +4 -4
- 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.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.tsx +125 -948
- package/src/components/feedback/Modal/Modal.styles.ts +126 -0
- package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
- package/src/components/feedback/Modal/Modal.tsx +37 -242
- 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.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/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/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
|
@@ -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
|
+
});
|
|
@@ -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>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What each variant accepts. `masked` constrains nothing — it only changes what
|
|
6
|
+
* is drawn — so it has no pattern here.
|
|
7
|
+
*/
|
|
8
|
+
const PATTERNS: Record<string, RegExp> = {
|
|
9
|
+
numeric: /^\d*$/,
|
|
10
|
+
alphanumeric: /^[a-zA-Z0-9]*$/,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Whether `text` is something this variant will take. */
|
|
14
|
+
const isAllowed = (variant: string, text: string) => (PATTERNS[variant] ?? /.*/).test(text);
|
|
15
|
+
|
|
16
|
+
/** The digit array is always `length` long, blank-padded on the right. */
|
|
17
|
+
const padTo = (digits: string[], length: number) =>
|
|
18
|
+
Array.from({ length }, (_, i) => digits[i] ?? '');
|
|
19
|
+
|
|
20
|
+
interface OtpInput {
|
|
21
|
+
variant: string;
|
|
22
|
+
length: number;
|
|
23
|
+
value: string;
|
|
24
|
+
onChange?: (value: string) => void;
|
|
25
|
+
onComplete?: (value: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The digits, and the three ways they change: typing, the keys that move
|
|
30
|
+
* between slots, and a paste into the first one.
|
|
31
|
+
*
|
|
32
|
+
* Focus moves as a consequence of entry, so the input refs live here with the
|
|
33
|
+
* state that drives them.
|
|
34
|
+
*/
|
|
35
|
+
export const useOtpDigits = ({ variant, length, value, onChange, onComplete }: OtpInput) => {
|
|
36
|
+
const [digits, setDigits] = useState<string[]>(() => padTo([], length));
|
|
37
|
+
const inputRefs = useRef<(globalThis.HTMLInputElement | null)[]>([]);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
setDigits(padTo(value.split('').slice(0, length), length));
|
|
41
|
+
}, [value, length]);
|
|
42
|
+
|
|
43
|
+
const focusSlot = useCallback((index: number) => {
|
|
44
|
+
inputRefs.current[index]?.focus();
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
/** Publishes the joined value, and reports completion when every slot is full. */
|
|
48
|
+
const publish = useCallback(
|
|
49
|
+
(next: string) => {
|
|
50
|
+
onChange?.(next);
|
|
51
|
+
if (next.length === length) {
|
|
52
|
+
onComplete?.(next);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
[length, onChange, onComplete],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const handleInputChange = useCallback(
|
|
59
|
+
(index: number, inputValue: string) => {
|
|
60
|
+
if (!isAllowed(variant, inputValue)) return;
|
|
61
|
+
|
|
62
|
+
const next = [...digits];
|
|
63
|
+
next[index] = inputValue.slice(-1); // one character per slot
|
|
64
|
+
setDigits(next);
|
|
65
|
+
publish(next.join(''));
|
|
66
|
+
|
|
67
|
+
if (inputValue && index < length - 1) {
|
|
68
|
+
focusSlot(index + 1);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
[variant, digits, length, publish, focusSlot],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const handleKeyDown = useCallback(
|
|
75
|
+
(index: number, e: React.KeyboardEvent) => {
|
|
76
|
+
// Backspace in an already-empty slot steps back rather than deleting.
|
|
77
|
+
const back =
|
|
78
|
+
(e.key === 'Backspace' && !digits[index]) || e.key === 'ArrowLeft';
|
|
79
|
+
|
|
80
|
+
if (back && index > 0) {
|
|
81
|
+
focusSlot(index - 1);
|
|
82
|
+
} else if (e.key === 'ArrowRight' && index < length - 1) {
|
|
83
|
+
focusSlot(index + 1);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
[digits, length, focusSlot],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const handlePaste = useCallback(
|
|
90
|
+
(e: React.ClipboardEvent) => {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
const pasted = e.clipboardData.getData('text').slice(0, length);
|
|
93
|
+
|
|
94
|
+
// An empty paste is not a rejection, but it is nothing to apply either.
|
|
95
|
+
if (!pasted || !isAllowed(variant, pasted)) return;
|
|
96
|
+
|
|
97
|
+
setDigits(padTo(pasted.split(''), length));
|
|
98
|
+
publish(pasted);
|
|
99
|
+
},
|
|
100
|
+
[variant, length, publish],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return { digits, inputRefs, handleInputChange, handleKeyDown, handlePaste };
|
|
104
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { alpha } from '@mui/material';
|
|
2
|
+
import type { CSSObject, Theme } from '@mui/material';
|
|
3
|
+
|
|
4
|
+
interface ColorPalette {
|
|
5
|
+
main: string;
|
|
6
|
+
dark?: string;
|
|
7
|
+
light?: string;
|
|
8
|
+
contrastText?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** `neutral` is not a MUI palette entry, so it is built from the grey ramp. */
|
|
12
|
+
const neutralPalette = (theme: Theme): ColorPalette => ({
|
|
13
|
+
main: theme.palette.grey[700],
|
|
14
|
+
dark: theme.palette.grey[800],
|
|
15
|
+
light: theme.palette.grey[500],
|
|
16
|
+
contrastText: '#fff',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** `danger` is this component's name for the error palette. */
|
|
20
|
+
const namedPalette = (theme: Theme, color: string): ColorPalette => {
|
|
21
|
+
const colorMap: Record<string, ColorPalette> = {
|
|
22
|
+
primary: theme.palette.primary,
|
|
23
|
+
secondary: theme.palette.secondary,
|
|
24
|
+
success: theme.palette.success,
|
|
25
|
+
warning: theme.palette.warning,
|
|
26
|
+
danger: theme.palette.error,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return colorMap[color] || theme.palette.primary;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const getColorFromTheme = (theme: Theme, color: string): ColorPalette => {
|
|
33
|
+
if (color === 'neutral') {
|
|
34
|
+
return neutralPalette(theme);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const palette = namedPalette(theme, color);
|
|
38
|
+
const { primary } = theme.palette;
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
main: palette.main || primary.main,
|
|
42
|
+
dark: palette.dark || palette.main || primary.dark,
|
|
43
|
+
light: palette.light || palette.main || primary.light,
|
|
44
|
+
contrastText: palette.contrastText || '#fff',
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type SizeKey = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
|
49
|
+
|
|
50
|
+
/** Each slot is a square, so width and height always match. */
|
|
51
|
+
const SIZES: Record<SizeKey, { side: string; fontSize: string }> = {
|
|
52
|
+
xs: { side: '32px', fontSize: '0.75rem' },
|
|
53
|
+
sm: { side: '40px', fontSize: '0.875rem' },
|
|
54
|
+
md: { side: '48px', fontSize: '1rem' },
|
|
55
|
+
lg: { side: '56px', fontSize: '1.125rem' },
|
|
56
|
+
xl: { side: '64px', fontSize: '1.25rem' },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export interface OtpSlotFlags {
|
|
60
|
+
customColor?: string;
|
|
61
|
+
customSize?: string;
|
|
62
|
+
glass?: boolean;
|
|
63
|
+
gradient?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const otpSlotSx = (theme: Theme, flags: OtpSlotFlags): CSSObject => {
|
|
67
|
+
const { customColor = 'primary', customSize = 'md', glass, gradient } = flags;
|
|
68
|
+
const palette = getColorFromTheme(theme, customColor);
|
|
69
|
+
const { side, fontSize } = SIZES[customSize as SizeKey] ?? SIZES.md;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
width: side,
|
|
73
|
+
height: side,
|
|
74
|
+
'& .MuiOutlinedInput-root': {
|
|
75
|
+
width: side,
|
|
76
|
+
height: side,
|
|
77
|
+
fontSize,
|
|
78
|
+
fontWeight: 600,
|
|
79
|
+
textAlign: 'center',
|
|
80
|
+
borderRadius: theme.spacing(1),
|
|
81
|
+
...(glass && {
|
|
82
|
+
backgroundColor: alpha(theme.palette.background.paper, 0.1),
|
|
83
|
+
backdropFilter: 'blur(20px)',
|
|
84
|
+
'& fieldset': { border: `1px solid ${alpha(theme.palette.divider, 0.2)}` },
|
|
85
|
+
}),
|
|
86
|
+
...(gradient && {
|
|
87
|
+
'&.Mui-focused fieldset': {
|
|
88
|
+
background: `linear-gradient(135deg, ${palette.main}, ${palette.light})`,
|
|
89
|
+
borderWidth: '2px',
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
'& input': { textAlign: 'center', padding: 0, fontWeight: 'inherit' },
|
|
93
|
+
'&:hover fieldset': { borderColor: palette.main },
|
|
94
|
+
'&.Mui-focused fieldset': { borderColor: palette.main, borderWidth: '2px' },
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
};
|