@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.
Files changed (66) hide show
  1. package/eslint.config.js +12 -0
  2. package/package.json +5 -4
  3. package/src/components/data-display/AlertDialog/AlertDialog.test.stories.tsx +16 -6
  4. package/src/components/data-display/Banner/Banner.test.stories.tsx +26 -18
  5. package/src/components/data-display/Carousel/Carousel.chrome.tsx +83 -0
  6. package/src/components/data-display/Carousel/Carousel.hooks.ts +88 -0
  7. package/src/components/data-display/Carousel/Carousel.parts.tsx +346 -0
  8. package/src/components/data-display/Carousel/Carousel.slide.tsx +190 -0
  9. package/src/components/data-display/Carousel/Carousel.styles.ts +86 -0
  10. package/src/components/data-display/Carousel/Carousel.test.stories.tsx +15 -5
  11. package/src/components/data-display/Carousel/Carousel.tsx +84 -732
  12. package/src/components/data-display/HoverCard/HoverCard.animations.ts +73 -0
  13. package/src/components/data-display/HoverCard/HoverCard.content.tsx +143 -0
  14. package/src/components/data-display/HoverCard/HoverCard.hooks.ts +180 -0
  15. package/src/components/data-display/HoverCard/HoverCard.styles.ts +178 -0
  16. package/src/components/data-display/HoverCard/HoverCard.tsx +117 -631
  17. package/src/components/data-display/Sheet/Sheet.animations.ts +44 -0
  18. package/src/components/data-display/Sheet/Sheet.body.tsx +124 -0
  19. package/src/components/data-display/Sheet/Sheet.drag.ts +203 -0
  20. package/src/components/data-display/Sheet/Sheet.hooks.ts +318 -0
  21. package/src/components/data-display/Sheet/Sheet.parts.tsx +224 -0
  22. package/src/components/data-display/Sheet/Sheet.styles.ts +257 -0
  23. package/src/components/data-display/Sheet/Sheet.test.stories.tsx +25 -21
  24. package/src/components/data-display/Sheet/Sheet.tsx +125 -948
  25. package/src/components/feedback/Dialog/Dialog.test.stories.tsx +37 -28
  26. package/src/components/feedback/Modal/Modal.styles.ts +126 -0
  27. package/src/components/feedback/Modal/Modal.test.stories.tsx +5 -5
  28. package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
  29. package/src/components/feedback/Modal/Modal.tsx +37 -242
  30. package/src/components/feedback/StackedModal/StackedModal.test.stories.tsx +9 -11
  31. package/src/components/form/InputOTP/InputOTP.hooks.ts +104 -0
  32. package/src/components/form/InputOTP/InputOTP.styles.ts +97 -0
  33. package/src/components/form/InputOTP/InputOTP.tsx +74 -221
  34. package/src/components/form/Label/Label.parts.tsx +75 -0
  35. package/src/components/form/Label/Label.styles.ts +206 -0
  36. package/src/components/form/Label/Label.test.stories.tsx +20 -14
  37. package/src/components/form/Label/Label.tsx +73 -325
  38. package/src/components/form/Menubar/Menubar.hooks.ts +41 -0
  39. package/src/components/form/Menubar/Menubar.item.tsx +121 -0
  40. package/src/components/form/Menubar/Menubar.separator.tsx +25 -0
  41. package/src/components/form/Menubar/Menubar.styles.ts +110 -0
  42. package/src/components/form/Menubar/Menubar.toolbar.tsx +103 -0
  43. package/src/components/form/Menubar/Menubar.tsx +64 -347
  44. package/src/components/form/RadioGroup/RadioGroup.animations.ts +48 -0
  45. package/src/components/form/RadioGroup/RadioGroup.styles.ts +305 -0
  46. package/src/components/form/RadioGroup/RadioGroup.tsx +81 -651
  47. package/src/components/form/RadioGroup/RadioGroup.variants.tsx +291 -0
  48. package/src/components/form/Slider/Slider.animations.ts +40 -0
  49. package/src/components/form/Slider/Slider.styles.ts +292 -0
  50. package/src/components/form/Slider/Slider.tsx +106 -357
  51. package/src/components/form/Switch/Switch.animations.ts +62 -0
  52. package/src/components/form/Switch/Switch.parts.tsx +213 -0
  53. package/src/components/form/Switch/Switch.styles.ts +355 -0
  54. package/src/components/form/Switch/Switch.tsx +87 -566
  55. package/src/components/form/ToggleGroup/ToggleGroup.test.stories.tsx +26 -29
  56. package/src/components/layout/Resizable/Resizable.test.stories.tsx +11 -7
  57. package/src/components/layout/Spacer/Spacer.test.stories.tsx +11 -9
  58. package/src/components/typography/Blockquote/Blockquote.test.stories.tsx +7 -7
  59. package/src/components/typography/Heading/Heading.styles.ts +101 -0
  60. package/src/components/typography/Heading/Heading.tsx +48 -164
  61. package/src/components/typography/Text/Text.test.stories.tsx +27 -30
  62. package/src/components/utility/AnimatedIcon/AnimatedIcon.animations.ts +274 -0
  63. package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +231 -0
  64. package/src/components/utility/AnimatedIcon/AnimatedIcon.tsx +39 -559
  65. package/src/utils/index.ts +1 -0
  66. 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
- firstElement.focus();
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
- // Return focus to trigger
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
- firstElement.focus();
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
- if (window.innerWidth <= 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('Measure render time', async () => {
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
- const renderTime = endTime - startTime;
830
- // Log render time for debugging (can be removed in production)
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
- // Simulate rapid scrolling
842
- for (let i = 0; i < 5; i++) {
843
- scrollContainer.scrollTop = i * 50;
844
- await new Promise((resolve) => window.setTimeout(resolve, 50));
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
- // Verify no janky behavior
848
- await expect(scrollContainer).toBeInTheDocument();
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
  };