@snowcone-app/ui 0.1.42 → 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.
- package/CHANGELOG.md +33 -0
- package/README.md +18 -4
- package/package.json +9 -5
- package/src/components/CanvasIsolationBoundary.tsx +202 -0
- package/src/components/LoadingOverlayPrism.tsx +251 -0
- package/src/composed/AddToCart.tsx +229 -0
- package/src/composed/ArtAlignment.tsx +703 -0
- package/src/composed/ArtSelector.tsx +290 -0
- package/src/composed/ArtworkCustomizer.tsx +212 -0
- package/src/composed/CanvasEditor.tsx +79 -0
- package/src/composed/ColorPicker.tsx +111 -0
- package/src/composed/CurrentSelectionDisplay.tsx +86 -0
- package/src/composed/HeroProductImage.tsx +1071 -0
- package/src/composed/Lightbox.index.ts +2 -0
- package/src/composed/Lightbox.tsx +230 -0
- package/src/composed/PlacementClipShapeSelector.tsx +88 -0
- package/src/composed/PlacementTabs.tsx +179 -0
- package/src/composed/ProductCard.tsx +298 -0
- package/src/composed/ProductGallery.tsx +54 -0
- package/src/composed/ProductImage.tsx +129 -0
- package/src/composed/ProductList.tsx +147 -0
- package/src/composed/ProductOptions.tsx +305 -0
- package/src/composed/RealtimeMockup.tsx +121 -0
- package/src/composed/TileCount.tsx +348 -0
- package/src/composed/carousels/HeroCarousel.tsx +240 -0
- package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
- package/src/composed/carousels/index.ts +11 -0
- package/src/composed/carousels/types.ts +58 -0
- package/src/composed/grids/MasonryGrid.tsx +238 -0
- package/src/composed/grids/index.ts +9 -0
- package/src/composed/search/CurrentRefinements.tsx +80 -0
- package/src/composed/search/Filters.tsx +49 -0
- package/src/composed/search/FiltersButton.tsx +57 -0
- package/src/composed/search/FiltersDrawer.tsx +375 -0
- package/src/composed/search/ProductGrid.tsx +118 -0
- package/src/composed/search/ProductHit.tsx +56 -0
- package/src/composed/search/SearchBox.tsx +109 -0
- package/src/composed/search/SearchProvider.tsx +136 -0
- package/src/composed/search/facetConfig.ts +16 -0
- package/src/composed/search/index.ts +22 -0
- package/src/composed/search/meilisearchAdapter.ts +20 -0
- package/src/composed/search/types.ts +22 -0
- package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
- package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
- package/src/composed/zoom/ZoomOverlay.tsx +194 -0
- package/src/composed/zoom/index.ts +12 -0
- package/src/composed/zoom/types.ts +12 -0
- package/src/design-system/ColorPalette.tsx +126 -0
- package/src/design-system/ColorSwatch.tsx +49 -0
- package/src/design-system/DesignSystemPage.tsx +130 -0
- package/src/design-system/ThemeSwitcher.tsx +181 -0
- package/src/design-system/TypographyScale.tsx +106 -0
- package/src/design-system/index.ts +5 -0
- package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
- package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
- package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
- package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
- package/src/hooks/useBrand.ts +41 -0
- package/src/hooks/useCanvasContext.ts +127 -0
- package/src/hooks/useDeviceDetection.ts +64 -0
- package/src/hooks/useFocusTrap.ts +70 -0
- package/src/hooks/useImagePreloader.ts +268 -0
- package/src/hooks/useImageTransition.ts +608 -0
- package/src/hooks/usePlacementsProcessor.ts +74 -0
- package/src/hooks/useProductGallery.ts +193 -0
- package/src/hooks/useProductPage.ts +467 -0
- package/src/hooks/useRenderGuard.ts +96 -0
- package/src/hooks/useScrollDirection.ts +196 -0
- package/src/hooks/viewport/index.ts +25 -0
- package/src/hooks/viewport/useContainerWidth.ts +59 -0
- package/src/hooks/viewport/useMediaQuery.ts +52 -0
- package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
- package/src/hooks/viewport/useViewportDimensions.ts +135 -0
- package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
- package/src/hooks/visibility/index.ts +15 -0
- package/src/hooks/visibility/observerPool.ts +150 -0
- package/src/index.ts +240 -0
- package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
- package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
- package/src/layouts/hero-zoom/index.ts +30 -0
- package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
- package/src/layouts/hero-zoom/types.ts +113 -0
- package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
- package/src/layouts/index.ts +9 -0
- package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
- package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
- package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
- package/src/layouts/pdp/PDPLayout.tsx +246 -0
- package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
- package/src/layouts/pdp/index.ts +40 -0
- package/src/lib/env.ts +15 -0
- package/src/lib/locale.ts +167 -0
- package/src/lib/router.tsx +46 -0
- package/src/lib/utils.ts +6 -0
- package/src/lightbox/README.md +77 -0
- package/src/next/index.tsx +26 -0
- package/src/patterns/MockupPriorityProvider.tsx +1014 -0
- package/src/patterns/Product.tsx +850 -0
- package/src/patterns/ProductPageProvider.tsx +224 -0
- package/src/patterns/RealtimeProvider.tsx +1162 -0
- package/src/patterns/ShopProvider.tsx +603 -0
- package/src/personalization/PersonalizationBridge.tsx +235 -0
- package/src/personalization/PersonalizationContext.ts +29 -0
- package/src/personalization/PersonalizationInputs.tsx +110 -0
- package/src/personalization/PersonalizationProvider.tsx +407 -0
- package/src/personalization/canvas-stub.d.ts +22 -0
- package/src/personalization/index.ts +43 -0
- package/src/personalization/types.ts +48 -0
- package/src/personalization/usePersonalization.ts +32 -0
- package/src/personalization/usePersonalizationShimmer.ts +159 -0
- package/src/personalization/utils.ts +59 -0
- package/src/primitives/BrandLogo.tsx +65 -0
- package/src/primitives/BrandName.tsx +51 -0
- package/src/primitives/Button.tsx +123 -0
- package/src/primitives/ColorSwatch.tsx +221 -0
- package/src/primitives/DragHintAnimation.tsx +190 -0
- package/src/primitives/EdgeSwipeGuards.tsx +60 -0
- package/src/primitives/FloatingActionGroup.tsx +176 -0
- package/src/primitives/ProductPrice.tsx +171 -0
- package/src/primitives/ProgressiveBlur.tsx +295 -0
- package/src/primitives/ThemeToggle.tsx +125 -0
- package/src/primitives/__tests__/story-coverage.test.ts +98 -0
- package/src/primitives/accordion.tsx +280 -0
- package/src/primitives/badge.tsx +137 -0
- package/src/primitives/card.tsx +61 -0
- package/src/primitives/checkbox.tsx +56 -0
- package/src/primitives/collapsible.tsx +51 -0
- package/src/primitives/drawer.tsx +828 -0
- package/src/primitives/dropdown-menu.tsx +197 -0
- package/src/primitives/fieldset.tsx +73 -0
- package/src/primitives/index.ts +138 -0
- package/src/primitives/input.tsx +91 -0
- package/src/primitives/kbd.tsx +130 -0
- package/src/primitives/label.tsx +20 -0
- package/src/primitives/link.tsx +182 -0
- package/src/primitives/popover.tsx +80 -0
- package/src/primitives/radio-group.tsx +79 -0
- package/src/primitives/scroll-fade.tsx +159 -0
- package/src/primitives/select.tsx +170 -0
- package/src/primitives/separator.tsx +25 -0
- package/src/primitives/slider.tsx +221 -0
- package/src/primitives/spinner.tsx +72 -0
- package/src/primitives/stories/Accordion.stories.tsx +121 -0
- package/src/primitives/stories/Badge.stories.tsx +221 -0
- package/src/primitives/stories/Button.stories.tsx +185 -0
- package/src/primitives/stories/Card.stories.tsx +171 -0
- package/src/primitives/stories/Checkbox.stories.tsx +214 -0
- package/src/primitives/stories/Collapsible.stories.tsx +230 -0
- package/src/primitives/stories/Drawer.stories.tsx +378 -0
- package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
- package/src/primitives/stories/Fieldset.stories.tsx +212 -0
- package/src/primitives/stories/Input.stories.tsx +172 -0
- package/src/primitives/stories/Kbd.stories.tsx +183 -0
- package/src/primitives/stories/Label.stories.tsx +98 -0
- package/src/primitives/stories/Link.stories.tsx +260 -0
- package/src/primitives/stories/Popover.stories.tsx +178 -0
- package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
- package/src/primitives/stories/Select.stories.tsx +222 -0
- package/src/primitives/stories/Separator.stories.tsx +134 -0
- package/src/primitives/stories/Slider.stories.tsx +203 -0
- package/src/primitives/stories/Spinner.stories.tsx +142 -0
- package/src/primitives/stories/Surface.stories.tsx +257 -0
- package/src/primitives/stories/Switch.stories.tsx +131 -0
- package/src/primitives/stories/Tabs.stories.tsx +275 -0
- package/src/primitives/stories/TextField.stories.tsx +139 -0
- package/src/primitives/stories/Textarea.stories.tsx +148 -0
- package/src/primitives/stories/Tooltip.stories.tsx +119 -0
- package/src/primitives/surface.tsx +86 -0
- package/src/primitives/switch.tsx +35 -0
- package/src/primitives/tabs.tsx +206 -0
- package/src/primitives/text-field.tsx +84 -0
- package/src/primitives/textarea.tsx +50 -0
- package/src/primitives/tooltip.tsx +58 -0
- package/src/services/CanvasExportService.ts +518 -0
- package/src/styles/base.css +380 -0
- package/src/styles/defaults.css +280 -0
- package/src/styles/globals.css +1242 -0
- package/src/styles/index.css +17 -0
- package/src/styles/ne-themes.css +4740 -0
- package/src/styles/tailwind.css +11 -0
- package/src/styles/tokens.css +117 -0
- package/src/styles/utilities.css +188 -0
- package/src/themes/apply-theme.ts +449 -0
- package/src/themes/getThemeStyles.ts +454 -0
- package/src/themes/index.ts +48 -0
- package/src/themes/oklch-theme.ts +283 -0
- package/src/themes/presets.ts +989 -0
- package/src/themes/types.ts +386 -0
- package/src/themes/useTheme.tsx +450 -0
- package/src/utils/dev-warnings.ts +161 -0
- package/src/utils/devWarnings.ts +153 -0
- 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
|
+
};
|