@12-apps/ui 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/eslint.config.js +12 -0
  2. package/package.json +5 -4
  3. package/src/components/data-display/AlertDialog/AlertDialog.test.stories.tsx +16 -6
  4. package/src/components/data-display/Banner/Banner.test.stories.tsx +26 -18
  5. package/src/components/data-display/Carousel/Carousel.chrome.tsx +83 -0
  6. package/src/components/data-display/Carousel/Carousel.hooks.ts +88 -0
  7. package/src/components/data-display/Carousel/Carousel.parts.tsx +346 -0
  8. package/src/components/data-display/Carousel/Carousel.slide.tsx +190 -0
  9. package/src/components/data-display/Carousel/Carousel.styles.ts +86 -0
  10. package/src/components/data-display/Carousel/Carousel.test.stories.tsx +15 -5
  11. package/src/components/data-display/Carousel/Carousel.tsx +84 -732
  12. package/src/components/data-display/HoverCard/HoverCard.animations.ts +73 -0
  13. package/src/components/data-display/HoverCard/HoverCard.content.tsx +143 -0
  14. package/src/components/data-display/HoverCard/HoverCard.hooks.ts +180 -0
  15. package/src/components/data-display/HoverCard/HoverCard.styles.ts +178 -0
  16. package/src/components/data-display/HoverCard/HoverCard.tsx +117 -631
  17. package/src/components/data-display/Sheet/Sheet.animations.ts +44 -0
  18. package/src/components/data-display/Sheet/Sheet.body.tsx +124 -0
  19. package/src/components/data-display/Sheet/Sheet.drag.ts +203 -0
  20. package/src/components/data-display/Sheet/Sheet.hooks.ts +318 -0
  21. package/src/components/data-display/Sheet/Sheet.parts.tsx +224 -0
  22. package/src/components/data-display/Sheet/Sheet.styles.ts +257 -0
  23. package/src/components/data-display/Sheet/Sheet.test.stories.tsx +25 -21
  24. package/src/components/data-display/Sheet/Sheet.tsx +125 -948
  25. package/src/components/feedback/Dialog/Dialog.test.stories.tsx +37 -28
  26. package/src/components/feedback/Modal/Modal.styles.ts +126 -0
  27. package/src/components/feedback/Modal/Modal.test.stories.tsx +5 -5
  28. package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
  29. package/src/components/feedback/Modal/Modal.tsx +37 -242
  30. package/src/components/feedback/StackedModal/StackedModal.test.stories.tsx +9 -11
  31. package/src/components/form/InputOTP/InputOTP.hooks.ts +104 -0
  32. package/src/components/form/InputOTP/InputOTP.styles.ts +97 -0
  33. package/src/components/form/InputOTP/InputOTP.tsx +74 -221
  34. package/src/components/form/Label/Label.parts.tsx +75 -0
  35. package/src/components/form/Label/Label.styles.ts +206 -0
  36. package/src/components/form/Label/Label.test.stories.tsx +20 -14
  37. package/src/components/form/Label/Label.tsx +73 -325
  38. package/src/components/form/Menubar/Menubar.hooks.ts +41 -0
  39. package/src/components/form/Menubar/Menubar.item.tsx +121 -0
  40. package/src/components/form/Menubar/Menubar.separator.tsx +25 -0
  41. package/src/components/form/Menubar/Menubar.styles.ts +110 -0
  42. package/src/components/form/Menubar/Menubar.toolbar.tsx +103 -0
  43. package/src/components/form/Menubar/Menubar.tsx +64 -347
  44. package/src/components/form/RadioGroup/RadioGroup.animations.ts +48 -0
  45. package/src/components/form/RadioGroup/RadioGroup.styles.ts +305 -0
  46. package/src/components/form/RadioGroup/RadioGroup.tsx +81 -651
  47. package/src/components/form/RadioGroup/RadioGroup.variants.tsx +291 -0
  48. package/src/components/form/Slider/Slider.animations.ts +40 -0
  49. package/src/components/form/Slider/Slider.styles.ts +292 -0
  50. package/src/components/form/Slider/Slider.tsx +106 -357
  51. package/src/components/form/Switch/Switch.animations.ts +62 -0
  52. package/src/components/form/Switch/Switch.parts.tsx +213 -0
  53. package/src/components/form/Switch/Switch.styles.ts +355 -0
  54. package/src/components/form/Switch/Switch.tsx +87 -566
  55. package/src/components/form/ToggleGroup/ToggleGroup.test.stories.tsx +26 -29
  56. package/src/components/layout/Resizable/Resizable.test.stories.tsx +11 -7
  57. package/src/components/layout/Spacer/Spacer.test.stories.tsx +11 -9
  58. package/src/components/typography/Blockquote/Blockquote.test.stories.tsx +7 -7
  59. package/src/components/typography/Heading/Heading.styles.ts +101 -0
  60. package/src/components/typography/Heading/Heading.tsx +48 -164
  61. package/src/components/typography/Text/Text.test.stories.tsx +27 -30
  62. package/src/components/utility/AnimatedIcon/AnimatedIcon.animations.ts +274 -0
  63. package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +231 -0
  64. package/src/components/utility/AnimatedIcon/AnimatedIcon.tsx +39 -559
  65. package/src/utils/index.ts +1 -0
  66. package/src/utils/withDefaults.ts +20 -0
@@ -1,615 +1,136 @@
1
- import CloseIcon from '@mui/icons-material/Close';
2
- import {
3
- alpha,
4
- Backdrop,
5
- Box,
6
- CircularProgress,
7
- Divider,
8
- Drawer,
9
- Fade,
10
- IconButton,
11
- keyframes,
12
- SwipeableDrawer,
13
- Typography,
14
- useTheme,
15
- } from '@mui/material';
1
+ import { Drawer, SwipeableDrawer, useTheme } from '@mui/material';
16
2
  import type { BackdropProps } from '@mui/material';
17
- import React, { useCallback, useEffect, useMemo,useRef, useState } from 'react';
18
-
19
- import type {
20
- SheetContentProps,
21
- SheetFooterProps,
22
- SheetHeaderProps,
23
- SheetOverlayProps,
24
- SheetProps,
25
- } from './Sheet.types';
26
-
27
- // Spring physics configuration for smooth animations
28
- const SPRING_CONFIG = {
29
- tension: 200,
30
- friction: 25,
31
- velocity: 0,
32
- };
33
-
34
- // Velocity threshold for snap detection (pixels per millisecond)
35
- const DEFAULT_VELOCITY_THRESHOLD = 0.5;
36
-
37
- // Drag resistance factor at boundaries
38
- const DEFAULT_DRAG_RESISTANCE = 0.3;
39
-
40
- // Keyframe animations
41
- const pulseAnimation = keyframes`
42
- 0% {
43
- box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0.4);
44
- }
45
- 70% {
46
- box-shadow: 0 0 0 20px rgba(var(--pulse-color), 0);
47
- }
48
- 100% {
49
- box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0);
50
- }
51
- `;
52
-
53
- const shimmerAnimation = keyframes`
54
- 0% {
55
- background-position: -1000px 0;
56
- }
57
- 100% {
58
- background-position: 1000px 0;
59
- }
60
- `;
3
+ import React from 'react';
61
4
 
62
- const glowAnimation = keyframes`
63
- 0%, 100% {
64
- box-shadow: 0 0 20px 5px rgba(var(--glow-color), 0.3);
65
- }
66
- 50% {
67
- box-shadow: 0 0 35px 10px rgba(var(--glow-color), 0.5);
68
- }
69
- `;
5
+ import { withDefaults } from '../../../utils/withDefaults';
70
6
 
71
- export const Sheet: React.FC<SheetProps> = ({
72
- open = false,
73
- onOpenChange,
74
- children,
75
- title,
76
- description,
77
- position = 'bottom',
78
- variant = 'default',
79
- size = 'md',
80
- color = 'primary',
81
- glow = false,
82
- pulse = false,
83
- glass = false,
84
- gradient = false,
85
- loading = false,
86
- disabled = false,
87
- className,
88
- style,
89
- showOverlay = true,
90
- closeOnOverlayClick = true,
91
- closeOnEscape = true,
92
- showCloseButton = true,
93
- showHandle = true,
94
- swipeable = true,
95
- snapPoints = [0.25, 0.5, 0.75, 1],
96
- defaultSnapPoint = 0.5,
97
- onSnapPointChange,
98
- minSnapPoint = 0,
99
- maxSnapPoint = 1,
100
- velocityThreshold = DEFAULT_VELOCITY_THRESHOLD,
101
- dragResistance = DEFAULT_DRAG_RESISTANCE,
102
- animationConfig = SPRING_CONFIG,
103
- onClick,
104
- onFocus,
105
- onBlur,
106
- onClose,
107
- onOpen,
108
- onDragStart,
109
- onDragEnd,
110
- footer,
111
- header,
112
- persistent = false,
113
- fullHeight = false,
114
- rounded = true,
115
- elevation = 16,
116
- dataTestId = 'sheet',
117
- ...rest
118
- }) => {
119
- const theme = useTheme();
120
- const [isOpen, setIsOpen] = useState(open);
121
- const [currentSnapPoint, setCurrentSnapPoint] = useState(defaultSnapPoint);
122
- const [isDragging, setIsDragging] = useState(false);
123
- const [dragStartY, setDragStartY] = useState(0);
124
- const [currentHeight, setCurrentHeight] = useState<number | null>(null);
125
- const [isAnimating, setIsAnimating] = useState(false);
126
-
127
- const sheetRef = useRef<HTMLDivElement>(null);
128
- const contentRef = useRef<HTMLDivElement>(null);
129
- const animationFrameRef = useRef<number | undefined>(undefined);
130
- const velocityRef = useRef(0);
131
- const lastYRef = useRef(0);
132
- const lastTimeRef = useRef(0);
133
-
134
- // Support both dataTestId prop and data-testid HTML attribute
135
- const testId = (rest as { 'data-testid'?: string })['data-testid'] || dataTestId;
136
-
137
- const isDraggableVariant = variant === 'draggable';
7
+ import {
8
+ DEFAULT_DRAG_RESISTANCE,
9
+ DEFAULT_VELOCITY_THRESHOLD,
10
+ SPRING_CONFIG,
11
+ } from './Sheet.animations';
12
+ import { SheetBody } from './Sheet.body';
13
+ import { useSheetDrag, useSheetOpen } from './Sheet.hooks';
14
+ import { SheetOverlay } from './Sheet.parts';
15
+ import { panelSx } from './Sheet.styles';
16
+ import type { SheetProps } from './Sheet.types';
17
+
18
+ export { SheetContent, SheetFooter, SheetHeader, SheetOverlay } from './Sheet.parts';
19
+
20
+ const DEFAULTS = {
21
+ open: false,
22
+ position: 'bottom',
23
+ variant: 'default',
24
+ size: 'md',
25
+ color: 'primary',
26
+ glow: false,
27
+ pulse: false,
28
+ glass: false,
29
+ gradient: false,
30
+ loading: false,
31
+ disabled: false,
32
+ showOverlay: true,
33
+ closeOnOverlayClick: true,
34
+ closeOnEscape: true,
35
+ showCloseButton: true,
36
+ showHandle: true,
37
+ swipeable: true,
38
+ snapPoints: [0.25, 0.5, 0.75, 1],
39
+ defaultSnapPoint: 0.5,
40
+ minSnapPoint: 0,
41
+ maxSnapPoint: 1,
42
+ velocityThreshold: DEFAULT_VELOCITY_THRESHOLD,
43
+ dragResistance: DEFAULT_DRAG_RESISTANCE,
44
+ animationConfig: SPRING_CONFIG,
45
+ persistent: false,
46
+ fullHeight: false,
47
+ rounded: true,
48
+ elevation: 16,
49
+ dataTestId: 'sheet',
50
+ } satisfies Partial<SheetProps>;
51
+
52
+ /**
53
+ * The props with every defaulted key now guaranteed present. Without this the
54
+ * component would need a `?? fallback` at each use site, and each of those is a
55
+ * branch the complexity gate counts.
56
+ */
57
+ type ResolvedSheetProps = SheetProps & Required<Pick<SheetProps, keyof typeof DEFAULTS>>;
58
+
59
+ /**
60
+ * The drawer supplies its own backdrop, which would sit on top of `SheetOverlay`
61
+ * and swallow its click. Rendering nothing in its place leaves this component's
62
+ * overlay — the one that knows about the `glass` blur — as the only one.
63
+ *
64
+ * Defined at module scope: a component built inside a render is a new type on
65
+ * every pass, which remounts the subtree it belongs to.
66
+ */
67
+ const NoBackdrop = React.forwardRef<HTMLDivElement, BackdropProps>(
68
+ (_props, _ref) => null,
69
+ ) as React.ComponentType<BackdropProps>;
70
+ NoBackdrop.displayName = 'NoBackdrop';
71
+
72
+ /**
73
+ * Which way the sheet is anchored. Four flags rather than four repeated
74
+ * comparisons, and the only thing the hooks and styles need to know about
75
+ * `position` and `variant`.
76
+ */
77
+ const orientationOf = ({ position, variant }: ResolvedSheetProps) => {
138
78
  const isBottomSheet = position === 'bottom';
139
79
  const isTopSheet = position === 'top';
140
- const isVerticalSheet = isBottomSheet || isTopSheet;
141
-
142
- // Sort snap points for easier navigation
143
- const sortedSnapPoints = useMemo(() => {
144
- const points = [...snapPoints].sort((a, b) => a - b);
145
- // Ensure snap points are within bounds
146
- return points.filter((point) => point >= minSnapPoint && point <= maxSnapPoint);
147
- }, [snapPoints, minSnapPoint, maxSnapPoint]);
148
-
149
- const handleClose = useCallback(() => {
150
- if (!persistent && !disabled) {
151
- setIsOpen(false);
152
- onOpenChange?.(false);
153
- onClose?.();
154
- }
155
- }, [persistent, disabled, onOpenChange, onClose]);
156
-
157
- const updateSheetHeight = useCallback(
158
- (snapPoint: number) => {
159
- if (!sheetRef.current || !isVerticalSheet) return;
160
-
161
- const viewportHeight = window.innerHeight;
162
- const height = Math.round(viewportHeight * snapPoint);
163
-
164
- if (isBottomSheet) {
165
- sheetRef.current.style.height = `${height}px`;
166
- sheetRef.current.style.transform = 'translateY(0)';
167
- } else if (isTopSheet) {
168
- sheetRef.current.style.height = `${height}px`;
169
- }
170
-
171
- setCurrentHeight(height);
172
- },
173
- [isBottomSheet, isTopSheet, isVerticalSheet],
174
- );
175
-
176
- const handleOverlayClick = useCallback(() => {
177
- if (closeOnOverlayClick && !persistent) {
178
- handleClose();
179
- }
180
- }, [closeOnOverlayClick, persistent, handleClose]);
181
-
182
- const animateToSnapPoint = useCallback(
183
- (targetSnapPoint: number, velocity = 0) => {
184
- if (!sheetRef.current || !isVerticalSheet) return;
185
-
186
- setIsAnimating(true);
187
- const startSnapPoint = currentSnapPoint;
188
- const distance = targetSnapPoint - startSnapPoint;
189
- const startTime = globalThis.performance.now();
190
-
191
- // Spring animation parameters
192
- const { tension, friction } = { ...SPRING_CONFIG, ...animationConfig };
193
- const springLength = 0;
194
- let currentVelocity = velocity * 1000; // Convert to pixels per second
195
-
196
- const animate = () => {
197
- const now = globalThis.performance.now();
198
- const elapsed = (now - startTime) / 1000; // Convert to seconds
199
-
200
- // Spring physics calculations
201
- const springForce = -tension * (startSnapPoint - targetSnapPoint + springLength);
202
- const dampingForce = -friction * currentVelocity;
203
- const acceleration = springForce + dampingForce;
204
-
205
- currentVelocity += acceleration * 0.016; // 60fps timestep
206
- const progress = Math.min(1, elapsed * 4); // Smooth easing
207
-
208
- const currentPosition = startSnapPoint + distance * progress;
209
-
210
- updateSheetHeight(currentPosition);
211
-
212
- if (progress < 1) {
213
- animationFrameRef.current = window.requestAnimationFrame(animate);
214
- } else {
215
- setIsAnimating(false);
216
- setCurrentSnapPoint(targetSnapPoint);
217
- onSnapPointChange?.(targetSnapPoint);
218
- updateSheetHeight(targetSnapPoint);
219
- }
220
- };
221
-
222
- animate();
223
- },
224
- [currentSnapPoint, isVerticalSheet, updateSheetHeight, animationConfig, onSnapPointChange],
225
- );
226
-
227
- const findClosestSnapPoint = useCallback(
228
- (currentPosition: number, velocity: number = 0) => {
229
- // If velocity is significant, snap to next/previous point
230
- if (Math.abs(velocity) > velocityThreshold) {
231
- const currentIndex = sortedSnapPoints.findIndex(
232
- (point) => Math.abs(point - currentSnapPoint) < 0.01,
233
- );
234
-
235
- if (velocity > 0 && currentIndex < sortedSnapPoints.length - 1) {
236
- // Dragging up (for bottom sheet) - go to next higher snap point
237
- return sortedSnapPoints[currentIndex + 1];
238
- } else if (velocity < 0 && currentIndex > 0) {
239
- // Dragging down (for bottom sheet) - go to next lower snap point
240
- return sortedSnapPoints[currentIndex - 1];
241
- }
242
- }
243
-
244
- // Find closest snap point
245
- return sortedSnapPoints.reduce((closest, point) => {
246
- const currentDistance = Math.abs(currentPosition - closest);
247
- const pointDistance = Math.abs(currentPosition - point);
248
- return pointDistance < currentDistance ? point : closest;
249
- }, sortedSnapPoints[0] || currentSnapPoint);
250
- },
251
- [sortedSnapPoints, currentSnapPoint, velocityThreshold],
252
- );
253
-
254
- const handleDragStart = useCallback(
255
- (e: React.MouseEvent | React.TouchEvent) => {
256
- if (!isDraggableVariant || !isVerticalSheet || isAnimating) return;
257
-
258
- e.preventDefault();
259
- e.stopPropagation();
260
-
261
- const clientY = 'touches' in e ? (e.touches[0]?.clientY ?? 0) : e.clientY;
262
-
263
- setIsDragging(true);
264
- setDragStartY(clientY);
265
- lastYRef.current = clientY;
266
- lastTimeRef.current = globalThis.performance.now();
267
- velocityRef.current = 0;
268
-
269
- if (animationFrameRef.current) {
270
- window.cancelAnimationFrame(animationFrameRef.current);
271
- }
272
-
273
- onDragStart?.();
274
- },
275
- [isDraggableVariant, isVerticalSheet, isAnimating, onDragStart],
276
- );
277
-
278
- const handleDragMove = useCallback(
279
- (e: globalThis.MouseEvent | globalThis.TouchEvent) => {
280
- if (!isDragging || !sheetRef.current || !isVerticalSheet) return;
281
-
282
- e.preventDefault();
283
- const clientY = 'touches' in e ? (e.touches[0]?.clientY ?? 0) : e.clientY;
284
- const currentTime = globalThis.performance.now();
285
-
286
- // Calculate velocity
287
- const deltaY = clientY - lastYRef.current;
288
- const deltaTime = currentTime - lastTimeRef.current;
289
- if (deltaTime > 0) {
290
- velocityRef.current = deltaY / deltaTime;
291
- }
292
-
293
- lastYRef.current = clientY;
294
- lastTimeRef.current = currentTime;
295
-
296
- const viewportHeight = window.innerHeight;
297
- const dragDelta = isBottomSheet ? dragStartY - clientY : clientY - dragStartY;
298
- const deltaSnapPoint = dragDelta / viewportHeight;
299
- let newSnapPoint = currentSnapPoint + deltaSnapPoint;
300
-
301
- // Apply resistance at boundaries
302
- if (newSnapPoint < minSnapPoint) {
303
- const overshoot = minSnapPoint - newSnapPoint;
304
- newSnapPoint = minSnapPoint - overshoot * dragResistance;
305
- } else if (newSnapPoint > maxSnapPoint) {
306
- const overshoot = newSnapPoint - maxSnapPoint;
307
- newSnapPoint = maxSnapPoint + overshoot * dragResistance;
308
- }
309
-
310
- updateSheetHeight(newSnapPoint);
311
- },
312
- [
313
- isDragging,
314
- isVerticalSheet,
315
- isBottomSheet,
316
- dragStartY,
317
- currentSnapPoint,
318
- minSnapPoint,
319
- maxSnapPoint,
320
- dragResistance,
321
- updateSheetHeight,
322
- ],
323
- );
324
-
325
- useEffect(() => {
326
- setIsOpen(open);
327
- if (open) {
328
- onOpen?.();
329
- if (isDraggableVariant && isVerticalSheet) {
330
- // Initialize at default snap point
331
- setCurrentSnapPoint(defaultSnapPoint);
332
- updateSheetHeight(defaultSnapPoint);
333
- }
334
- }
335
- }, [open, onOpen, isDraggableVariant, isVerticalSheet, defaultSnapPoint, updateSheetHeight]);
336
80
 
337
- useEffect(() => {
338
- const handleEscape = (e: globalThis.KeyboardEvent) => {
339
- if (closeOnEscape && e.key === 'Escape' && isOpen) {
340
- handleClose();
341
- }
342
- };
343
-
344
- if (isOpen) {
345
- document.addEventListener('keydown', handleEscape);
346
- }
347
-
348
- return () => {
349
- document.removeEventListener('keydown', handleEscape);
350
- };
351
- }, [isOpen, closeOnEscape, handleClose]);
352
-
353
- const handleDragEnd = useCallback(
354
- (e: globalThis.MouseEvent | globalThis.TouchEvent) => {
355
- if (!isDragging || !isVerticalSheet) return;
356
-
357
- e.preventDefault();
358
- setIsDragging(false);
359
-
360
- const clientY = 'touches' in e ? (e.touches[0]?.clientY ?? 0) : e.clientY;
361
- const viewportHeight = window.innerHeight;
362
- const totalDragDelta = isBottomSheet ? dragStartY - clientY : clientY - dragStartY;
363
- const deltaSnapPoint = totalDragDelta / viewportHeight;
364
- const currentPosition = currentSnapPoint + deltaSnapPoint;
365
-
366
- // Calculate final velocity for momentum-based snapping
367
- const velocity = isBottomSheet ? -(velocityRef.current || 0) : velocityRef.current || 0;
368
-
369
- // Find and animate to closest snap point
370
- const targetSnapPoint = findClosestSnapPoint(currentPosition, velocity);
371
- if (targetSnapPoint !== undefined) {
372
- animateToSnapPoint(targetSnapPoint, velocity);
373
- onDragEnd?.(targetSnapPoint);
374
- }
375
- },
376
- [
377
- isDragging,
378
- isVerticalSheet,
379
- isBottomSheet,
380
- dragStartY,
381
- currentSnapPoint,
382
- findClosestSnapPoint,
383
- animateToSnapPoint,
384
- onDragEnd,
385
- ],
386
- );
387
-
388
- // Add drag event listeners
389
- useEffect(() => {
390
- if (isDragging) {
391
- const handleMouseMove = (e: globalThis.MouseEvent) => handleDragMove(e);
392
- const handleMouseUp = (e: globalThis.MouseEvent) => handleDragEnd(e);
393
- const handleTouchMove = (e: globalThis.TouchEvent) => handleDragMove(e);
394
- const handleTouchEnd = (e: globalThis.TouchEvent) => handleDragEnd(e);
395
-
396
- document.addEventListener('mousemove', handleMouseMove);
397
- document.addEventListener('mouseup', handleMouseUp);
398
- document.addEventListener('touchmove', handleTouchMove, { passive: false });
399
- document.addEventListener('touchend', handleTouchEnd);
400
-
401
- return () => {
402
- document.removeEventListener('mousemove', handleMouseMove);
403
- document.removeEventListener('mouseup', handleMouseUp);
404
- document.removeEventListener('touchmove', handleTouchMove);
405
- document.removeEventListener('touchend', handleTouchEnd);
406
- };
407
- }
408
- }, [isDragging, handleDragMove, handleDragEnd]);
409
-
410
- const getSizeStyles = () => {
411
- const isHorizontal = position === 'left' || position === 'right';
412
-
413
- // For draggable variant, use dynamic height based on snap point
414
- if (isDraggableVariant && isVerticalSheet && currentHeight !== null) {
415
- return { height: currentHeight };
416
- }
417
-
418
- // Horizontal (left/right) sheets are viewport-aware: a px floor keeps the
419
- // panel usable, `…vw` lets the larger presets grow on wide screens (lg ≥32%,
420
- // xl ≥40% of the viewport), and the outer `min(92vw, …)` guarantees the
421
- // panel never overflows a small screen. Vertical presets stay fixed-height.
422
- switch (size) {
423
- case 'xs':
424
- return isHorizontal ? { width: 'min(92vw, 240px)' } : { height: 200 };
425
- case 'sm':
426
- return isHorizontal ? { width: 'min(92vw, 320px)' } : { height: 300 };
427
- case 'md':
428
- return isHorizontal ? { width: 'min(92vw, 400px)' } : { height: 400 };
429
- case 'lg':
430
- return isHorizontal ? { width: 'min(92vw, max(560px, 32vw))' } : { height: 500 };
431
- case 'xl':
432
- return isHorizontal ? { width: 'min(92vw, max(720px, 40vw))' } : { height: 600 };
433
- case 'full':
434
- return isHorizontal ? { width: '100%' } : { height: '100%' };
435
- default:
436
- return isHorizontal ? { width: 'min(92vw, 400px)' } : { height: 400 };
437
- }
81
+ return {
82
+ isBottomSheet,
83
+ isTopSheet,
84
+ isVerticalSheet: isBottomSheet || isTopSheet,
85
+ isDraggableVariant: variant === 'draggable',
438
86
  };
87
+ };
439
88
 
440
- const getVariantStyles = () => {
441
- // Extract RGB values for CSS variables
442
- const getRgbValues = (hexColor: string) => {
443
- const hex = hexColor.replace('#', '');
444
- const r = parseInt(hex.substring(0, 2), 16);
445
- const g = parseInt(hex.substring(2, 4), 16);
446
- const b = parseInt(hex.substring(4, 6), 16);
447
- return `${r}, ${g}, ${b}`;
448
- };
449
-
450
- const colorMain = theme.palette[color]?.main || theme.palette.primary.main;
451
- const colorRgb = getRgbValues(colorMain);
452
-
453
- const baseStyles = {
454
- backgroundColor: theme.palette.background.paper,
455
- transition:
456
- !isDragging && !isAnimating
457
- ? theme.transitions.create(['all'], {
458
- duration: theme.transitions.duration.standard,
459
- easing: theme.transitions.easing.easeInOut,
460
- })
461
- : 'none',
462
- opacity: disabled ? 0.5 : 1,
463
- pointerEvents: disabled ? ('none' as const) : ('auto' as const),
464
- cursor: isDraggableVariant && isVerticalSheet ? 'grab' : 'auto',
465
- '--pulse-color': colorRgb,
466
- '--glow-color': colorRgb,
467
- ...(isDragging && { cursor: 'grabbing' }),
468
- };
469
-
470
- const glowStyles = glow
471
- ? {
472
- animation: `${glowAnimation} 2s ease-in-out infinite`,
473
- filter: 'brightness(1.05)',
474
- }
475
- : {};
476
-
477
- const pulseStyles = pulse
478
- ? {
479
- animation: `${pulseAnimation} 2s infinite`,
480
- position: 'relative' as const,
481
- }
482
- : {};
483
-
484
- const roundedStyles =
485
- rounded && (position === 'top' || position === 'bottom')
486
- ? {
487
- borderRadius:
488
- position === 'top'
489
- ? `0 0 ${theme.spacing(2)} ${theme.spacing(2)}`
490
- : `${theme.spacing(2)} ${theme.spacing(2)} 0 0`,
491
- }
492
- : rounded && (position === 'left' || position === 'right')
493
- ? {
494
- borderRadius:
495
- position === 'left'
496
- ? `0 ${theme.spacing(2)} ${theme.spacing(2)} 0`
497
- : `${theme.spacing(2)} 0 0 ${theme.spacing(2)}`,
498
- }
499
- : {};
500
-
501
- switch (variant) {
502
- case 'draggable':
503
- return {
504
- ...baseStyles,
505
- ...glowStyles,
506
- ...pulseStyles,
507
- ...roundedStyles,
508
- boxShadow: `
509
- ${theme.shadows[Math.min(elevation + 4, 24)]},
510
- 0 -2px 10px 0 ${alpha(theme.palette.common.black, 0.1)}
511
- `,
512
- border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
513
- borderTop: `2px solid ${alpha(theme.palette[color]?.main || theme.palette.primary.main, 0.3)}`,
514
- transition: theme.transitions.create(['transform', 'box-shadow', 'border-color'], {
515
- duration: theme.transitions.duration.shorter,
516
- easing: theme.transitions.easing.easeInOut,
517
- }),
518
- '&:hover': {
519
- borderTopColor: alpha(theme.palette[color]?.main || theme.palette.primary.main, 0.5),
520
- },
521
- };
522
-
523
- case 'glass':
524
- return {
525
- ...baseStyles,
526
- ...glowStyles,
527
- ...pulseStyles,
528
- ...roundedStyles,
529
- backgroundColor: alpha(theme.palette.background.paper, glass ? 0.75 : 0.95),
530
- backdropFilter: 'blur(24px) saturate(180%)',
531
- WebkitBackdropFilter: 'blur(24px) saturate(180%)',
532
- border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
533
- boxShadow: `
534
- 0 8px 32px 0 ${alpha(theme.palette.common.black, 0.15)},
535
- inset 0 0 0 1px ${alpha(theme.palette.common.white, 0.1)}
536
- `,
537
- };
538
-
539
- case 'gradient':
540
- return {
541
- ...baseStyles,
542
- ...glowStyles,
543
- ...pulseStyles,
544
- ...roundedStyles,
545
- background: gradient
546
- ? `linear-gradient(
547
- 135deg,
548
- ${theme.palette.background.paper} 0%,
549
- ${alpha(theme.palette[color]?.main || theme.palette.primary.main, 0.08)} 50%,
550
- ${alpha(theme.palette[color]?.dark || theme.palette[color]?.main || theme.palette.primary.main, 0.12)} 100%
551
- )`
552
- : theme.palette.background.paper,
553
- position: 'relative' as const,
554
- overflow: 'hidden' as const,
555
- '&::before': gradient
556
- ? {
557
- content: '""',
558
- position: 'absolute',
559
- top: 0,
560
- left: '-100%',
561
- width: '100%',
562
- height: '100%',
563
- background: `linear-gradient(
564
- 90deg,
565
- transparent,
566
- ${alpha(theme.palette.common.white, 0.2)},
567
- transparent
568
- )`,
569
- animation: `${shimmerAnimation} 3s infinite`,
570
- }
571
- : {},
572
- };
573
-
574
- case 'elevated':
575
- return {
576
- ...baseStyles,
577
- ...glowStyles,
578
- ...pulseStyles,
579
- ...roundedStyles,
580
- boxShadow: `
581
- ${theme.shadows[elevation]},
582
- 0 20px 40px -15px ${alpha(theme.palette.common.black, 0.15)}
583
- `,
584
- transform: 'translateZ(0)',
585
- willChange: 'transform',
586
- };
587
-
588
- case 'minimal':
589
- return {
590
- ...baseStyles,
591
- ...glowStyles,
592
- ...pulseStyles,
593
- ...roundedStyles,
594
- boxShadow: 'none',
595
- border: 'none',
596
- };
89
+ export const Sheet: React.FC<SheetProps> = (props) => {
90
+ const resolved = withDefaults(props, DEFAULTS) as ResolvedSheetProps;
91
+ const { position, variant, showOverlay, swipeable, className, dataTestId } = resolved;
597
92
 
598
- default:
599
- return {
600
- ...baseStyles,
601
- ...glowStyles,
602
- ...pulseStyles,
603
- ...roundedStyles,
604
- };
605
- }
93
+ const theme = useTheme();
94
+ const orientation = orientationOf(resolved);
95
+
96
+ // Support both the dataTestId prop and a plain data-testid attribute.
97
+ const testId = (resolved as { 'data-testid'?: string })['data-testid'] || dataTestId;
98
+
99
+ const { sheetRef, currentHeight, isDragging, isAnimating, handleDragStart, resetToDefaultSnapPoint } =
100
+ useSheetDrag({ ...resolved, ...orientation });
101
+
102
+ const { isOpen, handleClose, handleOverlayClick, handleSwipeOpen } = useSheetOpen({
103
+ ...resolved,
104
+ ...orientation,
105
+ resetToDefaultSnapPoint,
106
+ });
107
+
108
+ const drawerProps = {
109
+ anchor: position,
110
+ open: isOpen,
111
+ className,
112
+ onClose: handleClose,
113
+ PaperProps: {
114
+ ref: sheetRef,
115
+ sx: panelSx({ ...resolved, ...orientation, theme, currentHeight, isDragging, isAnimating }),
116
+ },
117
+ ModalProps: { keepMounted: true, BackdropComponent: NoBackdrop },
606
118
  };
607
119
 
608
- const isSwipeable = swipeable && !isDraggableVariant;
120
+ const body = (
121
+ <SheetBody
122
+ {...resolved}
123
+ {...orientation}
124
+ testId={testId}
125
+ isDragging={isDragging}
126
+ onClose={handleClose}
127
+ onDragStart={handleDragStart}
128
+ />
129
+ );
609
130
 
610
- // Create a proper BackdropComponent that accepts refs and matches BackdropProps
611
- const NoBackdrop = React.forwardRef<HTMLDivElement, BackdropProps>((_props, _ref) => null) as React.ComponentType<BackdropProps>;
612
- NoBackdrop.displayName = 'NoBackdrop';
131
+ // The draggable variant drives the panel's height itself; MUI's swipe handling
132
+ // would be a second thing moving it.
133
+ const isSwipeable = swipeable && !orientation.isDraggableVariant;
613
134
 
614
135
  return (
615
136
  <>
@@ -619,360 +140,16 @@ export const Sheet: React.FC<SheetProps> = ({
619
140
 
620
141
  {isSwipeable ? (
621
142
  <SwipeableDrawer
622
- onOpen={() => {
623
- setIsOpen(true);
624
- onOpenChange?.(true);
625
- onOpen?.();
626
- }}
627
- onClose={handleClose}
143
+ {...drawerProps}
144
+ onOpen={handleSwipeOpen}
628
145
  disableSwipeToOpen={false}
629
146
  swipeAreaWidth={20}
630
- anchor={position}
631
- open={isOpen}
632
- className={className}
633
- PaperProps={{
634
- ref: sheetRef,
635
- sx: {
636
- ...getSizeStyles(),
637
- ...getVariantStyles(),
638
- ...style,
639
- overflow: 'visible',
640
- // Expand only the cross axis so the chosen `size` still governs
641
- // the main axis: side sheets keep their (responsive) width and go
642
- // full-height; top/bottom sheets keep their height and go full-width.
643
- ...(fullHeight &&
644
- !isDraggableVariant &&
645
- (position === 'left' || position === 'right'
646
- ? { height: '100%' }
647
- : { width: '100%' })),
648
- },
649
- }}
650
- ModalProps={{
651
- keepMounted: true,
652
- BackdropComponent: NoBackdrop,
653
- }}
654
147
  >
655
- <Box
656
- ref={contentRef}
657
- onClick={onClick}
658
- onFocus={onFocus}
659
- onBlur={onBlur}
660
- data-testid={testId}
661
- sx={{
662
- height: '100%',
663
- display: 'flex',
664
- flexDirection: 'column',
665
- position: 'relative',
666
- userSelect: isDragging ? 'none' : 'auto',
667
- }}
668
- >
669
- {(showHandle || title || description || showCloseButton || header) && (
670
- <SheetHeader
671
- title={title}
672
- description={description}
673
- showCloseButton={showCloseButton}
674
- onClose={handleClose}
675
- showHandle={showHandle && isVerticalSheet}
676
- isDraggable={isDraggableVariant && isVerticalSheet}
677
- onDragStart={handleDragStart}
678
- dataTestId={`${testId}-header`}
679
- >
680
- {header}
681
- </SheetHeader>
682
- )}
683
-
684
- <SheetContent padded dataTestId={`${testId}-content`}>
685
- {loading ? (
686
- <Box
687
- sx={{
688
- display: 'flex',
689
- justifyContent: 'center',
690
- alignItems: 'center',
691
- minHeight: 200,
692
- }}
693
- >
694
- <CircularProgress color={color} />
695
- </Box>
696
- ) : (
697
- children
698
- )}
699
- </SheetContent>
700
-
701
- {footer && (
702
- <SheetFooter sticky divider dataTestId={`${testId}-footer`}>
703
- {footer}
704
- </SheetFooter>
705
- )}
706
- </Box>
148
+ {body}
707
149
  </SwipeableDrawer>
708
150
  ) : (
709
- <Drawer
710
- onClose={handleClose}
711
- anchor={position}
712
- open={isOpen}
713
- className={className}
714
- PaperProps={{
715
- ref: sheetRef,
716
- sx: {
717
- ...getSizeStyles(),
718
- ...getVariantStyles(),
719
- ...style,
720
- overflow: 'visible',
721
- // Expand only the cross axis so the chosen `size` still governs
722
- // the main axis: side sheets keep their (responsive) width and go
723
- // full-height; top/bottom sheets keep their height and go full-width.
724
- ...(fullHeight &&
725
- !isDraggableVariant &&
726
- (position === 'left' || position === 'right'
727
- ? { height: '100%' }
728
- : { width: '100%' })),
729
- },
730
- }}
731
- ModalProps={{
732
- keepMounted: true,
733
- BackdropComponent: NoBackdrop,
734
- }}
735
- >
736
- <Box
737
- ref={contentRef}
738
- onClick={onClick}
739
- onFocus={onFocus}
740
- onBlur={onBlur}
741
- data-testid={testId}
742
- sx={{
743
- height: '100%',
744
- display: 'flex',
745
- flexDirection: 'column',
746
- position: 'relative',
747
- userSelect: isDragging ? 'none' : 'auto',
748
- }}
749
- >
750
- {(showHandle || title || description || showCloseButton || header) && (
751
- <SheetHeader
752
- title={title}
753
- description={description}
754
- showCloseButton={showCloseButton}
755
- onClose={handleClose}
756
- showHandle={showHandle && isVerticalSheet}
757
- isDraggable={isDraggableVariant && isVerticalSheet}
758
- onDragStart={handleDragStart}
759
- dataTestId={`${testId}-header`}
760
- >
761
- {header}
762
- </SheetHeader>
763
- )}
764
-
765
- <SheetContent padded dataTestId={`${testId}-content`}>
766
- {loading ? (
767
- <Box
768
- sx={{
769
- display: 'flex',
770
- justifyContent: 'center',
771
- alignItems: 'center',
772
- minHeight: 200,
773
- }}
774
- >
775
- <CircularProgress color={color} />
776
- </Box>
777
- ) : (
778
- children
779
- )}
780
- </SheetContent>
781
-
782
- {footer && (
783
- <SheetFooter sticky divider dataTestId={`${testId}-footer`}>
784
- {footer}
785
- </SheetFooter>
786
- )}
787
- </Box>
788
- </Drawer>
789
- )}
790
- </>
791
- );
792
- };
793
-
794
- export const SheetHeader: React.FC<SheetHeaderProps> = ({
795
- title,
796
- description,
797
- showCloseButton,
798
- onClose,
799
- showHandle,
800
- isDraggable = false,
801
- onDragStart,
802
- className,
803
- style,
804
- children,
805
- dataTestId = 'sheet-header',
806
- }) => {
807
- const theme = useTheme();
808
-
809
- return (
810
- <Box
811
- className={className}
812
- data-testid={dataTestId}
813
- sx={{
814
- p: 2,
815
- pb: showHandle ? 1 : 2,
816
- cursor: isDraggable ? 'grab' : 'auto',
817
- '&:active': isDraggable ? { cursor: 'grabbing' } : {},
818
- ...style,
819
- }}
820
- onMouseDown={isDraggable ? onDragStart : undefined}
821
- onTouchStart={isDraggable ? onDragStart : undefined}
822
- >
823
- {showHandle && (
824
- <Box
825
- sx={{
826
- display: 'flex',
827
- justifyContent: 'center',
828
- mb: 1,
829
- pointerEvents: isDraggable ? 'none' : 'auto',
830
- }}
831
- >
832
- <Box
833
- sx={{
834
- width: isDraggable ? 48 : 32,
835
- height: isDraggable ? 6 : 4,
836
- backgroundColor: alpha(theme.palette.text.primary, 0.3),
837
- borderRadius: 3,
838
- transition: theme.transitions.create(['all'], {
839
- duration: theme.transitions.duration.short,
840
- easing: theme.transitions.easing.easeInOut,
841
- }),
842
- position: 'relative',
843
- overflow: 'hidden',
844
- '&::after': {
845
- content: '""',
846
- position: 'absolute',
847
- top: 0,
848
- left: '-100%',
849
- width: '100%',
850
- height: '100%',
851
- background: `linear-gradient(
852
- 90deg,
853
- transparent,
854
- ${alpha(theme.palette.common.white, 0.3)},
855
- transparent
856
- )`,
857
- animation: isDraggable ? `${shimmerAnimation} 2s infinite` : 'none',
858
- },
859
- ...(isDraggable && {
860
- backgroundColor: alpha(theme.palette.primary.main, 0.4),
861
- boxShadow: `0 2px 4px ${alpha(theme.palette.primary.main, 0.2)}`,
862
- '&:hover': {
863
- backgroundColor: theme.palette.primary.main,
864
- transform: 'scaleX(1.15) scaleY(1.2)',
865
- boxShadow: `0 3px 6px ${alpha(theme.palette.primary.main, 0.3)}`,
866
- },
867
- }),
868
- }}
869
- />
870
- </Box>
151
+ <Drawer {...drawerProps}>{body}</Drawer>
871
152
  )}
872
-
873
- <Box
874
- sx={{
875
- display: 'flex',
876
- alignItems: 'flex-start',
877
- justifyContent: 'space-between',
878
- }}
879
- >
880
- <Box sx={{ flex: 1 }}>
881
- {title && (
882
- <Typography variant="h6" sx={{ fontWeight: 600 }} data-testid={`${dataTestId}-title`}>
883
- {title}
884
- </Typography>
885
- )}
886
- {description && (
887
- <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
888
- {description}
889
- </Typography>
890
- )}
891
- {children}
892
- </Box>
893
-
894
- {showCloseButton && (
895
- <IconButton onClick={onClose} size="small" sx={{ ml: 1, mt: -0.5 }} data-testid={`${dataTestId}-close-button`}>
896
- <CloseIcon data-testid={`${dataTestId}-close-icon`} />
897
- </IconButton>
898
- )}
899
- </Box>
900
- </Box>
901
- );
902
- };
903
-
904
- export const SheetContent: React.FC<SheetContentProps> = ({
905
- children,
906
- className,
907
- style,
908
- padded = true,
909
- dataTestId = 'sheet-content',
910
- }) => (
911
- <Box
912
- className={className}
913
- data-testid={dataTestId}
914
- sx={{
915
- flex: 1,
916
- overflow: 'auto',
917
- p: padded ? 2 : 0,
918
- ...style,
919
- }}
920
- >
921
- {children}
922
- </Box>
923
- );
924
-
925
- export const SheetFooter: React.FC<SheetFooterProps> = ({
926
- children,
927
- className,
928
- style,
929
- sticky = false,
930
- divider = false,
931
- dataTestId = 'sheet-footer',
932
- }) => (
933
- <>
934
- {divider && <Divider />}
935
- <Box
936
- className={className}
937
- data-testid={dataTestId}
938
- sx={{
939
- p: 2,
940
- position: sticky ? 'sticky' : 'relative',
941
- bottom: sticky ? 0 : 'auto',
942
- backgroundColor: 'background.paper',
943
- borderTop: sticky ? '1px solid' : 'none',
944
- borderColor: 'divider',
945
- ...style,
946
- }}
947
- >
948
- {children}
949
- </Box>
950
153
  </>
951
154
  );
952
-
953
- export const SheetOverlay: React.FC<SheetOverlayProps> = ({
954
- open = false,
955
- onClick,
956
- className,
957
- style,
958
- blur = false,
959
- }) => {
960
- const theme = useTheme();
961
-
962
- return (
963
- <Fade in={open} timeout={300}>
964
- <Backdrop
965
- open={open}
966
- onClick={onClick}
967
- className={className}
968
- sx={{
969
- zIndex: theme.zIndex.drawer - 1,
970
- backgroundColor: alpha(theme.palette.common.black, blur ? 0.6 : 0.5),
971
- backdropFilter: blur ? 'blur(8px) saturate(180%)' : 'none',
972
- WebkitBackdropFilter: blur ? 'blur(8px) saturate(180%)' : 'none',
973
- ...style,
974
- }}
975
- />
976
- </Fade>
977
- );
978
155
  };