@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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import CloseIcon from '@mui/icons-material/Close';
|
|
2
|
+
import { alpha, Backdrop, Box, Divider, Fade, IconButton, Typography, useTheme } from '@mui/material';
|
|
3
|
+
import type { Theme } from '@mui/material';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { withDefaults } from '../../../utils/withDefaults';
|
|
7
|
+
|
|
8
|
+
import { shimmerAnimation } from './Sheet.animations';
|
|
9
|
+
import type {
|
|
10
|
+
SheetContentProps,
|
|
11
|
+
SheetFooterProps,
|
|
12
|
+
SheetHeaderProps,
|
|
13
|
+
SheetOverlayProps,
|
|
14
|
+
} from './Sheet.types';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The grab handle's own styling. A draggable handle is deliberately larger and
|
|
18
|
+
* tinted, so the affordance reads as draggable before the user tries it; a
|
|
19
|
+
* decorative one stays a neutral bar.
|
|
20
|
+
*/
|
|
21
|
+
const handleSx = (theme: Theme, isDraggable: boolean) => ({
|
|
22
|
+
width: isDraggable ? 48 : 32,
|
|
23
|
+
height: isDraggable ? 6 : 4,
|
|
24
|
+
backgroundColor: alpha(theme.palette.text.primary, 0.3),
|
|
25
|
+
borderRadius: 3,
|
|
26
|
+
transition: theme.transitions.create(['all'], {
|
|
27
|
+
duration: theme.transitions.duration.short,
|
|
28
|
+
easing: theme.transitions.easing.easeInOut,
|
|
29
|
+
}),
|
|
30
|
+
position: 'relative',
|
|
31
|
+
overflow: 'hidden',
|
|
32
|
+
'&::after': {
|
|
33
|
+
content: '""',
|
|
34
|
+
position: 'absolute',
|
|
35
|
+
top: 0,
|
|
36
|
+
left: '-100%',
|
|
37
|
+
width: '100%',
|
|
38
|
+
height: '100%',
|
|
39
|
+
background: `linear-gradient(
|
|
40
|
+
90deg,
|
|
41
|
+
transparent,
|
|
42
|
+
${alpha(theme.palette.common.white, 0.3)},
|
|
43
|
+
transparent
|
|
44
|
+
)`,
|
|
45
|
+
animation: isDraggable ? `${shimmerAnimation} 2s infinite` : 'none',
|
|
46
|
+
},
|
|
47
|
+
...(isDraggable && {
|
|
48
|
+
backgroundColor: alpha(theme.palette.primary.main, 0.4),
|
|
49
|
+
boxShadow: `0 2px 4px ${alpha(theme.palette.primary.main, 0.2)}`,
|
|
50
|
+
'&:hover': {
|
|
51
|
+
backgroundColor: theme.palette.primary.main,
|
|
52
|
+
transform: 'scaleX(1.15) scaleY(1.2)',
|
|
53
|
+
boxShadow: `0 3px 6px ${alpha(theme.palette.primary.main, 0.3)}`,
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const SheetHandle: React.FC<{ isDraggable: boolean }> = ({ isDraggable }) => {
|
|
59
|
+
const theme = useTheme();
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Box
|
|
63
|
+
sx={{
|
|
64
|
+
display: 'flex',
|
|
65
|
+
justifyContent: 'center',
|
|
66
|
+
mb: 1,
|
|
67
|
+
// The handle is decoration on a draggable header: the drag listeners sit
|
|
68
|
+
// on the header itself, so letting the bar swallow the press would open
|
|
69
|
+
// a dead zone in the middle of the grab target.
|
|
70
|
+
pointerEvents: isDraggable ? 'none' : 'auto',
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<Box sx={handleSx(theme, isDraggable)} />
|
|
74
|
+
</Box>
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const HEADER_DEFAULTS: Partial<SheetHeaderProps> = {
|
|
79
|
+
isDraggable: false,
|
|
80
|
+
dataTestId: 'sheet-header',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const SheetHeader: React.FC<SheetHeaderProps> = (props) => {
|
|
84
|
+
const {
|
|
85
|
+
title,
|
|
86
|
+
description,
|
|
87
|
+
showCloseButton,
|
|
88
|
+
onClose,
|
|
89
|
+
showHandle,
|
|
90
|
+
isDraggable,
|
|
91
|
+
onDragStart,
|
|
92
|
+
className,
|
|
93
|
+
style,
|
|
94
|
+
children,
|
|
95
|
+
dataTestId,
|
|
96
|
+
} = withDefaults(props, HEADER_DEFAULTS);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<Box
|
|
100
|
+
className={className}
|
|
101
|
+
data-testid={dataTestId}
|
|
102
|
+
sx={{
|
|
103
|
+
p: 2,
|
|
104
|
+
pb: showHandle ? 1 : 2,
|
|
105
|
+
cursor: isDraggable ? 'grab' : 'auto',
|
|
106
|
+
'&:active': isDraggable ? { cursor: 'grabbing' } : {},
|
|
107
|
+
...style,
|
|
108
|
+
}}
|
|
109
|
+
onMouseDown={isDraggable ? onDragStart : undefined}
|
|
110
|
+
onTouchStart={isDraggable ? onDragStart : undefined}
|
|
111
|
+
>
|
|
112
|
+
{showHandle && <SheetHandle isDraggable={Boolean(isDraggable)} />}
|
|
113
|
+
|
|
114
|
+
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
|
115
|
+
<Box sx={{ flex: 1 }}>
|
|
116
|
+
{title && (
|
|
117
|
+
<Typography variant="h6" sx={{ fontWeight: 600 }} data-testid={`${dataTestId}-title`}>
|
|
118
|
+
{title}
|
|
119
|
+
</Typography>
|
|
120
|
+
)}
|
|
121
|
+
{description && (
|
|
122
|
+
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
|
123
|
+
{description}
|
|
124
|
+
</Typography>
|
|
125
|
+
)}
|
|
126
|
+
{children}
|
|
127
|
+
</Box>
|
|
128
|
+
|
|
129
|
+
{showCloseButton && (
|
|
130
|
+
<IconButton
|
|
131
|
+
onClick={onClose}
|
|
132
|
+
size="small"
|
|
133
|
+
sx={{ ml: 1, mt: -0.5 }}
|
|
134
|
+
data-testid={`${dataTestId}-close-button`}
|
|
135
|
+
>
|
|
136
|
+
<CloseIcon data-testid={`${dataTestId}-close-icon`} />
|
|
137
|
+
</IconButton>
|
|
138
|
+
)}
|
|
139
|
+
</Box>
|
|
140
|
+
</Box>
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const CONTENT_DEFAULTS: Partial<SheetContentProps> = {
|
|
145
|
+
padded: true,
|
|
146
|
+
dataTestId: 'sheet-content',
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export const SheetContent: React.FC<SheetContentProps> = (props) => {
|
|
150
|
+
const { children, className, style, padded, dataTestId } = withDefaults(props, CONTENT_DEFAULTS);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<Box
|
|
154
|
+
className={className}
|
|
155
|
+
data-testid={dataTestId}
|
|
156
|
+
sx={{ flex: 1, overflow: 'auto', p: padded ? 2 : 0, ...style }}
|
|
157
|
+
>
|
|
158
|
+
{children}
|
|
159
|
+
</Box>
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const FOOTER_DEFAULTS: Partial<SheetFooterProps> = {
|
|
164
|
+
sticky: false,
|
|
165
|
+
divider: false,
|
|
166
|
+
dataTestId: 'sheet-footer',
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const SheetFooter: React.FC<SheetFooterProps> = (props) => {
|
|
170
|
+
const { children, className, style, sticky, divider, dataTestId } = withDefaults(
|
|
171
|
+
props,
|
|
172
|
+
FOOTER_DEFAULTS,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<>
|
|
177
|
+
{divider && <Divider />}
|
|
178
|
+
<Box
|
|
179
|
+
className={className}
|
|
180
|
+
data-testid={dataTestId}
|
|
181
|
+
sx={{
|
|
182
|
+
p: 2,
|
|
183
|
+
position: sticky ? 'sticky' : 'relative',
|
|
184
|
+
bottom: sticky ? 0 : 'auto',
|
|
185
|
+
backgroundColor: 'background.paper',
|
|
186
|
+
borderTop: sticky ? '1px solid' : 'none',
|
|
187
|
+
borderColor: 'divider',
|
|
188
|
+
...style,
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
{children}
|
|
192
|
+
</Box>
|
|
193
|
+
</>
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const OVERLAY_DEFAULTS: Partial<SheetOverlayProps> = {
|
|
198
|
+
open: false,
|
|
199
|
+
blur: false,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const SheetOverlay: React.FC<SheetOverlayProps> = (props) => {
|
|
203
|
+
const { open, onClick, className, style, blur } = withDefaults(props, OVERLAY_DEFAULTS);
|
|
204
|
+
const theme = useTheme();
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<Fade in={open} timeout={300}>
|
|
208
|
+
<Backdrop
|
|
209
|
+
open={Boolean(open)}
|
|
210
|
+
onClick={onClick}
|
|
211
|
+
className={className}
|
|
212
|
+
sx={{
|
|
213
|
+
// One below the drawer: this backdrop replaces MUI's own (the drawer
|
|
214
|
+
// is given a no-op BackdropComponent) and must sit under the panel.
|
|
215
|
+
zIndex: theme.zIndex.drawer - 1,
|
|
216
|
+
backgroundColor: alpha(theme.palette.common.black, blur ? 0.6 : 0.5),
|
|
217
|
+
backdropFilter: blur ? 'blur(8px) saturate(180%)' : 'none',
|
|
218
|
+
WebkitBackdropFilter: blur ? 'blur(8px) saturate(180%)' : 'none',
|
|
219
|
+
...style,
|
|
220
|
+
}}
|
|
221
|
+
/>
|
|
222
|
+
</Fade>
|
|
223
|
+
);
|
|
224
|
+
};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { alpha } from '@mui/material';
|
|
2
|
+
import type { CSSObject, Theme } from '@mui/material';
|
|
3
|
+
import type React from 'react';
|
|
4
|
+
|
|
5
|
+
import { glowAnimation, pulseAnimation, shimmerAnimation } from './Sheet.animations';
|
|
6
|
+
import type { SheetProps } from './Sheet.types';
|
|
7
|
+
|
|
8
|
+
type Position = NonNullable<SheetProps['position']>;
|
|
9
|
+
type Variant = NonNullable<SheetProps['variant']>;
|
|
10
|
+
type Size = NonNullable<SheetProps['size']>;
|
|
11
|
+
type Color = NonNullable<SheetProps['color']>;
|
|
12
|
+
|
|
13
|
+
const isHorizontalPosition = (position: Position) => position === 'left' || position === 'right';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Horizontal (left/right) sheets are viewport-aware: a px floor keeps the panel
|
|
17
|
+
* usable, `…vw` lets the larger presets grow on wide screens (lg ≥32%, xl ≥40%
|
|
18
|
+
* of the viewport), and the outer `min(92vw, …)` guarantees the panel never
|
|
19
|
+
* overflows a small screen. Vertical presets stay fixed-height.
|
|
20
|
+
*/
|
|
21
|
+
const HORIZONTAL_SIZES: Record<Size, string> = {
|
|
22
|
+
xs: 'min(92vw, 240px)',
|
|
23
|
+
sm: 'min(92vw, 320px)',
|
|
24
|
+
md: 'min(92vw, 400px)',
|
|
25
|
+
lg: 'min(92vw, max(560px, 32vw))',
|
|
26
|
+
xl: 'min(92vw, max(720px, 40vw))',
|
|
27
|
+
full: '100%',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const VERTICAL_SIZES: Record<Size, number | string> = {
|
|
31
|
+
xs: 200,
|
|
32
|
+
sm: 300,
|
|
33
|
+
md: 400,
|
|
34
|
+
lg: 500,
|
|
35
|
+
xl: 600,
|
|
36
|
+
full: '100%',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
interface SizeStyleInput {
|
|
40
|
+
position: Position;
|
|
41
|
+
size: Size;
|
|
42
|
+
isDraggableVariant: boolean;
|
|
43
|
+
isVerticalSheet: boolean;
|
|
44
|
+
currentHeight: number | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sizeStyles = ({
|
|
48
|
+
position,
|
|
49
|
+
size,
|
|
50
|
+
isDraggableVariant,
|
|
51
|
+
isVerticalSheet,
|
|
52
|
+
currentHeight,
|
|
53
|
+
}: SizeStyleInput) => {
|
|
54
|
+
// The draggable variant owns its own height: the snap point decides it, so a
|
|
55
|
+
// preset would fight the drag.
|
|
56
|
+
if (isDraggableVariant && isVerticalSheet && currentHeight !== null) {
|
|
57
|
+
return { height: currentHeight };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return isHorizontalPosition(position)
|
|
61
|
+
? { width: HORIZONTAL_SIZES[size] ?? HORIZONTAL_SIZES.md }
|
|
62
|
+
: { height: VERTICAL_SIZES[size] ?? VERTICAL_SIZES.md };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** `r, g, b` for a hex colour, so it can feed an `rgba(var(--x), a)` custom property. */
|
|
66
|
+
const toRgbTriplet = (hexColor: string) => {
|
|
67
|
+
const hex = hexColor.replace('#', '');
|
|
68
|
+
const r = parseInt(hex.substring(0, 2), 16);
|
|
69
|
+
const g = parseInt(hex.substring(2, 4), 16);
|
|
70
|
+
const b = parseInt(hex.substring(4, 6), 16);
|
|
71
|
+
return `${r}, ${g}, ${b}`;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const roundedStyles = (theme: Theme, position: Position) => {
|
|
75
|
+
const radius = theme.spacing(2);
|
|
76
|
+
const byPosition: Record<Position, string> = {
|
|
77
|
+
top: `0 0 ${radius} ${radius}`,
|
|
78
|
+
bottom: `${radius} ${radius} 0 0`,
|
|
79
|
+
left: `0 ${radius} ${radius} 0`,
|
|
80
|
+
right: `${radius} 0 0 ${radius}`,
|
|
81
|
+
};
|
|
82
|
+
return { borderRadius: byPosition[position] };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
interface VariantStyleInput {
|
|
86
|
+
theme: Theme;
|
|
87
|
+
variant: Variant;
|
|
88
|
+
color: Color;
|
|
89
|
+
position: Position;
|
|
90
|
+
elevation: number;
|
|
91
|
+
glow: boolean;
|
|
92
|
+
pulse: boolean;
|
|
93
|
+
glass: boolean;
|
|
94
|
+
gradient: boolean;
|
|
95
|
+
rounded: boolean;
|
|
96
|
+
disabled: boolean;
|
|
97
|
+
isDragging: boolean;
|
|
98
|
+
isAnimating: boolean;
|
|
99
|
+
isDraggableVariant: boolean;
|
|
100
|
+
isVerticalSheet: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type SurfaceInput = VariantStyleInput & { accent: string };
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Each variant's own additions to the shared surface — never the shared part
|
|
107
|
+
* itself. Every arm of the old switch re-spread the same base, glow, pulse and
|
|
108
|
+
* rounded objects before adding these few rules; `variantStyles` applies that
|
|
109
|
+
* prefix once and lets the table hold only what actually differs.
|
|
110
|
+
*/
|
|
111
|
+
const SURFACES: Record<Variant, (input: SurfaceInput) => Record<string, unknown>> = {
|
|
112
|
+
draggable: ({ theme, elevation, accent }) => ({
|
|
113
|
+
boxShadow: `
|
|
114
|
+
${theme.shadows[Math.min(elevation + 4, 24)]},
|
|
115
|
+
0 -2px 10px 0 ${alpha(theme.palette.common.black, 0.1)}
|
|
116
|
+
`,
|
|
117
|
+
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
|
|
118
|
+
borderTop: `2px solid ${alpha(accent, 0.3)}`,
|
|
119
|
+
transition: theme.transitions.create(['transform', 'box-shadow', 'border-color'], {
|
|
120
|
+
duration: theme.transitions.duration.shorter,
|
|
121
|
+
easing: theme.transitions.easing.easeInOut,
|
|
122
|
+
}),
|
|
123
|
+
'&:hover': { borderTopColor: alpha(accent, 0.5) },
|
|
124
|
+
}),
|
|
125
|
+
|
|
126
|
+
glass: ({ theme, glass }) => ({
|
|
127
|
+
backgroundColor: alpha(theme.palette.background.paper, glass ? 0.75 : 0.95),
|
|
128
|
+
backdropFilter: 'blur(24px) saturate(180%)',
|
|
129
|
+
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
|
130
|
+
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
|
|
131
|
+
boxShadow: `
|
|
132
|
+
0 8px 32px 0 ${alpha(theme.palette.common.black, 0.15)},
|
|
133
|
+
inset 0 0 0 1px ${alpha(theme.palette.common.white, 0.1)}
|
|
134
|
+
`,
|
|
135
|
+
}),
|
|
136
|
+
|
|
137
|
+
gradient: ({ theme, color, gradient, accent }) => ({
|
|
138
|
+
background: gradient
|
|
139
|
+
? `linear-gradient(
|
|
140
|
+
135deg,
|
|
141
|
+
${theme.palette.background.paper} 0%,
|
|
142
|
+
${alpha(accent, 0.08)} 50%,
|
|
143
|
+
${alpha(theme.palette[color]?.dark || accent, 0.12)} 100%
|
|
144
|
+
)`
|
|
145
|
+
: theme.palette.background.paper,
|
|
146
|
+
position: 'relative' as const,
|
|
147
|
+
overflow: 'hidden' as const,
|
|
148
|
+
'&::before': gradient ? shimmerOverlay(theme) : {},
|
|
149
|
+
}),
|
|
150
|
+
|
|
151
|
+
elevated: ({ theme, elevation }) => ({
|
|
152
|
+
boxShadow: `
|
|
153
|
+
${theme.shadows[elevation]},
|
|
154
|
+
0 20px 40px -15px ${alpha(theme.palette.common.black, 0.15)}
|
|
155
|
+
`,
|
|
156
|
+
transform: 'translateZ(0)',
|
|
157
|
+
willChange: 'transform',
|
|
158
|
+
}),
|
|
159
|
+
|
|
160
|
+
minimal: () => ({ boxShadow: 'none', border: 'none' }),
|
|
161
|
+
|
|
162
|
+
default: () => ({}),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** The sweep of light the gradient variant runs across itself. */
|
|
166
|
+
const shimmerOverlay = (theme: Theme) => ({
|
|
167
|
+
content: '""',
|
|
168
|
+
position: 'absolute',
|
|
169
|
+
top: 0,
|
|
170
|
+
left: '-100%',
|
|
171
|
+
width: '100%',
|
|
172
|
+
height: '100%',
|
|
173
|
+
background: `linear-gradient(
|
|
174
|
+
90deg,
|
|
175
|
+
transparent,
|
|
176
|
+
${alpha(theme.palette.common.white, 0.2)},
|
|
177
|
+
transparent
|
|
178
|
+
)`,
|
|
179
|
+
animation: `${shimmerAnimation} 3s infinite`,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The parts of the surface that do not depend on the variant: the paper colour,
|
|
184
|
+
* the transition suppression while a drag or spring is driving the panel, the
|
|
185
|
+
* disabled and cursor states, and the two colour custom properties the glow and
|
|
186
|
+
* pulse keyframes read.
|
|
187
|
+
*/
|
|
188
|
+
const baseSurface = (input: VariantStyleInput, accent: string) => {
|
|
189
|
+
const { theme, disabled, isDragging, isAnimating, isDraggableVariant, isVerticalSheet } = input;
|
|
190
|
+
const colorRgb = toRgbTriplet(accent);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
backgroundColor: theme.palette.background.paper,
|
|
194
|
+
// A transition would fight the per-frame height writes while a drag or a
|
|
195
|
+
// snap animation is driving the panel.
|
|
196
|
+
transition:
|
|
197
|
+
!isDragging && !isAnimating
|
|
198
|
+
? theme.transitions.create(['all'], {
|
|
199
|
+
duration: theme.transitions.duration.standard,
|
|
200
|
+
easing: theme.transitions.easing.easeInOut,
|
|
201
|
+
})
|
|
202
|
+
: 'none',
|
|
203
|
+
opacity: disabled ? 0.5 : 1,
|
|
204
|
+
pointerEvents: disabled ? ('none' as const) : ('auto' as const),
|
|
205
|
+
cursor: isDraggableVariant && isVerticalSheet ? 'grab' : 'auto',
|
|
206
|
+
'--pulse-color': colorRgb,
|
|
207
|
+
'--glow-color': colorRgb,
|
|
208
|
+
...(isDragging && { cursor: 'grabbing' }),
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const variantStyles = (input: VariantStyleInput) => {
|
|
213
|
+
const { theme, variant, color, position, glow, pulse, rounded } = input;
|
|
214
|
+
const accent = theme.palette[color]?.main || theme.palette.primary.main;
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
...baseSurface(input, accent),
|
|
218
|
+
// glow first, then pulse: with both set the pulse keyframes win the shared
|
|
219
|
+
// `animation` slot, which is the order the original spread produced.
|
|
220
|
+
...(glow && { animation: `${glowAnimation} 2s ease-in-out infinite`, filter: 'brightness(1.05)' }),
|
|
221
|
+
...(pulse && { animation: `${pulseAnimation} 2s infinite`, position: 'relative' as const }),
|
|
222
|
+
...(rounded && roundedStyles(theme, position)),
|
|
223
|
+
...(SURFACES[variant] ?? SURFACES.default)({ ...input, accent }),
|
|
224
|
+
};
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
interface PanelSxInput extends VariantStyleInput {
|
|
228
|
+
size: Size;
|
|
229
|
+
currentHeight: number | null;
|
|
230
|
+
fullHeight: boolean;
|
|
231
|
+
style?: React.CSSProperties;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The drawer paper's complete `sx`: its size, its variant surface, the caller's
|
|
236
|
+
* own `style`, and the full-height rule.
|
|
237
|
+
*
|
|
238
|
+
* Built here rather than in the component so the component holds no style
|
|
239
|
+
* branches of its own — the cross-axis rule below is four on its own.
|
|
240
|
+
*/
|
|
241
|
+
export const panelSx = (input: PanelSxInput): CSSObject => {
|
|
242
|
+
const { position, size, isDraggableVariant, isVerticalSheet, currentHeight, fullHeight, style } =
|
|
243
|
+
input;
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
...sizeStyles({ position, size, isDraggableVariant, isVerticalSheet, currentHeight }),
|
|
247
|
+
...variantStyles(input),
|
|
248
|
+
...style,
|
|
249
|
+
overflow: 'visible',
|
|
250
|
+
// Expand only the cross axis so the chosen `size` still governs the main
|
|
251
|
+
// axis: side sheets keep their (responsive) width and go full-height;
|
|
252
|
+
// top/bottom sheets keep their height and go full-width.
|
|
253
|
+
...(fullHeight &&
|
|
254
|
+
!isDraggableVariant &&
|
|
255
|
+
(isHorizontalPosition(position) ? { height: '100%' } : { width: '100%' })),
|
|
256
|
+
};
|
|
257
|
+
};
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from '@mui/material';
|
|
13
13
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
14
14
|
import React, { useState } from 'react';
|
|
15
|
-
import { expect, fn,userEvent, waitFor, within } from 'storybook/test';
|
|
15
|
+
import { expect, fireEvent, fn,userEvent, waitFor, within } from 'storybook/test';
|
|
16
16
|
|
|
17
17
|
import { Sheet } from './Sheet';
|
|
18
18
|
|
|
@@ -331,7 +331,7 @@ export const KeyboardNavigation: Story = {
|
|
|
331
331
|
const firstElement = body.getByTestId('first-focusable');
|
|
332
332
|
const secondElement = body.getByLabelText('Input Field') as HTMLElement;
|
|
333
333
|
|
|
334
|
-
|
|
334
|
+
await userEvent.click(firstElement);
|
|
335
335
|
await waitFor(() => {
|
|
336
336
|
expect(firstElement).toHaveFocus();
|
|
337
337
|
});
|
|
@@ -459,7 +459,9 @@ export const FocusManagement: Story = {
|
|
|
459
459
|
showCloseButton
|
|
460
460
|
onClose={() => {
|
|
461
461
|
setOpen(false);
|
|
462
|
-
//
|
|
462
|
+
// Returning focus to the trigger is the component's own job here,
|
|
463
|
+
// not the test positioning itself.
|
|
464
|
+
// eslint-disable-next-line test-flakiness/no-focus-check -- component behaviour, not a test action
|
|
463
465
|
triggerRef.current?.focus();
|
|
464
466
|
}}
|
|
465
467
|
>
|
|
@@ -492,7 +494,7 @@ export const FocusManagement: Story = {
|
|
|
492
494
|
|
|
493
495
|
// Manually focus the element since autoFocus might not work in tests
|
|
494
496
|
const firstElement = body.getByTestId('first-modal-element');
|
|
495
|
-
|
|
497
|
+
await userEvent.click(firstElement);
|
|
496
498
|
await waitFor(() => {
|
|
497
499
|
expect(firstElement).toHaveFocus();
|
|
498
500
|
});
|
|
@@ -597,7 +599,10 @@ export const ResponsiveDesign: Story = {
|
|
|
597
599
|
const container = body.getByTestId('responsive-container');
|
|
598
600
|
const computedStyle = window.getComputedStyle(container);
|
|
599
601
|
|
|
600
|
-
|
|
602
|
+
// Measured off the container rather than the window: it is the box the
|
|
603
|
+
// component actually occupies, and it keeps the assertion about the
|
|
604
|
+
// element under test rather than about the screen the story runs on.
|
|
605
|
+
if (container.clientWidth <= 600) {
|
|
601
606
|
await expect(computedStyle.flexDirection).toBe('column');
|
|
602
607
|
} else {
|
|
603
608
|
await expect(computedStyle.flexDirection).toBe('row');
|
|
@@ -821,31 +826,30 @@ export const Performance: Story = {
|
|
|
821
826
|
});
|
|
822
827
|
});
|
|
823
828
|
|
|
824
|
-
await step('
|
|
825
|
-
const startTime = window.performance.now();
|
|
829
|
+
await step('All items render', async () => {
|
|
826
830
|
const elements = body.getAllByTestId(/item-/);
|
|
827
|
-
const endTime = window.performance.now();
|
|
828
831
|
|
|
829
|
-
|
|
830
|
-
//
|
|
831
|
-
// console.log(`Render time for ${elements.length} items: ${renderTime}ms`);
|
|
832
|
-
|
|
833
|
-
// Assert reasonable render time and elements are rendered
|
|
832
|
+
// The count is the assertion; a wall-clock budget would only measure
|
|
833
|
+
// whatever machine the story happens to run on.
|
|
834
834
|
await expect(elements.length).toBeGreaterThan(0);
|
|
835
|
-
await expect(renderTime).toBeLessThan(1000);
|
|
836
835
|
});
|
|
837
836
|
|
|
838
837
|
await step('Test scroll performance', async () => {
|
|
839
838
|
const scrollContainer = body.getByTestId('scroll-container');
|
|
840
839
|
|
|
841
|
-
//
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
840
|
+
// Scrolling is what this story is about, so it has to move the container;
|
|
841
|
+
// what it must not do is assert a pixel offset, which depends on the
|
|
842
|
+
// viewport the story renders in.
|
|
843
|
+
// eslint-disable-next-line test-flakiness/no-viewport-dependent -- the subject under test is scrolling itself
|
|
844
|
+
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
|
845
|
+
fireEvent.scroll(scrollContainer);
|
|
846
846
|
|
|
847
|
-
//
|
|
848
|
-
|
|
847
|
+
// The list is not virtualised, so every item must survive the scroll —
|
|
848
|
+
// which the old assertion on the container could never have caught, since
|
|
849
|
+
// the container was already in the document before the loop ran.
|
|
850
|
+
await waitFor(() => {
|
|
851
|
+
expect(body.getAllByTestId(/^item-\d+$/).length).toBeGreaterThan(0);
|
|
852
|
+
});
|
|
849
853
|
});
|
|
850
854
|
},
|
|
851
855
|
};
|