@12-apps/ui 1.4.0 → 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.
Files changed (53) hide show
  1. package/package.json +4 -4
  2. package/src/components/data-display/Carousel/Carousel.chrome.tsx +83 -0
  3. package/src/components/data-display/Carousel/Carousel.hooks.ts +88 -0
  4. package/src/components/data-display/Carousel/Carousel.parts.tsx +346 -0
  5. package/src/components/data-display/Carousel/Carousel.slide.tsx +190 -0
  6. package/src/components/data-display/Carousel/Carousel.styles.ts +86 -0
  7. package/src/components/data-display/Carousel/Carousel.tsx +84 -732
  8. package/src/components/data-display/HoverCard/HoverCard.animations.ts +73 -0
  9. package/src/components/data-display/HoverCard/HoverCard.content.tsx +143 -0
  10. package/src/components/data-display/HoverCard/HoverCard.hooks.ts +180 -0
  11. package/src/components/data-display/HoverCard/HoverCard.styles.ts +178 -0
  12. package/src/components/data-display/HoverCard/HoverCard.tsx +117 -631
  13. package/src/components/data-display/Sheet/Sheet.animations.ts +44 -0
  14. package/src/components/data-display/Sheet/Sheet.body.tsx +124 -0
  15. package/src/components/data-display/Sheet/Sheet.drag.ts +203 -0
  16. package/src/components/data-display/Sheet/Sheet.hooks.ts +318 -0
  17. package/src/components/data-display/Sheet/Sheet.parts.tsx +224 -0
  18. package/src/components/data-display/Sheet/Sheet.styles.ts +257 -0
  19. package/src/components/data-display/Sheet/Sheet.tsx +125 -948
  20. package/src/components/feedback/Modal/Modal.styles.ts +126 -0
  21. package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
  22. package/src/components/feedback/Modal/Modal.tsx +37 -242
  23. package/src/components/form/InputOTP/InputOTP.hooks.ts +104 -0
  24. package/src/components/form/InputOTP/InputOTP.styles.ts +97 -0
  25. package/src/components/form/InputOTP/InputOTP.tsx +74 -221
  26. package/src/components/form/Label/Label.parts.tsx +75 -0
  27. package/src/components/form/Label/Label.styles.ts +206 -0
  28. package/src/components/form/Label/Label.tsx +73 -325
  29. package/src/components/form/Menubar/Menubar.hooks.ts +41 -0
  30. package/src/components/form/Menubar/Menubar.item.tsx +121 -0
  31. package/src/components/form/Menubar/Menubar.separator.tsx +25 -0
  32. package/src/components/form/Menubar/Menubar.styles.ts +110 -0
  33. package/src/components/form/Menubar/Menubar.toolbar.tsx +103 -0
  34. package/src/components/form/Menubar/Menubar.tsx +64 -347
  35. package/src/components/form/RadioGroup/RadioGroup.animations.ts +48 -0
  36. package/src/components/form/RadioGroup/RadioGroup.styles.ts +305 -0
  37. package/src/components/form/RadioGroup/RadioGroup.tsx +81 -651
  38. package/src/components/form/RadioGroup/RadioGroup.variants.tsx +291 -0
  39. package/src/components/form/Slider/Slider.animations.ts +40 -0
  40. package/src/components/form/Slider/Slider.styles.ts +292 -0
  41. package/src/components/form/Slider/Slider.tsx +106 -357
  42. package/src/components/form/Switch/Switch.animations.ts +62 -0
  43. package/src/components/form/Switch/Switch.parts.tsx +213 -0
  44. package/src/components/form/Switch/Switch.styles.ts +355 -0
  45. package/src/components/form/Switch/Switch.tsx +87 -566
  46. package/src/components/typography/Blockquote/Blockquote.test.stories.tsx +7 -7
  47. package/src/components/typography/Heading/Heading.styles.ts +101 -0
  48. package/src/components/typography/Heading/Heading.tsx +48 -164
  49. package/src/components/utility/AnimatedIcon/AnimatedIcon.animations.ts +274 -0
  50. package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +231 -0
  51. package/src/components/utility/AnimatedIcon/AnimatedIcon.tsx +39 -559
  52. package/src/utils/index.ts +1 -0
  53. 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.4.0",
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.3.0",
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.4.0",
213
- "@12-apps/typescript-config": "^1.4.0",
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
+ };