@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,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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
389
|
-
|
|
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
|
};
|