@snowcone-app/ui 0.1.43 → 0.2.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 (192) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +18 -4
  3. package/package.json +9 -5
  4. package/src/components/CanvasIsolationBoundary.tsx +202 -0
  5. package/src/components/LoadingOverlayPrism.tsx +251 -0
  6. package/src/composed/AddToCart.tsx +229 -0
  7. package/src/composed/ArtAlignment.tsx +703 -0
  8. package/src/composed/ArtSelector.tsx +290 -0
  9. package/src/composed/ArtworkCustomizer.tsx +212 -0
  10. package/src/composed/CanvasEditor.tsx +79 -0
  11. package/src/composed/ColorPicker.tsx +111 -0
  12. package/src/composed/CurrentSelectionDisplay.tsx +86 -0
  13. package/src/composed/HeroProductImage.tsx +1071 -0
  14. package/src/composed/Lightbox.index.ts +2 -0
  15. package/src/composed/Lightbox.tsx +230 -0
  16. package/src/composed/PlacementClipShapeSelector.tsx +88 -0
  17. package/src/composed/PlacementTabs.tsx +179 -0
  18. package/src/composed/ProductCard.tsx +298 -0
  19. package/src/composed/ProductGallery.tsx +54 -0
  20. package/src/composed/ProductImage.tsx +129 -0
  21. package/src/composed/ProductList.tsx +147 -0
  22. package/src/composed/ProductOptions.tsx +305 -0
  23. package/src/composed/RealtimeMockup.tsx +121 -0
  24. package/src/composed/TileCount.tsx +348 -0
  25. package/src/composed/carousels/HeroCarousel.tsx +240 -0
  26. package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
  27. package/src/composed/carousels/index.ts +11 -0
  28. package/src/composed/carousels/types.ts +58 -0
  29. package/src/composed/grids/MasonryGrid.tsx +238 -0
  30. package/src/composed/grids/index.ts +9 -0
  31. package/src/composed/search/CurrentRefinements.tsx +80 -0
  32. package/src/composed/search/Filters.tsx +49 -0
  33. package/src/composed/search/FiltersButton.tsx +57 -0
  34. package/src/composed/search/FiltersDrawer.tsx +375 -0
  35. package/src/composed/search/ProductGrid.tsx +118 -0
  36. package/src/composed/search/ProductHit.tsx +56 -0
  37. package/src/composed/search/SearchBox.tsx +109 -0
  38. package/src/composed/search/SearchProvider.tsx +136 -0
  39. package/src/composed/search/facetConfig.ts +16 -0
  40. package/src/composed/search/index.ts +22 -0
  41. package/src/composed/search/meilisearchAdapter.ts +20 -0
  42. package/src/composed/search/types.ts +22 -0
  43. package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
  44. package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
  45. package/src/composed/zoom/ZoomOverlay.tsx +194 -0
  46. package/src/composed/zoom/index.ts +12 -0
  47. package/src/composed/zoom/types.ts +12 -0
  48. package/src/design-system/ColorPalette.tsx +126 -0
  49. package/src/design-system/ColorSwatch.tsx +49 -0
  50. package/src/design-system/DesignSystemPage.tsx +130 -0
  51. package/src/design-system/ThemeSwitcher.tsx +181 -0
  52. package/src/design-system/TypographyScale.tsx +106 -0
  53. package/src/design-system/index.ts +5 -0
  54. package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
  55. package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
  56. package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
  57. package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
  58. package/src/hooks/useBrand.ts +41 -0
  59. package/src/hooks/useCanvasContext.ts +127 -0
  60. package/src/hooks/useDeviceDetection.ts +64 -0
  61. package/src/hooks/useFocusTrap.ts +70 -0
  62. package/src/hooks/useImagePreloader.ts +268 -0
  63. package/src/hooks/useImageTransition.ts +608 -0
  64. package/src/hooks/usePlacementsProcessor.ts +74 -0
  65. package/src/hooks/useProductGallery.ts +193 -0
  66. package/src/hooks/useProductPage.ts +467 -0
  67. package/src/hooks/useRenderGuard.ts +96 -0
  68. package/src/hooks/useScrollDirection.ts +196 -0
  69. package/src/hooks/viewport/index.ts +25 -0
  70. package/src/hooks/viewport/useContainerWidth.ts +59 -0
  71. package/src/hooks/viewport/useMediaQuery.ts +52 -0
  72. package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
  73. package/src/hooks/viewport/useViewportDimensions.ts +135 -0
  74. package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
  75. package/src/hooks/visibility/index.ts +15 -0
  76. package/src/hooks/visibility/observerPool.ts +150 -0
  77. package/src/index.ts +240 -0
  78. package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
  79. package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
  80. package/src/layouts/hero-zoom/index.ts +30 -0
  81. package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
  82. package/src/layouts/hero-zoom/types.ts +113 -0
  83. package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
  84. package/src/layouts/index.ts +9 -0
  85. package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
  86. package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
  87. package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
  88. package/src/layouts/pdp/PDPLayout.tsx +246 -0
  89. package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
  90. package/src/layouts/pdp/index.ts +40 -0
  91. package/src/lib/env.ts +15 -0
  92. package/src/lib/locale.ts +167 -0
  93. package/src/lib/router.tsx +46 -0
  94. package/src/lib/utils.ts +6 -0
  95. package/src/lightbox/README.md +77 -0
  96. package/src/next/index.tsx +26 -0
  97. package/src/patterns/MockupPriorityProvider.tsx +1014 -0
  98. package/src/patterns/Product.tsx +850 -0
  99. package/src/patterns/ProductPageProvider.tsx +224 -0
  100. package/src/patterns/RealtimeProvider.tsx +1162 -0
  101. package/src/patterns/ShopProvider.tsx +603 -0
  102. package/src/personalization/PersonalizationBridge.tsx +235 -0
  103. package/src/personalization/PersonalizationContext.ts +29 -0
  104. package/src/personalization/PersonalizationInputs.tsx +110 -0
  105. package/src/personalization/PersonalizationProvider.tsx +407 -0
  106. package/src/personalization/canvas-stub.d.ts +22 -0
  107. package/src/personalization/index.ts +43 -0
  108. package/src/personalization/types.ts +48 -0
  109. package/src/personalization/usePersonalization.ts +32 -0
  110. package/src/personalization/usePersonalizationShimmer.ts +159 -0
  111. package/src/personalization/utils.ts +59 -0
  112. package/src/primitives/BrandLogo.tsx +65 -0
  113. package/src/primitives/BrandName.tsx +51 -0
  114. package/src/primitives/Button.tsx +123 -0
  115. package/src/primitives/ColorSwatch.tsx +221 -0
  116. package/src/primitives/DragHintAnimation.tsx +190 -0
  117. package/src/primitives/EdgeSwipeGuards.tsx +60 -0
  118. package/src/primitives/FloatingActionGroup.tsx +176 -0
  119. package/src/primitives/ProductPrice.tsx +171 -0
  120. package/src/primitives/ProgressiveBlur.tsx +295 -0
  121. package/src/primitives/ThemeToggle.tsx +125 -0
  122. package/src/primitives/__tests__/story-coverage.test.ts +98 -0
  123. package/src/primitives/accordion.tsx +280 -0
  124. package/src/primitives/badge.tsx +137 -0
  125. package/src/primitives/card.tsx +61 -0
  126. package/src/primitives/checkbox.tsx +56 -0
  127. package/src/primitives/collapsible.tsx +51 -0
  128. package/src/primitives/drawer.tsx +828 -0
  129. package/src/primitives/dropdown-menu.tsx +197 -0
  130. package/src/primitives/fieldset.tsx +73 -0
  131. package/src/primitives/index.ts +138 -0
  132. package/src/primitives/input.tsx +91 -0
  133. package/src/primitives/kbd.tsx +130 -0
  134. package/src/primitives/label.tsx +20 -0
  135. package/src/primitives/link.tsx +182 -0
  136. package/src/primitives/popover.tsx +80 -0
  137. package/src/primitives/radio-group.tsx +79 -0
  138. package/src/primitives/scroll-fade.tsx +159 -0
  139. package/src/primitives/select.tsx +170 -0
  140. package/src/primitives/separator.tsx +25 -0
  141. package/src/primitives/slider.tsx +221 -0
  142. package/src/primitives/spinner.tsx +72 -0
  143. package/src/primitives/stories/Accordion.stories.tsx +121 -0
  144. package/src/primitives/stories/Badge.stories.tsx +221 -0
  145. package/src/primitives/stories/Button.stories.tsx +185 -0
  146. package/src/primitives/stories/Card.stories.tsx +171 -0
  147. package/src/primitives/stories/Checkbox.stories.tsx +214 -0
  148. package/src/primitives/stories/Collapsible.stories.tsx +230 -0
  149. package/src/primitives/stories/Drawer.stories.tsx +378 -0
  150. package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
  151. package/src/primitives/stories/Fieldset.stories.tsx +212 -0
  152. package/src/primitives/stories/Input.stories.tsx +172 -0
  153. package/src/primitives/stories/Kbd.stories.tsx +183 -0
  154. package/src/primitives/stories/Label.stories.tsx +98 -0
  155. package/src/primitives/stories/Link.stories.tsx +260 -0
  156. package/src/primitives/stories/Popover.stories.tsx +178 -0
  157. package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
  158. package/src/primitives/stories/Select.stories.tsx +222 -0
  159. package/src/primitives/stories/Separator.stories.tsx +134 -0
  160. package/src/primitives/stories/Slider.stories.tsx +203 -0
  161. package/src/primitives/stories/Spinner.stories.tsx +142 -0
  162. package/src/primitives/stories/Surface.stories.tsx +257 -0
  163. package/src/primitives/stories/Switch.stories.tsx +131 -0
  164. package/src/primitives/stories/Tabs.stories.tsx +275 -0
  165. package/src/primitives/stories/TextField.stories.tsx +139 -0
  166. package/src/primitives/stories/Textarea.stories.tsx +148 -0
  167. package/src/primitives/stories/Tooltip.stories.tsx +119 -0
  168. package/src/primitives/surface.tsx +86 -0
  169. package/src/primitives/switch.tsx +35 -0
  170. package/src/primitives/tabs.tsx +206 -0
  171. package/src/primitives/text-field.tsx +84 -0
  172. package/src/primitives/textarea.tsx +50 -0
  173. package/src/primitives/tooltip.tsx +58 -0
  174. package/src/services/CanvasExportService.ts +518 -0
  175. package/src/styles/base.css +380 -0
  176. package/src/styles/defaults.css +280 -0
  177. package/src/styles/globals.css +1242 -0
  178. package/src/styles/index.css +17 -0
  179. package/src/styles/ne-themes.css +4740 -0
  180. package/src/styles/tailwind.css +11 -0
  181. package/src/styles/tokens.css +117 -0
  182. package/src/styles/utilities.css +188 -0
  183. package/src/themes/apply-theme.ts +449 -0
  184. package/src/themes/getThemeStyles.ts +454 -0
  185. package/src/themes/index.ts +48 -0
  186. package/src/themes/oklch-theme.ts +283 -0
  187. package/src/themes/presets.ts +989 -0
  188. package/src/themes/types.ts +386 -0
  189. package/src/themes/useTheme.tsx +450 -0
  190. package/src/utils/dev-warnings.ts +161 -0
  191. package/src/utils/devWarnings.ts +153 -0
  192. package/dist/styles.css +0 -1
@@ -0,0 +1,828 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { cn } from "../lib/utils";
6
+ import { Surface } from "./surface";
7
+
8
+ /**
9
+ * Drawer Component
10
+ *
11
+ * A mobile-optimized drawer component that works correctly on all platforms including iOS Safari.
12
+ * Uses CSS animations and proper positioning for smooth, native-feeling interactions.
13
+ *
14
+ * Key features:
15
+ * - CSS-based animations for smooth performance
16
+ * - Backdrop blur overlay for modern iOS 26+ appearance
17
+ * - Proper safe-area handling via CSS (no explicit color props needed)
18
+ * - Native swipe-to-dismiss gesture support
19
+ * - Works with scrollable page content
20
+ *
21
+ * @example Basic usage
22
+ * <Drawer open={isOpen} onOpenChange={setIsOpen}>
23
+ * <DrawerTrigger asChild>
24
+ * <Button>Open Drawer</Button>
25
+ * </DrawerTrigger>
26
+ * <DrawerContent>
27
+ * <DrawerHeader>
28
+ * <DrawerTitle>Title</DrawerTitle>
29
+ * <DrawerDescription>Description</DrawerDescription>
30
+ * </DrawerHeader>
31
+ * <div className="p-4">Content here</div>
32
+ * <DrawerFooter>
33
+ * <Button>Action</Button>
34
+ * </DrawerFooter>
35
+ * </DrawerContent>
36
+ * </Drawer>
37
+ *
38
+ * @example Custom colored drawer with blur overlay
39
+ * <DrawerContent className="bg-emerald-500">
40
+ * <DrawerTitle className="sr-only">Sheet</DrawerTitle>
41
+ * <div className="p-4 text-white">
42
+ * Content with custom background - safe areas handled automatically
43
+ * </div>
44
+ * </DrawerContent>
45
+ */
46
+
47
+ interface DrawerContextValue {
48
+ open: boolean;
49
+ onOpenChange: (open: boolean) => void;
50
+ }
51
+
52
+ const DrawerContext = React.createContext<DrawerContextValue | null>(null);
53
+
54
+ function useDrawerContext() {
55
+ const context = React.useContext(DrawerContext);
56
+ if (!context) {
57
+ throw new Error("Drawer components must be used within a Drawer");
58
+ }
59
+ return context;
60
+ }
61
+
62
+ // ============================================================================
63
+ // Drawer (Root)
64
+ // ============================================================================
65
+
66
+ interface DrawerProps {
67
+ open?: boolean;
68
+ defaultOpen?: boolean;
69
+ onOpenChange?: (open: boolean) => void;
70
+ children: React.ReactNode;
71
+ }
72
+
73
+ function Drawer({
74
+ open: controlledOpen,
75
+ defaultOpen = false,
76
+ onOpenChange,
77
+ children,
78
+ }: DrawerProps) {
79
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
80
+
81
+ const isControlled = controlledOpen !== undefined;
82
+ const open = isControlled ? controlledOpen : uncontrolledOpen;
83
+
84
+ const handleOpenChange = React.useCallback(
85
+ (newOpen: boolean) => {
86
+ if (!isControlled) {
87
+ setUncontrolledOpen(newOpen);
88
+ }
89
+ onOpenChange?.(newOpen);
90
+ },
91
+ [isControlled, onOpenChange]
92
+ );
93
+
94
+ // Dispatch drawer-open/close events and manage data-drawer-open attribute
95
+ // Note: drawer-close is also dispatched from DrawerContent.handleClose for animated closes,
96
+ // but we handle it here too for cases where open prop changes directly (e.g., clicking "Done" button)
97
+ const headerShiftDelay = 10; // ms - blur appears, then header shifts
98
+ const wasOpen = React.useRef(false);
99
+ React.useLayoutEffect(() => {
100
+ if (open) {
101
+ wasOpen.current = true;
102
+ document.documentElement.setAttribute("data-drawer-open", "true");
103
+ const timer = setTimeout(() => {
104
+ window.dispatchEvent(new CustomEvent("drawer-open"));
105
+ }, headerShiftDelay);
106
+ return () => clearTimeout(timer);
107
+ } else if (wasOpen.current) {
108
+ // Only dispatch close if this drawer was actually opened
109
+ // This prevents dispatching close for drawers that were never opened
110
+ wasOpen.current = false;
111
+ // Guard: Only remove attribute and dispatch event if attribute is still present
112
+ // This prevents double-dispatch when handleClose has already removed it during animated close
113
+ if (document.documentElement.hasAttribute("data-drawer-open")) {
114
+ document.documentElement.removeAttribute("data-drawer-open");
115
+ window.dispatchEvent(new CustomEvent("drawer-close"));
116
+ }
117
+ }
118
+ }, [open]);
119
+
120
+ return (
121
+ <DrawerContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
122
+ {children}
123
+ </DrawerContext.Provider>
124
+ );
125
+ }
126
+ Drawer.displayName = "Drawer";
127
+
128
+ // ============================================================================
129
+ // DrawerTrigger
130
+ // ============================================================================
131
+
132
+ interface DrawerTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
133
+ asChild?: boolean;
134
+ }
135
+
136
+ const DrawerTrigger = React.forwardRef<HTMLButtonElement, DrawerTriggerProps>(
137
+ ({ asChild, onClick, children, ...props }, ref) => {
138
+ const { onOpenChange } = useDrawerContext();
139
+
140
+ const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
141
+ onClick?.(e);
142
+ onOpenChange(true);
143
+ };
144
+
145
+ if (asChild && React.isValidElement(children)) {
146
+ return React.cloneElement(
147
+ children as React.ReactElement<{ onClick?: React.MouseEventHandler }>,
148
+ {
149
+ onClick: handleClick,
150
+ }
151
+ );
152
+ }
153
+
154
+ return (
155
+ <button ref={ref} onClick={handleClick} {...props}>
156
+ {children}
157
+ </button>
158
+ );
159
+ }
160
+ );
161
+ DrawerTrigger.displayName = "DrawerTrigger";
162
+
163
+ // ============================================================================
164
+ // DrawerClose
165
+ // ============================================================================
166
+
167
+ interface DrawerCloseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
168
+ asChild?: boolean;
169
+ }
170
+
171
+ const DrawerClose = React.forwardRef<HTMLButtonElement, DrawerCloseProps>(
172
+ ({ asChild, onClick, children, ...props }, ref) => {
173
+ const { onOpenChange } = useDrawerContext();
174
+
175
+ const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
176
+ onClick?.(e);
177
+ onOpenChange(false);
178
+ };
179
+
180
+ if (asChild && React.isValidElement(children)) {
181
+ return React.cloneElement(
182
+ children as React.ReactElement<{ onClick?: React.MouseEventHandler }>,
183
+ {
184
+ onClick: handleClick,
185
+ }
186
+ );
187
+ }
188
+
189
+ return (
190
+ <button ref={ref} onClick={handleClick} {...props}>
191
+ {children}
192
+ </button>
193
+ );
194
+ }
195
+ );
196
+ DrawerClose.displayName = "DrawerClose";
197
+
198
+ // ============================================================================
199
+ // DrawerContent
200
+ // ============================================================================
201
+
202
+ interface DrawerContentProps extends React.HTMLAttributes<HTMLDivElement> {
203
+ /** Drawer placement. Default: 'bottom' */
204
+ placement?: "bottom" | "left" | "right";
205
+ /** Explicit height for bottom drawer. Use this when you want the drawer to always be a specific size. */
206
+ height?: string;
207
+ /** Max height for bottom drawer. Default: '80dvh'. Only used if height is not set. */
208
+ maxHeight?: string;
209
+ /** Width for side drawers (left/right). Can be CSS value like '400px' or '80vw'. */
210
+ width?: string;
211
+ /** Max width for side drawers. Default: '400px' */
212
+ maxWidth?: string;
213
+ /** Whether to show the overlay/backdrop. Default: true */
214
+ showOverlay?: boolean;
215
+ /** Whether to hide the drag handle. Default: false */
216
+ hideHandle?: boolean;
217
+ /** Overlay opacity (0-1). Default: 0.3 */
218
+ overlayOpacity?: number;
219
+ /** Whether to lock body scroll when drawer is open. Default: true */
220
+ lockBodyScroll?: boolean;
221
+ /** Whether to use dialog ARIA attributes (role="dialog", aria-modal). Default: true */
222
+ useDialogRole?: boolean;
223
+ /** Disable swipe gestures for closing the drawer. Default: false. */
224
+ disableSwipeGestures?: boolean;
225
+ /** Animation duration in ms. Default: 300 (DEBUG: 2000) */
226
+ animationDuration?: number;
227
+ /** Show a gradient fade at the bottom that hides when scrolled to bottom. Default: true */
228
+ showScrollFade?: boolean;
229
+ /** Color for the scroll fade gradient. Should match drawer background. Default: uses CSS variable */
230
+ scrollFadeColor?: string;
231
+ /** Height ratio for bottom drawer (0-1). Deprecated - use height prop instead. Will set height to heightRatio * 100vh. */
232
+ heightRatio?: number;
233
+ /** Whether to add safe area padding at the bottom. Default: false. Deprecated - safe area is handled automatically via CSS. */
234
+ safeAreaPadding?: boolean;
235
+ }
236
+
237
+ const DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(
238
+ (
239
+ {
240
+ className,
241
+ children,
242
+ placement = "bottom",
243
+ height,
244
+ maxHeight = "80dvh",
245
+ width,
246
+ maxWidth = "400px",
247
+ showOverlay = true,
248
+ hideHandle = false,
249
+ overlayOpacity = 0.3,
250
+ lockBodyScroll = true,
251
+ useDialogRole = true,
252
+ disableSwipeGestures = false,
253
+ animationDuration = 500,
254
+ showScrollFade = true,
255
+ scrollFadeColor,
256
+ heightRatio,
257
+ safeAreaPadding = false,
258
+ ...props
259
+ },
260
+ ref
261
+ ) => {
262
+ const { open, onOpenChange } = useDrawerContext();
263
+ const [mounted, setMounted] = React.useState(false);
264
+ const [bodyHeight, setBodyHeight] = React.useState(0);
265
+ const [isClosing, setIsClosing] = React.useState(false);
266
+ const [isBlurFading, setIsBlurFading] = React.useState(false);
267
+ const [isAtBottom, setIsAtBottom] = React.useState(false);
268
+ const [delayedOpen, setDelayedOpen] = React.useState(false);
269
+ const drawerRef = React.useRef<HTMLDivElement>(null);
270
+ const contentRef = React.useRef<HTMLDivElement>(null);
271
+ const startY = React.useRef(0);
272
+ const currentY = React.useRef(0);
273
+
274
+ // Track nested drawer state - shift down when another drawer opens on top
275
+ const [isNestedDrawerOpen, setIsNestedDrawerOpen] = React.useState(false);
276
+ const [showGreenBar, setShowGreenBar] = React.useState(false);
277
+ const drawerMountedRef = React.useRef(false);
278
+ const ignoreNextDrawerOpenRef = React.useRef(true); // Ignore our own drawer-open event
279
+ const greenBarTimeoutRef = React.useRef<ReturnType<
280
+ typeof setTimeout
281
+ > | null>(null);
282
+
283
+ // Delay drawer opening to allow header shift to complete first
284
+ const openDelay = 120; // ms - must be after green bar finishes
285
+ React.useEffect(() => {
286
+ if (open) {
287
+ const timer = setTimeout(() => setDelayedOpen(true), openDelay);
288
+ return () => clearTimeout(timer);
289
+ } else {
290
+ setDelayedOpen(false);
291
+ }
292
+ }, [open]);
293
+
294
+ // Handle scroll to detect when at bottom
295
+ const handleScroll = React.useCallback(
296
+ (e: React.UIEvent<HTMLDivElement>) => {
297
+ const target = e.target as HTMLDivElement;
298
+ const atBottom =
299
+ target.scrollHeight - target.scrollTop - target.clientHeight < 20;
300
+ setIsAtBottom(atBottom);
301
+ },
302
+ []
303
+ );
304
+
305
+ // Merge refs
306
+ React.useImperativeHandle(ref, () => drawerRef.current!);
307
+
308
+ // Setup: mount detection and body height tracking
309
+ React.useEffect(() => {
310
+ setMounted(true);
311
+ setBodyHeight(document.body.clientHeight);
312
+
313
+ const updateHeight = () => setBodyHeight(document.body.clientHeight);
314
+ window.addEventListener("resize", updateHeight);
315
+
316
+ return () => {
317
+ window.removeEventListener("resize", updateHeight);
318
+ };
319
+ }, []);
320
+
321
+ // Lock body scroll when open
322
+ React.useEffect(() => {
323
+ if (open && lockBodyScroll) {
324
+ document.body.style.overflow = "hidden";
325
+ } else if (lockBodyScroll) {
326
+ document.body.style.overflow = "";
327
+ }
328
+ return () => {
329
+ if (lockBodyScroll) {
330
+ document.body.style.overflow = "";
331
+ }
332
+ };
333
+ }, [open, lockBodyScroll]);
334
+
335
+ // Track nested drawer state - when another drawer opens on top of this one
336
+ // Shift the sticky wrapper down to make room for iOS safe area
337
+ React.useLayoutEffect(() => {
338
+ if (!open) {
339
+ // Reset when this drawer closes
340
+ drawerMountedRef.current = false;
341
+ ignoreNextDrawerOpenRef.current = true;
342
+ setIsNestedDrawerOpen(false);
343
+ return;
344
+ }
345
+
346
+ // After a delay, start listening for nested drawer events
347
+ // (ignore our own drawer-open event which fires when we open)
348
+ const mountTimer = setTimeout(() => {
349
+ drawerMountedRef.current = true;
350
+ ignoreNextDrawerOpenRef.current = false;
351
+ }, 200); // Wait for our own drawer's open event to pass
352
+
353
+ const handleNestedDrawerOpen = () => {
354
+ if (!drawerMountedRef.current || ignoreNextDrawerOpenRef.current) {
355
+ return; // Ignore our own drawer-open event
356
+ }
357
+ // A nested drawer opened on top of us - shift down for iOS safe area
358
+ // Clear any existing green bar timeout to prevent stacking
359
+ if (greenBarTimeoutRef.current) {
360
+ clearTimeout(greenBarTimeoutRef.current);
361
+ }
362
+ // Flash green bar and shift drawer down
363
+ setShowGreenBar(true);
364
+ setIsNestedDrawerOpen(true);
365
+ // Hide green bar after 60ms (triggers iOS safe area detection)
366
+ greenBarTimeoutRef.current = setTimeout(() => {
367
+ setShowGreenBar(false);
368
+ greenBarTimeoutRef.current = null;
369
+ }, 60);
370
+ };
371
+
372
+ const handleNestedDrawerClose = () => {
373
+ if (!drawerMountedRef.current) return;
374
+ setIsNestedDrawerOpen(false);
375
+ };
376
+
377
+ window.addEventListener("drawer-open", handleNestedDrawerOpen);
378
+ window.addEventListener("drawer-close", handleNestedDrawerClose);
379
+
380
+ return () => {
381
+ clearTimeout(mountTimer);
382
+ if (greenBarTimeoutRef.current) {
383
+ clearTimeout(greenBarTimeoutRef.current);
384
+ greenBarTimeoutRef.current = null;
385
+ }
386
+ window.removeEventListener("drawer-open", handleNestedDrawerOpen);
387
+ window.removeEventListener("drawer-close", handleNestedDrawerClose);
388
+ };
389
+ }, [open]);
390
+
391
+ // Calculate sticky wrapper top offset for nested drawer
392
+ const stickyTopOffset = isNestedDrawerOpen ? 10 : 0;
393
+
394
+ // Scroll trick to trigger blur rendering on sticky elements
395
+ // Some browsers don't apply backdrop-filter until scroll activity occurs
396
+ const scrollNudgeAmount = 50;
397
+ const scrollNudgeDuration = 10; // ms
398
+ const savedScrollY = React.useRef<number | null>(null);
399
+
400
+ // Custom smooth scroll with configurable duration
401
+ const smoothScrollTo = React.useCallback(
402
+ (target: number, duration: number) => {
403
+ const start = window.scrollY;
404
+ const distance = target - start;
405
+ const startTime = performance.now();
406
+
407
+ const animate = (currentTime: number) => {
408
+ const elapsed = currentTime - startTime;
409
+ const progress = Math.min(elapsed / duration, 1);
410
+ const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
411
+ window.scrollTo(0, start + distance * eased);
412
+ if (progress < 1) requestAnimationFrame(animate);
413
+ };
414
+ requestAnimationFrame(animate);
415
+ },
416
+ []
417
+ );
418
+
419
+ const scrollNudgeDelay = 10; // ms - let blur appear first
420
+ React.useEffect(() => {
421
+ if (open && !isClosing) {
422
+ // Save position immediately
423
+ savedScrollY.current = document.documentElement.scrollTop;
424
+ // Skip scroll nudge if already scrolled past the nudge amount
425
+ if (savedScrollY.current > scrollNudgeAmount) {
426
+ return;
427
+ }
428
+ // Delay scroll nudge to let blur overlay appear first
429
+ const timer = setTimeout(() => {
430
+ smoothScrollTo(
431
+ savedScrollY.current! + scrollNudgeAmount,
432
+ scrollNudgeDuration
433
+ );
434
+ }, scrollNudgeDelay);
435
+ return () => clearTimeout(timer);
436
+ }
437
+ }, [open, isClosing, smoothScrollTo]);
438
+
439
+ // Restore scroll position when drawer closes via prop change (e.g., Done button)
440
+ // This handles the case where handleClose is not called (prop-driven close vs gesture close)
441
+ // When handleClose IS called (gesture close), it sets savedScrollY.current = null before
442
+ // open becomes false, so this effect won't double-restore.
443
+ React.useEffect(() => {
444
+ if (!open && !isClosing && savedScrollY.current !== null) {
445
+ const targetScroll = savedScrollY.current;
446
+ savedScrollY.current = null;
447
+ document.body.style.overflow = "";
448
+ window.scrollTo(0, targetScroll);
449
+ }
450
+ }, [open, isClosing]);
451
+
452
+ // Handle close with reversed sequence: drawer → header → blur
453
+ // Close timings (reverse of open)
454
+ const drawerExitDuration = 300; // ms - drawer slides down
455
+ const headerShiftDelay = 200; // ms - header shifts after drawer mostly gone
456
+ const blurFadeDelay = 250; // ms - blur starts fading near end
457
+ const blurFadeDuration = 150; // ms - blur fade animation
458
+ const totalCloseDuration = blurFadeDelay + blurFadeDuration;
459
+
460
+ // Small delay before drawer starts closing visually
461
+ const closeStartDelay = 50; // ms - give everything a moment before visual close
462
+
463
+ const handleClose = React.useCallback(() => {
464
+ // Brief pause before starting visual close
465
+ setTimeout(() => {
466
+ // Start drawer slide-out
467
+ setIsClosing(true);
468
+
469
+ // STEP 1: Restore scroll position FIRST (at 150ms)
470
+ // This must happen BEFORE removing data-drawer-open, so that when
471
+ // CSS scroll-driven animations resume, they calculate with the correct scroll position.
472
+ // Otherwise, the hero images will be at wrong scale/position (the "breaks half the time" bug).
473
+ const scrollRestoreDelay = 150;
474
+ setTimeout(() => {
475
+ document.body.style.overflow = "";
476
+ const targetScroll = savedScrollY.current;
477
+ if (targetScroll !== null) {
478
+ savedScrollY.current = null;
479
+ window.scrollTo(0, targetScroll);
480
+ }
481
+ }, scrollRestoreDelay);
482
+
483
+ // STEP 2: After scroll restored and drawer mostly gone, remove attribute (at 200ms)
484
+ // CSS animations will now resume with the correct scroll position
485
+ setTimeout(() => {
486
+ document.documentElement.removeAttribute("data-drawer-open");
487
+ window.dispatchEvent(new CustomEvent("drawer-close"));
488
+ }, headerShiftDelay);
489
+
490
+ // STEP 3: Start blur fade near the end (at 250ms)
491
+ setTimeout(() => {
492
+ setIsBlurFading(true);
493
+ }, blurFadeDelay);
494
+
495
+ // STEP 4: Finally unmount everything (at 400ms)
496
+ setTimeout(() => {
497
+ setIsClosing(false);
498
+ setIsBlurFading(false);
499
+ onOpenChange(false);
500
+ }, totalCloseDuration);
501
+ }, closeStartDelay);
502
+ }, [onOpenChange]);
503
+
504
+ // Escape key handler
505
+ React.useEffect(() => {
506
+ const handleEscape = (e: KeyboardEvent) => {
507
+ if (e.key === "Escape" && open && !isClosing) {
508
+ handleClose();
509
+ }
510
+ };
511
+ document.addEventListener("keydown", handleEscape);
512
+ return () => document.removeEventListener("keydown", handleEscape);
513
+ }, [open, isClosing, handleClose]);
514
+
515
+ // Swipe gesture handlers for bottom drawer
516
+ const handleTouchStart = React.useCallback(
517
+ (e: React.TouchEvent) => {
518
+ if (disableSwipeGestures) return;
519
+ startY.current = e.touches[0].clientY;
520
+ currentY.current = 0;
521
+ if (drawerRef.current) {
522
+ drawerRef.current.style.transition = "none";
523
+ }
524
+ },
525
+ [disableSwipeGestures]
526
+ );
527
+
528
+ const handleTouchMove = React.useCallback(
529
+ (e: React.TouchEvent) => {
530
+ if (disableSwipeGestures) return;
531
+ const delta = e.touches[0].clientY - startY.current;
532
+ // Only allow dragging down (positive delta)
533
+ currentY.current = Math.max(0, delta);
534
+ if (drawerRef.current) {
535
+ drawerRef.current.style.transform = `translateY(${currentY.current}px)`;
536
+ }
537
+ },
538
+ [disableSwipeGestures]
539
+ );
540
+
541
+ const handleTouchEnd = React.useCallback(() => {
542
+ if (disableSwipeGestures) return;
543
+ if (drawerRef.current) {
544
+ drawerRef.current.style.transition = `transform ${animationDuration}ms cubic-bezier(0.32, 0.72, 0, 1)`;
545
+ drawerRef.current.style.transform = "";
546
+ }
547
+
548
+ const threshold = 100;
549
+ if (currentY.current > threshold) {
550
+ handleClose();
551
+ }
552
+ }, [disableSwipeGestures, handleClose, animationDuration]);
553
+
554
+ // Show overlay immediately when open, drawer panel waits for delayedOpen
555
+ if (!mounted || (!open && !isClosing)) return null;
556
+
557
+ const showDrawerPanel = delayedOpen || isClosing;
558
+
559
+ const animationStyle = {
560
+ "--animation-duration": `${animationDuration}ms`,
561
+ } as React.CSSProperties;
562
+
563
+ return createPortal(
564
+ <div
565
+ style={{
566
+ position: "absolute",
567
+ top: 0,
568
+ left: 0,
569
+ width: "100%",
570
+ height: bodyHeight || "100%",
571
+ zIndex: 10000000,
572
+ }}
573
+ {...(useDialogRole ? { role: "dialog", "aria-modal": true } : {})}
574
+ >
575
+ {/* Overlay with blur */}
576
+ {showOverlay && (
577
+ <div
578
+ onClick={handleClose}
579
+ className={cn(
580
+ "absolute inset-0 backdrop-blur-lg",
581
+ isBlurFading && "animate-out fade-out"
582
+ )}
583
+ style={{
584
+ backgroundColor: `rgba(0,0,0,${overlayOpacity})`,
585
+ animationDuration: isBlurFading
586
+ ? `${blurFadeDuration}ms`
587
+ : undefined,
588
+ }}
589
+ aria-hidden="true"
590
+ />
591
+ )}
592
+
593
+ {/* Safe area bar - flashes briefly when nested drawer opens to trigger iOS safe area */}
594
+ {/* Must be above drawer overlay (z-index 10000000) */}
595
+ {showGreenBar && (
596
+ <div
597
+ className="fixed w-full bg-surface"
598
+ style={{
599
+ top: 0,
600
+ left: 0,
601
+ height: stickyTopOffset || 10,
602
+ zIndex: 10000001,
603
+ }}
604
+ />
605
+ )}
606
+
607
+ {/* Sticky wrapper to position drawer */}
608
+ {showDrawerPanel && (
609
+ <div
610
+ className={cn(
611
+ "sticky left-0 w-full flex pointer-events-none",
612
+ placement === "bottom" && "items-end justify-center",
613
+ // Side drawers: stretch to full height unless custom height is set (then top-align with padding)
614
+ placement === "left" && (height ? "items-start justify-start pt-4" : "items-stretch justify-start"),
615
+ placement === "right" && (height ? "items-start justify-end pt-4" : "items-stretch justify-end")
616
+ )}
617
+ style={{
618
+ height: "100dvh",
619
+ top: placement === "bottom" ? stickyTopOffset : 0,
620
+ transition: "top 10ms ease-out",
621
+ }}
622
+ >
623
+ {/* Drawer Panel */}
624
+ <Surface asChild variant="default">
625
+ <div
626
+ ref={drawerRef}
627
+ className={cn(
628
+ "flex flex-col shadow-2xl pointer-events-auto",
629
+ // Bottom drawer: rounded top, full width
630
+ placement === "bottom" && "rounded-t-3xl w-full",
631
+ // Left drawer: rounded right side, full height unless custom height
632
+ placement === "left" && cn("rounded-r-2xl", !height && "h-full"),
633
+ // Right drawer: rounded left side, full height unless custom height
634
+ placement === "right" && cn("rounded-l-2xl", !height && "h-full"),
635
+ // Animations based on placement
636
+ placement === "bottom" && (isClosing
637
+ ? "animate-out slide-out-to-bottom"
638
+ : "animate-in slide-in-from-bottom"),
639
+ placement === "left" && (isClosing
640
+ ? "animate-out slide-out-to-left"
641
+ : "animate-in slide-in-from-left"),
642
+ placement === "right" && (isClosing
643
+ ? "animate-out slide-out-to-right"
644
+ : "animate-in slide-in-from-right"),
645
+ className
646
+ )}
647
+ style={{
648
+ // Bottom drawer: use height/maxHeight
649
+ ...(placement === "bottom" ? {
650
+ height: heightRatio ? `${heightRatio * 100}vh` : height,
651
+ maxHeight: (heightRatio || height) ? undefined : maxHeight,
652
+ } : {}),
653
+ // Side drawers: use width/maxWidth, custom height or full height
654
+ ...(placement !== "bottom" ? {
655
+ width: width || maxWidth,
656
+ maxWidth: maxWidth,
657
+ height: height || "100%",
658
+ } : {}),
659
+ animationDuration: isClosing
660
+ ? `${drawerExitDuration}ms`
661
+ : `${animationDuration}ms`,
662
+ animationTimingFunction: isClosing
663
+ ? "ease-in"
664
+ : "cubic-bezier(0.32, 0.72, 0, 1)",
665
+ animationFillMode: "forwards",
666
+ ...animationStyle,
667
+ }}
668
+ onClick={(e) => e.stopPropagation()}
669
+ {...props}
670
+ >
671
+ {/* Drag handle zone - only for bottom drawer */}
672
+ {placement === "bottom" && !disableSwipeGestures && (
673
+ <div
674
+ onTouchStart={handleTouchStart}
675
+ onTouchMove={handleTouchMove}
676
+ onTouchEnd={handleTouchEnd}
677
+ className={cn(
678
+ "cursor-grab active:cursor-grabbing flex-shrink-0",
679
+ hideHandle && "absolute top-0 left-0 right-0 h-8 z-10"
680
+ )}
681
+ style={{ touchAction: "none" }}
682
+ >
683
+ {!hideHandle && (
684
+ <div className="flex justify-center pt-3 pb-2">
685
+ <div
686
+ className="h-1 w-10 rounded-full bg-muted-foreground/30"
687
+ aria-hidden="true"
688
+ />
689
+ </div>
690
+ )}
691
+ </div>
692
+ )}
693
+
694
+ {/* Content area with scroll fade */}
695
+ <div className="flex-1 overflow-hidden flex flex-col relative min-h-0">
696
+ <div
697
+ ref={contentRef}
698
+ className="flex-1 flex flex-col overflow-y-auto min-h-0"
699
+ style={{
700
+ WebkitOverflowScrolling: "touch",
701
+ overscrollBehavior: "contain",
702
+ }}
703
+ onScroll={handleScroll}
704
+ >
705
+ {children}
706
+ </div>
707
+
708
+ {/* Gradient fade - hides when scrolled to bottom */}
709
+ {showScrollFade && (
710
+ <div
711
+ className={cn(
712
+ "absolute bottom-0 left-0 right-0 h-20 pointer-events-none transition-opacity duration-300 z-10",
713
+ isAtBottom ? "opacity-0" : "opacity-100"
714
+ )}
715
+ style={{
716
+ background: scrollFadeColor
717
+ ? `linear-gradient(to top, ${scrollFadeColor}, transparent)`
718
+ : "linear-gradient(to top, var(--color-drawer-bg, var(--surface)), transparent)",
719
+ }}
720
+ aria-hidden="true"
721
+ />
722
+ )}
723
+ </div>
724
+
725
+ {/* Bottom safe area padding - inherits drawer background */}
726
+ <div
727
+ className="flex-shrink-0"
728
+ style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
729
+ />
730
+ </div>
731
+ </Surface>
732
+ </div>
733
+ )}
734
+ </div>,
735
+ document.body
736
+ );
737
+ }
738
+ );
739
+ DrawerContent.displayName = "DrawerContent";
740
+
741
+ // ============================================================================
742
+ // DrawerHeader
743
+ // ============================================================================
744
+
745
+ const DrawerHeader = ({
746
+ className,
747
+ ...props
748
+ }: React.HTMLAttributes<HTMLDivElement>) => (
749
+ <div
750
+ className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
751
+ {...props}
752
+ />
753
+ );
754
+ DrawerHeader.displayName = "DrawerHeader";
755
+
756
+ // ============================================================================
757
+ // DrawerFooter
758
+ // ============================================================================
759
+
760
+ const DrawerFooter = ({
761
+ className,
762
+ ...props
763
+ }: React.HTMLAttributes<HTMLDivElement>) => (
764
+ <div
765
+ className={cn("mt-auto flex flex-col gap-2 p-4", className)}
766
+ {...props}
767
+ />
768
+ );
769
+ DrawerFooter.displayName = "DrawerFooter";
770
+
771
+ // ============================================================================
772
+ // DrawerTitle
773
+ // ============================================================================
774
+
775
+ const DrawerTitle = React.forwardRef<
776
+ HTMLHeadingElement,
777
+ React.HTMLAttributes<HTMLHeadingElement>
778
+ >(({ className, ...props }, ref) => (
779
+ <h2
780
+ ref={ref}
781
+ className={cn(
782
+ "text-lg font-semibold leading-none tracking-tight",
783
+ className
784
+ )}
785
+ {...props}
786
+ />
787
+ ));
788
+ DrawerTitle.displayName = "DrawerTitle";
789
+
790
+ // ============================================================================
791
+ // DrawerDescription
792
+ // ============================================================================
793
+
794
+ const DrawerDescription = React.forwardRef<
795
+ HTMLParagraphElement,
796
+ React.HTMLAttributes<HTMLParagraphElement>
797
+ >(({ className, ...props }, ref) => (
798
+ <p
799
+ ref={ref}
800
+ className={cn("text-sm text-muted-foreground", className)}
801
+ {...props}
802
+ />
803
+ ));
804
+ DrawerDescription.displayName = "DrawerDescription";
805
+
806
+ // Primary exports
807
+ export {
808
+ Drawer,
809
+ DrawerTrigger,
810
+ DrawerClose,
811
+ DrawerContent,
812
+ DrawerHeader,
813
+ DrawerFooter,
814
+ DrawerTitle,
815
+ DrawerDescription,
816
+ };
817
+
818
+ // Legacy IOS-prefixed exports (for backwards compatibility during migration)
819
+ export {
820
+ Drawer as IOSDrawer,
821
+ DrawerTrigger as IOSDrawerTrigger,
822
+ DrawerClose as IOSDrawerClose,
823
+ DrawerContent as IOSDrawerContent,
824
+ DrawerHeader as IOSDrawerHeader,
825
+ DrawerFooter as IOSDrawerFooter,
826
+ DrawerTitle as IOSDrawerTitle,
827
+ DrawerDescription as IOSDrawerDescription,
828
+ };