@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,346 @@
1
+ import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
2
+ import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
3
+ import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
4
+ import { alpha, Box, IconButton, Typography, useTheme } from '@mui/material';
5
+ import type { CSSObject, Theme } from '@mui/material';
6
+ import React from 'react';
7
+
8
+ import { withDefaults } from '../../../utils/withDefaults';
9
+
10
+ import type {
11
+ CarouselArrowsProps,
12
+ CarouselIndicatorsProps,
13
+ CarouselItem,
14
+ CarouselThumbnailsProps,
15
+ } from './Carousel.types';
16
+
17
+ type IndicatorPosition = NonNullable<CarouselIndicatorsProps['position']>;
18
+ type IndicatorVariant = NonNullable<CarouselIndicatorsProps['variant']>;
19
+ type Color = NonNullable<CarouselIndicatorsProps['color']>;
20
+
21
+ /** Fires `onSelect` for the keys a native button would treat as activation. */
22
+ const activateOnKey = (onSelect: () => void) => (e: React.KeyboardEvent) => {
23
+ if (e.key === 'Enter' || e.key === ' ') {
24
+ e.preventDefault();
25
+ onSelect();
26
+ }
27
+ };
28
+
29
+ const INDICATOR_POSITIONS = (theme: Theme): Record<IndicatorPosition, CSSObject> => ({
30
+ top: { top: theme.spacing(2), left: '50%', transform: 'translateX(-50%)' },
31
+ bottom: { bottom: theme.spacing(2), left: '50%', transform: 'translateX(-50%)' },
32
+ left: {
33
+ left: theme.spacing(2),
34
+ top: '50%',
35
+ transform: 'translateY(-50%)',
36
+ flexDirection: 'column',
37
+ },
38
+ right: {
39
+ right: theme.spacing(2),
40
+ top: '50%',
41
+ transform: 'translateY(-50%)',
42
+ flexDirection: 'column',
43
+ },
44
+ });
45
+
46
+ interface IndicatorProps {
47
+ index: number;
48
+ isActive: boolean;
49
+ color: Color;
50
+ onSelect: () => void;
51
+ }
52
+
53
+ /** Attributes every indicator variant carries, whatever it draws. */
54
+ const indicatorAttrs = (index: number, isActive: boolean) => ({
55
+ 'aria-label': `Go to slide ${index + 1}`,
56
+ 'data-testid': `carousel-indicator-${index}`,
57
+ 'data-active': isActive,
58
+ });
59
+
60
+ const LineIndicator: React.FC<IndicatorProps> = ({ index, isActive, color, onSelect }) => {
61
+ const theme = useTheme();
62
+
63
+ return (
64
+ <Box
65
+ role="button"
66
+ tabIndex={0}
67
+ {...indicatorAttrs(index, isActive)}
68
+ sx={{
69
+ width: isActive ? 30 : 20,
70
+ height: 3,
71
+ backgroundColor: isActive
72
+ ? theme.palette[color].main
73
+ : alpha(theme.palette[color].main, 0.3),
74
+ borderRadius: 1.5,
75
+ transition: theme.transitions.create(['width', 'background-color']),
76
+ cursor: 'pointer',
77
+ }}
78
+ onClick={onSelect}
79
+ onKeyDown={activateOnKey(onSelect)}
80
+ />
81
+ );
82
+ };
83
+
84
+ const NumberIndicator: React.FC<IndicatorProps> = ({ index, isActive, color, onSelect }) => {
85
+ const theme = useTheme();
86
+
87
+ return (
88
+ <Box
89
+ role="button"
90
+ tabIndex={0}
91
+ {...indicatorAttrs(index, isActive)}
92
+ sx={{
93
+ width: 24,
94
+ height: 24,
95
+ display: 'flex',
96
+ alignItems: 'center',
97
+ justifyContent: 'center',
98
+ borderRadius: '50%',
99
+ backgroundColor: isActive ? theme.palette[color].main : 'transparent',
100
+ color: isActive ? theme.palette[color].contrastText : theme.palette[color].main,
101
+ border: `1px solid ${theme.palette[color].main}`,
102
+ fontSize: '0.75rem',
103
+ fontWeight: isActive ? 'bold' : 'normal',
104
+ cursor: 'pointer',
105
+ transition: theme.transitions.create(['all']),
106
+ }}
107
+ onClick={onSelect}
108
+ onKeyDown={activateOnKey(onSelect)}
109
+ >
110
+ {index + 1}
111
+ </Box>
112
+ );
113
+ };
114
+
115
+ const DotIndicator: React.FC<IndicatorProps> = ({ index, isActive, color, onSelect }) => {
116
+ const theme = useTheme();
117
+
118
+ return (
119
+ <IconButton
120
+ size="small"
121
+ {...indicatorAttrs(index, isActive)}
122
+ onClick={onSelect}
123
+ sx={{
124
+ p: 0.5,
125
+ color: isActive ? theme.palette[color].main : alpha(theme.palette[color].main, 0.3),
126
+ transform: isActive ? 'scale(1.2)' : 'scale(1)',
127
+ transition: theme.transitions.create(['transform', 'color']),
128
+ }}
129
+ >
130
+ <FiberManualRecordIcon fontSize="small" />
131
+ </IconButton>
132
+ );
133
+ };
134
+
135
+ const INDICATORS: Record<IndicatorVariant, React.FC<IndicatorProps>> = {
136
+ lines: LineIndicator,
137
+ numbers: NumberIndicator,
138
+ dots: DotIndicator,
139
+ };
140
+
141
+ const INDICATORS_DEFAULTS = {
142
+ position: 'bottom',
143
+ color: 'primary',
144
+ variant: 'dots',
145
+ } satisfies Partial<CarouselIndicatorsProps>;
146
+
147
+ export const CarouselIndicators: React.FC<CarouselIndicatorsProps> = (props) => {
148
+ const { count, activeIndex, onSelect, position, color, variant, className, style } = withDefaults(
149
+ props,
150
+ INDICATORS_DEFAULTS,
151
+ ) as CarouselIndicatorsProps & Required<Pick<CarouselIndicatorsProps, keyof typeof INDICATORS_DEFAULTS>>;
152
+
153
+ const theme = useTheme();
154
+ const Indicator = INDICATORS[variant] ?? DotIndicator;
155
+
156
+ return (
157
+ <Box
158
+ className={className}
159
+ data-testid="carousel-indicators"
160
+ sx={{
161
+ position: 'absolute',
162
+ display: 'flex',
163
+ gap: 1,
164
+ zIndex: 2,
165
+ ...(INDICATOR_POSITIONS(theme)[position] ?? INDICATOR_POSITIONS(theme).bottom),
166
+ ...style,
167
+ }}
168
+ >
169
+ {Array.from({ length: count }).map((_, index) => (
170
+ <Indicator
171
+ key={index}
172
+ index={index}
173
+ isActive={index === activeIndex}
174
+ color={color}
175
+ onSelect={() => onSelect(index)}
176
+ />
177
+ ))}
178
+ </Box>
179
+ );
180
+ };
181
+
182
+ const ARROWS_DEFAULTS = {
183
+ position: 'overlay',
184
+ disabled: false,
185
+ disablePrev: false,
186
+ disableNext: false,
187
+ color: 'primary',
188
+ size: 'medium',
189
+ } satisfies Partial<CarouselArrowsProps>;
190
+
191
+ /**
192
+ * All three positions place the arrow the same way; only `overlay` fades it back
193
+ * until hovered, and only `outside` sits it clear of the frame.
194
+ */
195
+ const arrowSx = (theme: Theme, color: Color, isOverlay: boolean): CSSObject => ({
196
+ position: 'absolute',
197
+ top: '50%',
198
+ transform: 'translateY(-50%)',
199
+ zIndex: 3,
200
+ backgroundColor: alpha(theme.palette.background.paper, 0.8),
201
+ color: theme.palette[color].main,
202
+ '&:disabled': { opacity: 0.3 },
203
+ ...(isOverlay
204
+ ? {
205
+ opacity: 0.7,
206
+ '&:hover': { backgroundColor: theme.palette.background.paper, opacity: 1 },
207
+ }
208
+ : { '&:hover': { backgroundColor: theme.palette.background.paper } }),
209
+ });
210
+
211
+ export const CarouselArrows: React.FC<CarouselArrowsProps> = (props) => {
212
+ const {
213
+ onPrev, onNext, position, disabled, disablePrev, disableNext, color, size, className, style,
214
+ } = withDefaults(props, ARROWS_DEFAULTS) as CarouselArrowsProps &
215
+ Required<Pick<CarouselArrowsProps, keyof typeof ARROWS_DEFAULTS>>;
216
+
217
+ const theme = useTheme();
218
+ const sx = arrowSx(theme, color, position === 'overlay');
219
+ const offset = position === 'outside' ? -40 : 8;
220
+
221
+ return (
222
+ <Box data-testid="carousel-navigation">
223
+ <IconButton
224
+ onClick={onPrev}
225
+ disabled={disabled || disablePrev}
226
+ size={size}
227
+ className={className}
228
+ aria-label="Previous slide"
229
+ data-testid="carousel-prev-button"
230
+ sx={{ ...sx, left: offset, ...style }}
231
+ >
232
+ <ArrowBackIosIcon />
233
+ </IconButton>
234
+
235
+ <IconButton
236
+ onClick={onNext}
237
+ disabled={disabled || disableNext}
238
+ aria-label="Next slide"
239
+ size={size}
240
+ className={className}
241
+ data-testid="carousel-next-button"
242
+ sx={{ ...sx, right: offset, ...style }}
243
+ >
244
+ <ArrowForwardIosIcon />
245
+ </IconButton>
246
+ </Box>
247
+ );
248
+ };
249
+
250
+ const THUMBNAIL_SIZES = { xs: 40, sm: 60, md: 80, lg: 100, xl: 120 } as const;
251
+ const DEFAULT_THUMBNAIL_SIZE = 60;
252
+
253
+ const Thumbnail: React.FC<{
254
+ item: CarouselItem;
255
+ index: number;
256
+ isActive: boolean;
257
+ side: number;
258
+ onSelect: () => void;
259
+ }> = ({ item, index, isActive, side, onSelect }) => {
260
+ const theme = useTheme();
261
+
262
+ return (
263
+ <Box
264
+ onClick={onSelect}
265
+ data-testid={`carousel-thumbnail-${index}`}
266
+ data-active={isActive}
267
+ sx={{
268
+ width: side,
269
+ height: side,
270
+ border: `2px solid ${isActive ? theme.palette.primary.main : 'transparent'}`,
271
+ borderRadius: 1,
272
+ overflow: 'hidden',
273
+ cursor: 'pointer',
274
+ opacity: isActive ? 1 : 0.6,
275
+ transition: theme.transitions.create(['opacity', 'border-color']),
276
+ '&:hover': { opacity: 1 },
277
+ }}
278
+ >
279
+ {item.image ? (
280
+ <Box
281
+ component="img"
282
+ src={item.image}
283
+ alt={item.alt || item.title}
284
+ sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
285
+ />
286
+ ) : (
287
+ // No image to show, so the slide's number stands in for it.
288
+ <Box
289
+ sx={{
290
+ width: '100%',
291
+ height: '100%',
292
+ backgroundColor: theme.palette.grey[300],
293
+ display: 'flex',
294
+ alignItems: 'center',
295
+ justifyContent: 'center',
296
+ }}
297
+ >
298
+ <Typography variant="caption">{index + 1}</Typography>
299
+ </Box>
300
+ )}
301
+ </Box>
302
+ );
303
+ };
304
+
305
+ export const CarouselThumbnails: React.FC<CarouselThumbnailsProps> = ({
306
+ items,
307
+ activeIndex,
308
+ onSelect,
309
+ size = 'sm',
310
+ className,
311
+ style,
312
+ }) => {
313
+ const theme = useTheme();
314
+ const side = THUMBNAIL_SIZES[size as keyof typeof THUMBNAIL_SIZES] ?? DEFAULT_THUMBNAIL_SIZE;
315
+
316
+ return (
317
+ <Box
318
+ className={className}
319
+ data-testid="carousel-thumbnails"
320
+ sx={{
321
+ position: 'absolute',
322
+ // Hung below the frame, clear of its own height plus a gap.
323
+ bottom: -side - 16,
324
+ left: '50%',
325
+ transform: 'translateX(-50%)',
326
+ display: 'flex',
327
+ gap: 1,
328
+ p: 1,
329
+ backgroundColor: alpha(theme.palette.background.paper, 0.9),
330
+ borderRadius: 1,
331
+ ...style,
332
+ }}
333
+ >
334
+ {items.map((item, index) => (
335
+ <Thumbnail
336
+ key={item.id}
337
+ item={item}
338
+ index={index}
339
+ isActive={index === activeIndex}
340
+ side={side}
341
+ onSelect={() => onSelect(index)}
342
+ />
343
+ ))}
344
+ </Box>
345
+ );
346
+ };
@@ -0,0 +1,190 @@
1
+ import { Box, Paper, Typography, useTheme } from '@mui/material';
2
+ import React from 'react';
3
+
4
+ import type { CarouselItem, CarouselProps } from './Carousel.types';
5
+
6
+ export interface CarouselSlideProps {
7
+ item: CarouselItem;
8
+ index: number;
9
+ isActive: boolean;
10
+ variant: NonNullable<CarouselProps['variant']>;
11
+ clickable: boolean;
12
+ onSelect: (item: CarouselItem, index: number) => void;
13
+ }
14
+
15
+ /**
16
+ * A slide's caption. Over an image it is white with a shadow so it stays legible
17
+ * against whatever the photograph happens to be; on a bare surface it uses the
18
+ * ordinary text colours.
19
+ */
20
+ const SlideCaption: React.FC<Pick<CarouselItem, 'title' | 'description' | 'content' | 'image'>> = ({
21
+ title,
22
+ description,
23
+ content,
24
+ image,
25
+ }) => (
26
+ <Box sx={{ position: 'relative', zIndex: 1, textAlign: 'center', p: 3 }}>
27
+ {title && (
28
+ <Typography
29
+ variant="h5"
30
+ sx={{
31
+ color: image ? 'white' : 'text.primary',
32
+ textShadow: image ? '0 2px 4px rgba(0,0,0,0.5)' : 'none',
33
+ mb: 1,
34
+ }}
35
+ >
36
+ {title}
37
+ </Typography>
38
+ )}
39
+
40
+ {description && (
41
+ <Typography
42
+ variant="body1"
43
+ sx={{
44
+ color: image ? 'white' : 'text.secondary',
45
+ textShadow: image ? '0 1px 2px rgba(0,0,0,0.5)' : 'none',
46
+ }}
47
+ >
48
+ {description}
49
+ </Typography>
50
+ )}
51
+
52
+ {content}
53
+ </Box>
54
+ );
55
+
56
+ export const CarouselSlide: React.FC<CarouselSlideProps> = ({
57
+ item,
58
+ index,
59
+ isActive,
60
+ variant,
61
+ clickable,
62
+ onSelect,
63
+ }) => {
64
+ const theme = useTheme();
65
+
66
+ const content = (
67
+ <Box
68
+ sx={{
69
+ width: '100%',
70
+ height: '100%',
71
+ display: 'flex',
72
+ flexDirection: 'column',
73
+ justifyContent: 'center',
74
+ alignItems: 'center',
75
+ cursor: clickable ? 'pointer' : 'default',
76
+ position: 'relative',
77
+ }}
78
+ onClick={() => onSelect(item, index)}
79
+ >
80
+ {item.image && (
81
+ <Box
82
+ component="img"
83
+ src={item.image}
84
+ alt={item.alt || item.title}
85
+ sx={{
86
+ width: '100%',
87
+ height: '100%',
88
+ objectFit: 'cover',
89
+ position: 'absolute',
90
+ top: 0,
91
+ left: 0,
92
+ }}
93
+ />
94
+ )}
95
+
96
+ <SlideCaption
97
+ title={item.title}
98
+ description={item.description}
99
+ content={item.content}
100
+ image={item.image}
101
+ />
102
+ </Box>
103
+ );
104
+
105
+ // The cards variant gives each slide its own raised surface, and lifts the
106
+ // active one further so the stack reads as a deck rather than a strip.
107
+ if (variant === 'cards') {
108
+ return (
109
+ <Paper
110
+ elevation={isActive ? 8 : 2}
111
+ sx={{
112
+ width: '100%',
113
+ height: '100%',
114
+ overflow: 'hidden',
115
+ transform: isActive ? 'scale(1)' : 'scale(0.9)',
116
+ transition: theme.transitions.create(['transform', 'box-shadow']),
117
+ }}
118
+ >
119
+ {content}
120
+ </Paper>
121
+ );
122
+ }
123
+
124
+ return content;
125
+ };
126
+
127
+ export interface CarouselTrackProps {
128
+ items: CarouselItem[];
129
+ activeIndex: number;
130
+ variant: NonNullable<CarouselProps['variant']>;
131
+ animation: NonNullable<CarouselProps['animation']>;
132
+ transition: React.ElementType;
133
+ clickable: boolean;
134
+ onSelect: (item: CarouselItem, index: number) => void;
135
+ }
136
+
137
+ /**
138
+ * The slides themselves. Every slide stays mounted and absolutely positioned so
139
+ * the transition has both the outgoing and incoming one to work with; `display`
140
+ * is what actually hides the rest.
141
+ */
142
+ export const CarouselTrack: React.FC<CarouselTrackProps> = ({
143
+ items,
144
+ activeIndex,
145
+ variant,
146
+ animation,
147
+ transition: Transition,
148
+ clickable,
149
+ onSelect,
150
+ }) => (
151
+ <Box
152
+ data-testid="carousel-track"
153
+ sx={{
154
+ width: '100%',
155
+ height: '100%',
156
+ position: 'relative',
157
+ display: 'flex',
158
+ alignItems: 'center',
159
+ }}
160
+ >
161
+ {items.map((item, index) => (
162
+ <Transition
163
+ key={item.id}
164
+ in={index === activeIndex}
165
+ timeout={600}
166
+ {...(animation === 'flip' && { direction: 'left' as const })}
167
+ >
168
+ <Box
169
+ data-testid={`carousel-slide-${index}`}
170
+ data-active={index === activeIndex}
171
+ sx={{
172
+ position: 'absolute',
173
+ width: '100%',
174
+ height: '100%',
175
+ display: index === activeIndex ? 'block' : 'none',
176
+ }}
177
+ >
178
+ <CarouselSlide
179
+ item={item}
180
+ index={index}
181
+ isActive={index === activeIndex}
182
+ variant={variant}
183
+ clickable={clickable}
184
+ onSelect={onSelect}
185
+ />
186
+ </Box>
187
+ </Transition>
188
+ ))}
189
+ </Box>
190
+ );
@@ -0,0 +1,86 @@
1
+ import { alpha } from '@mui/material';
2
+ import type { CSSObject, Theme } from '@mui/material';
3
+
4
+ import type { CarouselProps } from './Carousel.types';
5
+
6
+ type Variant = NonNullable<CarouselProps['variant']>;
7
+ type Size = NonNullable<CarouselProps['size']>;
8
+ type Color = NonNullable<CarouselProps['color']>;
9
+
10
+ const SIZES: Record<Size, { height: number; fontSize: string }> = {
11
+ xs: { height: 200, fontSize: '0.75rem' },
12
+ sm: { height: 300, fontSize: '0.875rem' },
13
+ md: { height: 400, fontSize: '1rem' },
14
+ lg: { height: 500, fontSize: '1.125rem' },
15
+ xl: { height: 600, fontSize: '1.25rem' },
16
+ };
17
+
18
+ const sizeStyles = (size: Size) => SIZES[size] ?? SIZES.md;
19
+
20
+ interface ContainerStyleInput {
21
+ theme: Theme;
22
+ variant: Variant;
23
+ size: Size;
24
+ color: Color;
25
+ height: number | string;
26
+ width: number | string;
27
+ glow: boolean;
28
+ pulse: boolean;
29
+ glass: boolean;
30
+ gradient: boolean;
31
+ }
32
+
33
+ /**
34
+ * Each variant's own additions to the shared frame. The old switch re-spread the
35
+ * same base, glow and pulse objects in all six arms before adding these; only
36
+ * these differ, so the shared prefix is applied once by `containerStyles`.
37
+ */
38
+ const SURFACES: Record<Variant, (input: ContainerStyleInput) => CSSObject> = {
39
+ glass: ({ theme, color, glass }) => ({
40
+ backgroundColor: alpha(theme.palette.background.paper, glass ? 0.1 : 0.9),
41
+ backdropFilter: 'blur(20px)',
42
+ border: `1px solid ${alpha(theme.palette[color].main, 0.2)}`,
43
+ }),
44
+
45
+ gradient: ({ theme, color, gradient }) => ({
46
+ background: gradient
47
+ ? `linear-gradient(135deg, ${alpha(theme.palette[color].light, 0.1)}, ${alpha(theme.palette[color].dark, 0.1)})`
48
+ : 'transparent',
49
+ }),
50
+
51
+ elevated: ({ theme }) => ({ boxShadow: theme.shadows[8] }),
52
+
53
+ minimal: () => ({ border: 'none', boxShadow: 'none' }),
54
+
55
+ cards: ({ theme }) => ({ padding: theme.spacing(2) }),
56
+
57
+ default: () => ({}),
58
+ };
59
+
60
+ export const containerStyles = (input: ContainerStyleInput): CSSObject => {
61
+ const { theme, variant, size, color, height, width, glow, pulse } = input;
62
+ const accent = theme.palette[color].main;
63
+
64
+ return {
65
+ position: 'relative' as const,
66
+ width,
67
+ // A numeric height is taken literally; anything else falls back to the size
68
+ // preset, since a percentage cannot resolve against an unsized parent here.
69
+ height: typeof height === 'number' ? height : sizeStyles(size).height,
70
+ overflow: 'hidden',
71
+ borderRadius: theme.spacing(1),
72
+ transition: theme.transitions.create(['all'], {
73
+ duration: theme.transitions.duration.standard,
74
+ }),
75
+ ...(glow && { boxShadow: `0 0 30px ${alpha(accent, 0.4)}` }),
76
+ ...(pulse && {
77
+ animation: 'pulse 2s infinite',
78
+ '@keyframes pulse': {
79
+ '0%': { boxShadow: `0 0 0 0 ${alpha(accent, 0.4)}` },
80
+ '70%': { boxShadow: `0 0 0 20px ${alpha(accent, 0)}` },
81
+ '100%': { boxShadow: `0 0 0 0 ${alpha(accent, 0)}` },
82
+ },
83
+ }),
84
+ ...(SURFACES[variant] ?? SURFACES.default)(input),
85
+ };
86
+ };
@@ -209,7 +209,11 @@ export const Autoplay: Story = {
209
209
  if (carousel) {
210
210
  await userEvent.hover(carousel);
211
211
 
212
- // Wait a bit to ensure it doesn't advance
212
+ // This assertion is that nothing happens, which no query can await: a
213
+ // findBy would resolve on the slide already showing, and waitFor passes
214
+ // the instant its condition holds. Proving the timer is paused needs more
215
+ // than one autoplay interval to actually elapse.
216
+ // eslint-disable-next-line test-flakiness/no-hard-coded-timeout -- negative assertion over elapsed time
213
217
  await new Promise((resolve) => window.setTimeout(resolve, 1500));
214
218
 
215
219
  // Should still be on slide 2
@@ -252,7 +256,7 @@ export const KeyboardNavigation: Story = {
252
256
  // Focus on the carousel container
253
257
  const carousel = canvasElement.querySelector('[class*="MuiBox-root"]');
254
258
  if (carousel) {
255
- carousel.focus();
259
+ await userEvent.click(carousel);
256
260
 
257
261
  // Press right arrow key
258
262
  await userEvent.keyboard('{ArrowRight}');
@@ -330,7 +334,7 @@ export const FocusManagement: Story = {
330
334
  // Focus on carousel container
331
335
  const carousel = canvasElement.querySelector('[class*="MuiBox-root"]');
332
336
  if (carousel instanceof HTMLElement) {
333
- carousel.focus();
337
+ await userEvent.click(carousel);
334
338
 
335
339
  // Tab through interactive elements
336
340
  await userEvent.tab();
@@ -385,8 +389,14 @@ export const ResponsiveDesign: Story = {
385
389
  const styles = window.getComputedStyle(carousel);
386
390
  expect(styles.width).toBeTruthy();
387
391
 
388
- // Verify responsive behavior
389
- expect(carousel.clientWidth).toBeLessThanOrEqual(window.innerWidth);
392
+ // Responsive here means the carousel fills the box it is given, whatever
393
+ // that box is — comparing it to the viewport would hold on any screen and
394
+ // so would pass even if the carousel ignored its container entirely.
395
+ const parent = carousel.parentElement;
396
+ expect(carousel.clientWidth).toBeGreaterThan(0);
397
+ if (parent) {
398
+ expect(carousel.clientWidth).toBeLessThanOrEqual(parent.clientWidth);
399
+ }
390
400
  }
391
401
  },
392
402
  };