@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
@@ -0,0 +1,224 @@
1
+ import CloseIcon from '@mui/icons-material/Close';
2
+ import { alpha, Backdrop, Box, Divider, Fade, IconButton, Typography, useTheme } from '@mui/material';
3
+ import type { Theme } from '@mui/material';
4
+ import React from 'react';
5
+
6
+ import { withDefaults } from '../../../utils/withDefaults';
7
+
8
+ import { shimmerAnimation } from './Sheet.animations';
9
+ import type {
10
+ SheetContentProps,
11
+ SheetFooterProps,
12
+ SheetHeaderProps,
13
+ SheetOverlayProps,
14
+ } from './Sheet.types';
15
+
16
+ /**
17
+ * The grab handle's own styling. A draggable handle is deliberately larger and
18
+ * tinted, so the affordance reads as draggable before the user tries it; a
19
+ * decorative one stays a neutral bar.
20
+ */
21
+ const handleSx = (theme: Theme, isDraggable: boolean) => ({
22
+ width: isDraggable ? 48 : 32,
23
+ height: isDraggable ? 6 : 4,
24
+ backgroundColor: alpha(theme.palette.text.primary, 0.3),
25
+ borderRadius: 3,
26
+ transition: theme.transitions.create(['all'], {
27
+ duration: theme.transitions.duration.short,
28
+ easing: theme.transitions.easing.easeInOut,
29
+ }),
30
+ position: 'relative',
31
+ overflow: 'hidden',
32
+ '&::after': {
33
+ content: '""',
34
+ position: 'absolute',
35
+ top: 0,
36
+ left: '-100%',
37
+ width: '100%',
38
+ height: '100%',
39
+ background: `linear-gradient(
40
+ 90deg,
41
+ transparent,
42
+ ${alpha(theme.palette.common.white, 0.3)},
43
+ transparent
44
+ )`,
45
+ animation: isDraggable ? `${shimmerAnimation} 2s infinite` : 'none',
46
+ },
47
+ ...(isDraggable && {
48
+ backgroundColor: alpha(theme.palette.primary.main, 0.4),
49
+ boxShadow: `0 2px 4px ${alpha(theme.palette.primary.main, 0.2)}`,
50
+ '&:hover': {
51
+ backgroundColor: theme.palette.primary.main,
52
+ transform: 'scaleX(1.15) scaleY(1.2)',
53
+ boxShadow: `0 3px 6px ${alpha(theme.palette.primary.main, 0.3)}`,
54
+ },
55
+ }),
56
+ });
57
+
58
+ const SheetHandle: React.FC<{ isDraggable: boolean }> = ({ isDraggable }) => {
59
+ const theme = useTheme();
60
+
61
+ return (
62
+ <Box
63
+ sx={{
64
+ display: 'flex',
65
+ justifyContent: 'center',
66
+ mb: 1,
67
+ // The handle is decoration on a draggable header: the drag listeners sit
68
+ // on the header itself, so letting the bar swallow the press would open
69
+ // a dead zone in the middle of the grab target.
70
+ pointerEvents: isDraggable ? 'none' : 'auto',
71
+ }}
72
+ >
73
+ <Box sx={handleSx(theme, isDraggable)} />
74
+ </Box>
75
+ );
76
+ };
77
+
78
+ const HEADER_DEFAULTS: Partial<SheetHeaderProps> = {
79
+ isDraggable: false,
80
+ dataTestId: 'sheet-header',
81
+ };
82
+
83
+ export const SheetHeader: React.FC<SheetHeaderProps> = (props) => {
84
+ const {
85
+ title,
86
+ description,
87
+ showCloseButton,
88
+ onClose,
89
+ showHandle,
90
+ isDraggable,
91
+ onDragStart,
92
+ className,
93
+ style,
94
+ children,
95
+ dataTestId,
96
+ } = withDefaults(props, HEADER_DEFAULTS);
97
+
98
+ return (
99
+ <Box
100
+ className={className}
101
+ data-testid={dataTestId}
102
+ sx={{
103
+ p: 2,
104
+ pb: showHandle ? 1 : 2,
105
+ cursor: isDraggable ? 'grab' : 'auto',
106
+ '&:active': isDraggable ? { cursor: 'grabbing' } : {},
107
+ ...style,
108
+ }}
109
+ onMouseDown={isDraggable ? onDragStart : undefined}
110
+ onTouchStart={isDraggable ? onDragStart : undefined}
111
+ >
112
+ {showHandle && <SheetHandle isDraggable={Boolean(isDraggable)} />}
113
+
114
+ <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
115
+ <Box sx={{ flex: 1 }}>
116
+ {title && (
117
+ <Typography variant="h6" sx={{ fontWeight: 600 }} data-testid={`${dataTestId}-title`}>
118
+ {title}
119
+ </Typography>
120
+ )}
121
+ {description && (
122
+ <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
123
+ {description}
124
+ </Typography>
125
+ )}
126
+ {children}
127
+ </Box>
128
+
129
+ {showCloseButton && (
130
+ <IconButton
131
+ onClick={onClose}
132
+ size="small"
133
+ sx={{ ml: 1, mt: -0.5 }}
134
+ data-testid={`${dataTestId}-close-button`}
135
+ >
136
+ <CloseIcon data-testid={`${dataTestId}-close-icon`} />
137
+ </IconButton>
138
+ )}
139
+ </Box>
140
+ </Box>
141
+ );
142
+ };
143
+
144
+ const CONTENT_DEFAULTS: Partial<SheetContentProps> = {
145
+ padded: true,
146
+ dataTestId: 'sheet-content',
147
+ };
148
+
149
+ export const SheetContent: React.FC<SheetContentProps> = (props) => {
150
+ const { children, className, style, padded, dataTestId } = withDefaults(props, CONTENT_DEFAULTS);
151
+
152
+ return (
153
+ <Box
154
+ className={className}
155
+ data-testid={dataTestId}
156
+ sx={{ flex: 1, overflow: 'auto', p: padded ? 2 : 0, ...style }}
157
+ >
158
+ {children}
159
+ </Box>
160
+ );
161
+ };
162
+
163
+ const FOOTER_DEFAULTS: Partial<SheetFooterProps> = {
164
+ sticky: false,
165
+ divider: false,
166
+ dataTestId: 'sheet-footer',
167
+ };
168
+
169
+ export const SheetFooter: React.FC<SheetFooterProps> = (props) => {
170
+ const { children, className, style, sticky, divider, dataTestId } = withDefaults(
171
+ props,
172
+ FOOTER_DEFAULTS,
173
+ );
174
+
175
+ return (
176
+ <>
177
+ {divider && <Divider />}
178
+ <Box
179
+ className={className}
180
+ data-testid={dataTestId}
181
+ sx={{
182
+ p: 2,
183
+ position: sticky ? 'sticky' : 'relative',
184
+ bottom: sticky ? 0 : 'auto',
185
+ backgroundColor: 'background.paper',
186
+ borderTop: sticky ? '1px solid' : 'none',
187
+ borderColor: 'divider',
188
+ ...style,
189
+ }}
190
+ >
191
+ {children}
192
+ </Box>
193
+ </>
194
+ );
195
+ };
196
+
197
+ const OVERLAY_DEFAULTS: Partial<SheetOverlayProps> = {
198
+ open: false,
199
+ blur: false,
200
+ };
201
+
202
+ export const SheetOverlay: React.FC<SheetOverlayProps> = (props) => {
203
+ const { open, onClick, className, style, blur } = withDefaults(props, OVERLAY_DEFAULTS);
204
+ const theme = useTheme();
205
+
206
+ return (
207
+ <Fade in={open} timeout={300}>
208
+ <Backdrop
209
+ open={Boolean(open)}
210
+ onClick={onClick}
211
+ className={className}
212
+ sx={{
213
+ // One below the drawer: this backdrop replaces MUI's own (the drawer
214
+ // is given a no-op BackdropComponent) and must sit under the panel.
215
+ zIndex: theme.zIndex.drawer - 1,
216
+ backgroundColor: alpha(theme.palette.common.black, blur ? 0.6 : 0.5),
217
+ backdropFilter: blur ? 'blur(8px) saturate(180%)' : 'none',
218
+ WebkitBackdropFilter: blur ? 'blur(8px) saturate(180%)' : 'none',
219
+ ...style,
220
+ }}
221
+ />
222
+ </Fade>
223
+ );
224
+ };
@@ -0,0 +1,257 @@
1
+ import { alpha } from '@mui/material';
2
+ import type { CSSObject, Theme } from '@mui/material';
3
+ import type React from 'react';
4
+
5
+ import { glowAnimation, pulseAnimation, shimmerAnimation } from './Sheet.animations';
6
+ import type { SheetProps } from './Sheet.types';
7
+
8
+ type Position = NonNullable<SheetProps['position']>;
9
+ type Variant = NonNullable<SheetProps['variant']>;
10
+ type Size = NonNullable<SheetProps['size']>;
11
+ type Color = NonNullable<SheetProps['color']>;
12
+
13
+ const isHorizontalPosition = (position: Position) => position === 'left' || position === 'right';
14
+
15
+ /**
16
+ * Horizontal (left/right) sheets are viewport-aware: a px floor keeps the panel
17
+ * usable, `…vw` lets the larger presets grow on wide screens (lg ≥32%, xl ≥40%
18
+ * of the viewport), and the outer `min(92vw, …)` guarantees the panel never
19
+ * overflows a small screen. Vertical presets stay fixed-height.
20
+ */
21
+ const HORIZONTAL_SIZES: Record<Size, string> = {
22
+ xs: 'min(92vw, 240px)',
23
+ sm: 'min(92vw, 320px)',
24
+ md: 'min(92vw, 400px)',
25
+ lg: 'min(92vw, max(560px, 32vw))',
26
+ xl: 'min(92vw, max(720px, 40vw))',
27
+ full: '100%',
28
+ };
29
+
30
+ const VERTICAL_SIZES: Record<Size, number | string> = {
31
+ xs: 200,
32
+ sm: 300,
33
+ md: 400,
34
+ lg: 500,
35
+ xl: 600,
36
+ full: '100%',
37
+ };
38
+
39
+ interface SizeStyleInput {
40
+ position: Position;
41
+ size: Size;
42
+ isDraggableVariant: boolean;
43
+ isVerticalSheet: boolean;
44
+ currentHeight: number | null;
45
+ }
46
+
47
+ const sizeStyles = ({
48
+ position,
49
+ size,
50
+ isDraggableVariant,
51
+ isVerticalSheet,
52
+ currentHeight,
53
+ }: SizeStyleInput) => {
54
+ // The draggable variant owns its own height: the snap point decides it, so a
55
+ // preset would fight the drag.
56
+ if (isDraggableVariant && isVerticalSheet && currentHeight !== null) {
57
+ return { height: currentHeight };
58
+ }
59
+
60
+ return isHorizontalPosition(position)
61
+ ? { width: HORIZONTAL_SIZES[size] ?? HORIZONTAL_SIZES.md }
62
+ : { height: VERTICAL_SIZES[size] ?? VERTICAL_SIZES.md };
63
+ };
64
+
65
+ /** `r, g, b` for a hex colour, so it can feed an `rgba(var(--x), a)` custom property. */
66
+ const toRgbTriplet = (hexColor: string) => {
67
+ const hex = hexColor.replace('#', '');
68
+ const r = parseInt(hex.substring(0, 2), 16);
69
+ const g = parseInt(hex.substring(2, 4), 16);
70
+ const b = parseInt(hex.substring(4, 6), 16);
71
+ return `${r}, ${g}, ${b}`;
72
+ };
73
+
74
+ const roundedStyles = (theme: Theme, position: Position) => {
75
+ const radius = theme.spacing(2);
76
+ const byPosition: Record<Position, string> = {
77
+ top: `0 0 ${radius} ${radius}`,
78
+ bottom: `${radius} ${radius} 0 0`,
79
+ left: `0 ${radius} ${radius} 0`,
80
+ right: `${radius} 0 0 ${radius}`,
81
+ };
82
+ return { borderRadius: byPosition[position] };
83
+ };
84
+
85
+ interface VariantStyleInput {
86
+ theme: Theme;
87
+ variant: Variant;
88
+ color: Color;
89
+ position: Position;
90
+ elevation: number;
91
+ glow: boolean;
92
+ pulse: boolean;
93
+ glass: boolean;
94
+ gradient: boolean;
95
+ rounded: boolean;
96
+ disabled: boolean;
97
+ isDragging: boolean;
98
+ isAnimating: boolean;
99
+ isDraggableVariant: boolean;
100
+ isVerticalSheet: boolean;
101
+ }
102
+
103
+ type SurfaceInput = VariantStyleInput & { accent: string };
104
+
105
+ /**
106
+ * Each variant's own additions to the shared surface — never the shared part
107
+ * itself. Every arm of the old switch re-spread the same base, glow, pulse and
108
+ * rounded objects before adding these few rules; `variantStyles` applies that
109
+ * prefix once and lets the table hold only what actually differs.
110
+ */
111
+ const SURFACES: Record<Variant, (input: SurfaceInput) => Record<string, unknown>> = {
112
+ draggable: ({ theme, elevation, accent }) => ({
113
+ boxShadow: `
114
+ ${theme.shadows[Math.min(elevation + 4, 24)]},
115
+ 0 -2px 10px 0 ${alpha(theme.palette.common.black, 0.1)}
116
+ `,
117
+ border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
118
+ borderTop: `2px solid ${alpha(accent, 0.3)}`,
119
+ transition: theme.transitions.create(['transform', 'box-shadow', 'border-color'], {
120
+ duration: theme.transitions.duration.shorter,
121
+ easing: theme.transitions.easing.easeInOut,
122
+ }),
123
+ '&:hover': { borderTopColor: alpha(accent, 0.5) },
124
+ }),
125
+
126
+ glass: ({ theme, glass }) => ({
127
+ backgroundColor: alpha(theme.palette.background.paper, glass ? 0.75 : 0.95),
128
+ backdropFilter: 'blur(24px) saturate(180%)',
129
+ WebkitBackdropFilter: 'blur(24px) saturate(180%)',
130
+ border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
131
+ boxShadow: `
132
+ 0 8px 32px 0 ${alpha(theme.palette.common.black, 0.15)},
133
+ inset 0 0 0 1px ${alpha(theme.palette.common.white, 0.1)}
134
+ `,
135
+ }),
136
+
137
+ gradient: ({ theme, color, gradient, accent }) => ({
138
+ background: gradient
139
+ ? `linear-gradient(
140
+ 135deg,
141
+ ${theme.palette.background.paper} 0%,
142
+ ${alpha(accent, 0.08)} 50%,
143
+ ${alpha(theme.palette[color]?.dark || accent, 0.12)} 100%
144
+ )`
145
+ : theme.palette.background.paper,
146
+ position: 'relative' as const,
147
+ overflow: 'hidden' as const,
148
+ '&::before': gradient ? shimmerOverlay(theme) : {},
149
+ }),
150
+
151
+ elevated: ({ theme, elevation }) => ({
152
+ boxShadow: `
153
+ ${theme.shadows[elevation]},
154
+ 0 20px 40px -15px ${alpha(theme.palette.common.black, 0.15)}
155
+ `,
156
+ transform: 'translateZ(0)',
157
+ willChange: 'transform',
158
+ }),
159
+
160
+ minimal: () => ({ boxShadow: 'none', border: 'none' }),
161
+
162
+ default: () => ({}),
163
+ };
164
+
165
+ /** The sweep of light the gradient variant runs across itself. */
166
+ const shimmerOverlay = (theme: Theme) => ({
167
+ content: '""',
168
+ position: 'absolute',
169
+ top: 0,
170
+ left: '-100%',
171
+ width: '100%',
172
+ height: '100%',
173
+ background: `linear-gradient(
174
+ 90deg,
175
+ transparent,
176
+ ${alpha(theme.palette.common.white, 0.2)},
177
+ transparent
178
+ )`,
179
+ animation: `${shimmerAnimation} 3s infinite`,
180
+ });
181
+
182
+ /**
183
+ * The parts of the surface that do not depend on the variant: the paper colour,
184
+ * the transition suppression while a drag or spring is driving the panel, the
185
+ * disabled and cursor states, and the two colour custom properties the glow and
186
+ * pulse keyframes read.
187
+ */
188
+ const baseSurface = (input: VariantStyleInput, accent: string) => {
189
+ const { theme, disabled, isDragging, isAnimating, isDraggableVariant, isVerticalSheet } = input;
190
+ const colorRgb = toRgbTriplet(accent);
191
+
192
+ return {
193
+ backgroundColor: theme.palette.background.paper,
194
+ // A transition would fight the per-frame height writes while a drag or a
195
+ // snap animation is driving the panel.
196
+ transition:
197
+ !isDragging && !isAnimating
198
+ ? theme.transitions.create(['all'], {
199
+ duration: theme.transitions.duration.standard,
200
+ easing: theme.transitions.easing.easeInOut,
201
+ })
202
+ : 'none',
203
+ opacity: disabled ? 0.5 : 1,
204
+ pointerEvents: disabled ? ('none' as const) : ('auto' as const),
205
+ cursor: isDraggableVariant && isVerticalSheet ? 'grab' : 'auto',
206
+ '--pulse-color': colorRgb,
207
+ '--glow-color': colorRgb,
208
+ ...(isDragging && { cursor: 'grabbing' }),
209
+ };
210
+ };
211
+
212
+ const variantStyles = (input: VariantStyleInput) => {
213
+ const { theme, variant, color, position, glow, pulse, rounded } = input;
214
+ const accent = theme.palette[color]?.main || theme.palette.primary.main;
215
+
216
+ return {
217
+ ...baseSurface(input, accent),
218
+ // glow first, then pulse: with both set the pulse keyframes win the shared
219
+ // `animation` slot, which is the order the original spread produced.
220
+ ...(glow && { animation: `${glowAnimation} 2s ease-in-out infinite`, filter: 'brightness(1.05)' }),
221
+ ...(pulse && { animation: `${pulseAnimation} 2s infinite`, position: 'relative' as const }),
222
+ ...(rounded && roundedStyles(theme, position)),
223
+ ...(SURFACES[variant] ?? SURFACES.default)({ ...input, accent }),
224
+ };
225
+ };
226
+
227
+ interface PanelSxInput extends VariantStyleInput {
228
+ size: Size;
229
+ currentHeight: number | null;
230
+ fullHeight: boolean;
231
+ style?: React.CSSProperties;
232
+ }
233
+
234
+ /**
235
+ * The drawer paper's complete `sx`: its size, its variant surface, the caller's
236
+ * own `style`, and the full-height rule.
237
+ *
238
+ * Built here rather than in the component so the component holds no style
239
+ * branches of its own — the cross-axis rule below is four on its own.
240
+ */
241
+ export const panelSx = (input: PanelSxInput): CSSObject => {
242
+ const { position, size, isDraggableVariant, isVerticalSheet, currentHeight, fullHeight, style } =
243
+ input;
244
+
245
+ return {
246
+ ...sizeStyles({ position, size, isDraggableVariant, isVerticalSheet, currentHeight }),
247
+ ...variantStyles(input),
248
+ ...style,
249
+ overflow: 'visible',
250
+ // Expand only the cross axis so the chosen `size` still governs the main
251
+ // axis: side sheets keep their (responsive) width and go full-height;
252
+ // top/bottom sheets keep their height and go full-width.
253
+ ...(fullHeight &&
254
+ !isDraggableVariant &&
255
+ (isHorizontalPosition(position) ? { height: '100%' } : { width: '100%' })),
256
+ };
257
+ };