@12-apps/ui 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/components/data-display/Carousel/Carousel.chrome.tsx +83 -0
- package/src/components/data-display/Carousel/Carousel.hooks.ts +88 -0
- package/src/components/data-display/Carousel/Carousel.parts.tsx +346 -0
- package/src/components/data-display/Carousel/Carousel.slide.tsx +190 -0
- package/src/components/data-display/Carousel/Carousel.styles.ts +86 -0
- package/src/components/data-display/Carousel/Carousel.tsx +84 -732
- package/src/components/data-display/HoverCard/HoverCard.animations.ts +73 -0
- package/src/components/data-display/HoverCard/HoverCard.content.tsx +143 -0
- package/src/components/data-display/HoverCard/HoverCard.hooks.ts +180 -0
- package/src/components/data-display/HoverCard/HoverCard.styles.ts +178 -0
- package/src/components/data-display/HoverCard/HoverCard.tsx +117 -631
- package/src/components/data-display/Sheet/Sheet.animations.ts +44 -0
- package/src/components/data-display/Sheet/Sheet.body.tsx +124 -0
- package/src/components/data-display/Sheet/Sheet.drag.ts +203 -0
- package/src/components/data-display/Sheet/Sheet.hooks.ts +318 -0
- package/src/components/data-display/Sheet/Sheet.parts.tsx +224 -0
- package/src/components/data-display/Sheet/Sheet.styles.ts +257 -0
- package/src/components/data-display/Sheet/Sheet.tsx +125 -948
- package/src/components/feedback/Modal/Modal.styles.ts +126 -0
- package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
- package/src/components/feedback/Modal/Modal.tsx +37 -242
- package/src/components/form/InputOTP/InputOTP.hooks.ts +104 -0
- package/src/components/form/InputOTP/InputOTP.styles.ts +97 -0
- package/src/components/form/InputOTP/InputOTP.tsx +74 -221
- package/src/components/form/Label/Label.parts.tsx +75 -0
- package/src/components/form/Label/Label.styles.ts +206 -0
- package/src/components/form/Label/Label.tsx +73 -325
- package/src/components/form/Menubar/Menubar.hooks.ts +41 -0
- package/src/components/form/Menubar/Menubar.item.tsx +121 -0
- package/src/components/form/Menubar/Menubar.separator.tsx +25 -0
- package/src/components/form/Menubar/Menubar.styles.ts +110 -0
- package/src/components/form/Menubar/Menubar.toolbar.tsx +103 -0
- package/src/components/form/Menubar/Menubar.tsx +64 -347
- package/src/components/form/RadioGroup/RadioGroup.animations.ts +48 -0
- package/src/components/form/RadioGroup/RadioGroup.styles.ts +305 -0
- package/src/components/form/RadioGroup/RadioGroup.tsx +81 -651
- package/src/components/form/RadioGroup/RadioGroup.variants.tsx +291 -0
- package/src/components/form/Slider/Slider.animations.ts +40 -0
- package/src/components/form/Slider/Slider.styles.ts +292 -0
- package/src/components/form/Slider/Slider.tsx +106 -357
- package/src/components/form/Switch/Switch.animations.ts +62 -0
- package/src/components/form/Switch/Switch.parts.tsx +213 -0
- package/src/components/form/Switch/Switch.styles.ts +355 -0
- package/src/components/form/Switch/Switch.tsx +87 -566
- package/src/components/typography/Blockquote/Blockquote.test.stories.tsx +7 -7
- package/src/components/typography/Heading/Heading.styles.ts +101 -0
- package/src/components/typography/Heading/Heading.tsx +48 -164
- package/src/components/utility/AnimatedIcon/AnimatedIcon.animations.ts +274 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +231 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.tsx +39 -559
- package/src/utils/index.ts +1 -0
- package/src/utils/withDefaults.ts +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"packageManager": "pnpm@9.0.0",
|
|
6
6
|
"type": "module",
|
|
@@ -194,7 +194,7 @@
|
|
|
194
194
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
195
195
|
},
|
|
196
196
|
"dependencies": {
|
|
197
|
-
"@12-apps/forms-core": "^1.
|
|
197
|
+
"@12-apps/forms-core": "^1.3.1",
|
|
198
198
|
"@emotion/react": "^11.14.0",
|
|
199
199
|
"@emotion/styled": "^11.14.0",
|
|
200
200
|
"@monaco-editor/react": "^4.7.0",
|
|
@@ -209,8 +209,8 @@
|
|
|
209
209
|
"recharts": "^3.5.1"
|
|
210
210
|
},
|
|
211
211
|
"devDependencies": {
|
|
212
|
-
"@12-apps/eslint-config": "^1.
|
|
213
|
-
"@12-apps/typescript-config": "^1.
|
|
212
|
+
"@12-apps/eslint-config": "^1.4.1",
|
|
213
|
+
"@12-apps/typescript-config": "^1.4.1",
|
|
214
214
|
"@playwright/test": "^1.55.0",
|
|
215
215
|
"@storybook/addon-docs": "^9.1.10",
|
|
216
216
|
"@storybook/addon-links": "^9.1.10",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { CarouselArrows, CarouselIndicators, CarouselThumbnails } from './Carousel.parts';
|
|
4
|
+
import type {
|
|
5
|
+
CarouselArrowsProps,
|
|
6
|
+
CarouselIndicatorsProps,
|
|
7
|
+
CarouselItem,
|
|
8
|
+
CarouselThumbnailsProps,
|
|
9
|
+
} from './Carousel.types';
|
|
10
|
+
|
|
11
|
+
type IndicatorPosition = NonNullable<CarouselIndicatorsProps['position']>;
|
|
12
|
+
type Color = NonNullable<CarouselIndicatorsProps['color']>;
|
|
13
|
+
|
|
14
|
+
export interface CarouselChromeProps {
|
|
15
|
+
items: CarouselItem[];
|
|
16
|
+
activeIndex: number;
|
|
17
|
+
loop: boolean;
|
|
18
|
+
disabled: boolean;
|
|
19
|
+
showArrows: boolean;
|
|
20
|
+
showIndicators: boolean;
|
|
21
|
+
showThumbnails: boolean;
|
|
22
|
+
arrowPosition: NonNullable<CarouselArrowsProps['position']>;
|
|
23
|
+
indicatorPosition: IndicatorPosition;
|
|
24
|
+
color: Color;
|
|
25
|
+
size: NonNullable<CarouselThumbnailsProps['size']>;
|
|
26
|
+
onPrev: () => void;
|
|
27
|
+
onNext: () => void;
|
|
28
|
+
onSelect: (index: number) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Everything drawn over the slides: the arrows, the indicators and the thumbnail
|
|
33
|
+
* strip. Each is independently switchable, so they travel together rather than
|
|
34
|
+
* as three conditional blocks inside the carousel's own render.
|
|
35
|
+
*/
|
|
36
|
+
export const CarouselChrome: React.FC<CarouselChromeProps> = ({
|
|
37
|
+
items,
|
|
38
|
+
activeIndex,
|
|
39
|
+
loop,
|
|
40
|
+
disabled,
|
|
41
|
+
showArrows,
|
|
42
|
+
showIndicators,
|
|
43
|
+
showThumbnails,
|
|
44
|
+
arrowPosition,
|
|
45
|
+
indicatorPosition,
|
|
46
|
+
color,
|
|
47
|
+
size,
|
|
48
|
+
onPrev,
|
|
49
|
+
onNext,
|
|
50
|
+
onSelect,
|
|
51
|
+
}) => (
|
|
52
|
+
<>
|
|
53
|
+
{showArrows && !disabled && (
|
|
54
|
+
<CarouselArrows
|
|
55
|
+
onPrev={onPrev}
|
|
56
|
+
onNext={onNext}
|
|
57
|
+
position={arrowPosition}
|
|
58
|
+
color={color}
|
|
59
|
+
disablePrev={!loop && activeIndex === 0}
|
|
60
|
+
disableNext={!loop && activeIndex === items.length - 1}
|
|
61
|
+
/>
|
|
62
|
+
)}
|
|
63
|
+
|
|
64
|
+
{showIndicators && (
|
|
65
|
+
<CarouselIndicators
|
|
66
|
+
count={items.length}
|
|
67
|
+
activeIndex={activeIndex}
|
|
68
|
+
onSelect={onSelect}
|
|
69
|
+
position={indicatorPosition}
|
|
70
|
+
color={color}
|
|
71
|
+
/>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{showThumbnails && (
|
|
75
|
+
<CarouselThumbnails
|
|
76
|
+
items={items}
|
|
77
|
+
activeIndex={activeIndex}
|
|
78
|
+
onSelect={onSelect}
|
|
79
|
+
size={size}
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface CarouselNavigationInput {
|
|
4
|
+
count: number;
|
|
5
|
+
loop: boolean;
|
|
6
|
+
autoPlay: boolean;
|
|
7
|
+
autoPlayInterval: number;
|
|
8
|
+
pauseOnHover: boolean;
|
|
9
|
+
disabled: boolean;
|
|
10
|
+
onChange?: (index: number) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Steps `current` by `step`, wrapping when `loop` allows it and stopping at the
|
|
15
|
+
* end when it does not. Returns the same index it was given when there is
|
|
16
|
+
* nowhere to go, which is what lets the caller skip a no-op `onChange`.
|
|
17
|
+
*/
|
|
18
|
+
const stepIndex = (current: number, step: number, count: number, loop: boolean) => {
|
|
19
|
+
const next = current + step;
|
|
20
|
+
|
|
21
|
+
if (next >= count) return loop ? 0 : current;
|
|
22
|
+
if (next < 0) return loop ? count - 1 : 0;
|
|
23
|
+
return next;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Which slide is showing and everything that changes it: the arrows, an
|
|
28
|
+
* indicator click, and the autoplay timer.
|
|
29
|
+
*
|
|
30
|
+
* Hover is tracked here rather than in the component because its only job is to
|
|
31
|
+
* pause that timer.
|
|
32
|
+
*/
|
|
33
|
+
export const useCarouselNavigation = ({
|
|
34
|
+
count,
|
|
35
|
+
loop,
|
|
36
|
+
autoPlay,
|
|
37
|
+
autoPlayInterval,
|
|
38
|
+
pauseOnHover,
|
|
39
|
+
disabled,
|
|
40
|
+
onChange,
|
|
41
|
+
}: CarouselNavigationInput) => {
|
|
42
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
43
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
44
|
+
const intervalRef = useRef<number | null>(null);
|
|
45
|
+
|
|
46
|
+
const goBy = useCallback(
|
|
47
|
+
(step: number) => {
|
|
48
|
+
setActiveIndex((prev) => {
|
|
49
|
+
const next = stepIndex(prev, step, count, loop);
|
|
50
|
+
if (next !== prev) {
|
|
51
|
+
onChange?.(next);
|
|
52
|
+
}
|
|
53
|
+
return next;
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
[count, loop, onChange],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const handleNext = useCallback(() => goBy(1), [goBy]);
|
|
60
|
+
const handlePrev = useCallback(() => goBy(-1), [goBy]);
|
|
61
|
+
|
|
62
|
+
const handleSelect = useCallback(
|
|
63
|
+
(index: number) => {
|
|
64
|
+
setActiveIndex(index);
|
|
65
|
+
onChange?.(index);
|
|
66
|
+
},
|
|
67
|
+
[onChange],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!autoPlay || isHovered || disabled) return;
|
|
72
|
+
|
|
73
|
+
intervalRef.current = window.setInterval(handleNext, autoPlayInterval);
|
|
74
|
+
|
|
75
|
+
return () => {
|
|
76
|
+
if (intervalRef.current) {
|
|
77
|
+
window.clearInterval(intervalRef.current);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}, [autoPlay, autoPlayInterval, isHovered, disabled, handleNext]);
|
|
81
|
+
|
|
82
|
+
const hoverProps = {
|
|
83
|
+
onMouseEnter: () => pauseOnHover && setIsHovered(true),
|
|
84
|
+
onMouseLeave: () => pauseOnHover && setIsHovered(false),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return { activeIndex, handleNext, handlePrev, handleSelect, hoverProps };
|
|
88
|
+
};
|
|
@@ -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
|
+
};
|