@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,365 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Icon } from '@iconify/react';
|
|
4
|
+
import { Product } from '../../patterns/Product';
|
|
5
|
+
import { ProductOptions } from '../../composed/ProductOptions';
|
|
6
|
+
import { AddToCart } from '../../composed/AddToCart';
|
|
7
|
+
import {
|
|
8
|
+
Accordion,
|
|
9
|
+
AccordionItem,
|
|
10
|
+
AccordionTrigger,
|
|
11
|
+
AccordionContent,
|
|
12
|
+
} from '../../primitives/accordion';
|
|
13
|
+
import type { CatalogProduct } from '@snowcone-app/sdk';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* PDPLayout - Complete Product Detail Page Layout
|
|
17
|
+
*
|
|
18
|
+
* The actual PDP layout where:
|
|
19
|
+
* - Hero images are FULL WIDTH and stack vertically
|
|
20
|
+
* - Info panel OVERLAPS on the right side (fixed/sticky position)
|
|
21
|
+
* - Uses REAL ProductOptions and AddToCart components
|
|
22
|
+
*
|
|
23
|
+
* This matches the real next-ecommerce implementation.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Helper to generate all combinations for sizes and colors
|
|
27
|
+
const sizes = ['XS', 'S', 'M', 'L', 'XL', '2XL'];
|
|
28
|
+
const colors = ['Black', 'White', 'Navy', 'Heather Gray'];
|
|
29
|
+
|
|
30
|
+
const generateCombinations = (sizeList: string[], colorList: string[], price: number) => {
|
|
31
|
+
const combinations: { price: number; variantId: string; Size: string; Color: string }[] = [];
|
|
32
|
+
for (const size of sizeList) {
|
|
33
|
+
for (const color of colorList) {
|
|
34
|
+
combinations.push({
|
|
35
|
+
price,
|
|
36
|
+
variantId: `${size.toLowerCase()}-${color.toLowerCase().replace(' ', '-')}`,
|
|
37
|
+
Size: size,
|
|
38
|
+
Color: color,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return combinations;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Mock product data
|
|
46
|
+
const mockProduct: CatalogProduct = {
|
|
47
|
+
id: 'classic-cotton-tshirt',
|
|
48
|
+
name: 'Classic Cotton T-Shirt',
|
|
49
|
+
slug: 'classic-cotton-tshirt',
|
|
50
|
+
price: 2999, // $29.99 in cents
|
|
51
|
+
options: {
|
|
52
|
+
attributes: {
|
|
53
|
+
Size: {
|
|
54
|
+
type: 'select',
|
|
55
|
+
affectsCombinations: true,
|
|
56
|
+
choices: sizes.map(label => ({ label })),
|
|
57
|
+
},
|
|
58
|
+
Color: {
|
|
59
|
+
type: 'swatch',
|
|
60
|
+
affectsCombinations: true,
|
|
61
|
+
choices: [
|
|
62
|
+
{ label: 'Black', hex: '#1a1a1a' },
|
|
63
|
+
{ label: 'White', hex: '#ffffff' },
|
|
64
|
+
{ label: 'Navy', hex: '#1e3a5f' },
|
|
65
|
+
{ label: 'Heather Gray', hex: '#9ca3af' },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
combinations: generateCombinations(sizes, colors, 2999),
|
|
70
|
+
attributesList: ['Size', 'Color'],
|
|
71
|
+
},
|
|
72
|
+
description: ['Premium quality with exceptional comfort and style.'],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const demoMockups = [
|
|
76
|
+
{ id: 'front', label: 'Front' },
|
|
77
|
+
{ id: 'back', label: 'Back' },
|
|
78
|
+
{ id: 'detail', label: 'Detail' },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
interface PDPLayoutProps {
|
|
82
|
+
product?: CatalogProduct;
|
|
83
|
+
tag?: string;
|
|
84
|
+
showArtworkCustomizer?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const PDPLayout = ({
|
|
88
|
+
product = mockProduct,
|
|
89
|
+
tag = 'Upgraded',
|
|
90
|
+
showArtworkCustomizer = false,
|
|
91
|
+
}: PDPLayoutProps) => {
|
|
92
|
+
const [isFavorite, setIsFavorite] = React.useState(false);
|
|
93
|
+
|
|
94
|
+
// Format price from cents
|
|
95
|
+
const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`;
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Product productData={product} initialSelection={{ Size: 'M', Color: product.options?.attributes?.Color?.choices?.[0]?.label || 'Black' }}>
|
|
99
|
+
<div className="min-h-screen bg-background">
|
|
100
|
+
{/* Header */}
|
|
101
|
+
<header className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-md border-b border-border">
|
|
102
|
+
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
|
103
|
+
<div className="flex items-center gap-4">
|
|
104
|
+
<button className="p-2 hover:bg-muted rounded-lg">
|
|
105
|
+
<Icon icon="gravity-ui:bars" className="w-5 h-5" />
|
|
106
|
+
</button>
|
|
107
|
+
<span className="font-bold text-lg">Store</span>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="flex items-center gap-2">
|
|
110
|
+
<button className="p-2 hover:bg-muted rounded-lg">
|
|
111
|
+
<Icon icon="gravity-ui:magnifier" className="w-5 h-5" />
|
|
112
|
+
</button>
|
|
113
|
+
<button className="p-2 hover:bg-muted rounded-lg relative">
|
|
114
|
+
<Icon icon="gravity-ui:shopping-cart" className="w-5 h-5" />
|
|
115
|
+
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-primary text-primary-foreground text-xs rounded-full flex items-center justify-center">
|
|
116
|
+
2
|
|
117
|
+
</span>
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</header>
|
|
122
|
+
|
|
123
|
+
{/* Main Content - Images are full width, panel overlaps */}
|
|
124
|
+
<main className="pt-16 relative">
|
|
125
|
+
{/* Full-width Hero Images - content centered in visible area (left of panel) */}
|
|
126
|
+
<div className="w-full">
|
|
127
|
+
{demoMockups.map((mockup) => (
|
|
128
|
+
<div
|
|
129
|
+
key={mockup.id}
|
|
130
|
+
className="relative w-full"
|
|
131
|
+
style={{ height: '70vh' }}
|
|
132
|
+
>
|
|
133
|
+
{/* Placeholder - in real app this is HeroProductImage with object-fit: cover */}
|
|
134
|
+
<div className="w-full h-full bg-gradient-to-br from-muted to-muted/50 flex items-center justify-center">
|
|
135
|
+
{/* Content offset to left to center in visible area (accounting for ~35% panel on right) */}
|
|
136
|
+
<div
|
|
137
|
+
className="text-center text-muted-foreground"
|
|
138
|
+
style={{ marginRight: '30%' }} // Offset to center in visible area
|
|
139
|
+
>
|
|
140
|
+
<Icon
|
|
141
|
+
icon="gravity-ui:t-shirt"
|
|
142
|
+
className="w-32 h-32 mx-auto mb-4 opacity-20"
|
|
143
|
+
/>
|
|
144
|
+
<p className="text-xl font-medium opacity-60">{mockup.label} View</p>
|
|
145
|
+
<p className="text-sm opacity-40">Product mockup</p>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
</div>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
{/* Overlapping Info Panel - fixed on right side */}
|
|
154
|
+
<div
|
|
155
|
+
className="hidden md:block fixed right-8 top-24 w-[30%] xl:w-[35%] max-w-96 z-10"
|
|
156
|
+
style={{ maxHeight: 'calc(100vh - 8rem)' }}
|
|
157
|
+
>
|
|
158
|
+
<div className="bg-background/85 backdrop-blur-sm rounded-3xl p-6 shadow-lg overflow-y-auto max-h-[calc(100vh-10rem)]">
|
|
159
|
+
<div className="flex flex-col gap-6">
|
|
160
|
+
{/* Tag, Product Name and Price */}
|
|
161
|
+
<div className="flex flex-col gap-3">
|
|
162
|
+
{/* Tag */}
|
|
163
|
+
{tag && (
|
|
164
|
+
<span className="text-sm font-medium text-primary">{tag}</span>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{/* Name and Price */}
|
|
168
|
+
<div className="flex items-end justify-between gap-4">
|
|
169
|
+
<h1 className="text-2xl font-bold text-foreground leading-tight">
|
|
170
|
+
{product.name}
|
|
171
|
+
</h1>
|
|
172
|
+
<div className="flex items-baseline gap-2 shrink-0">
|
|
173
|
+
<span className="text-lg font-semibold text-foreground">{formatPrice(product.price)}</span>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{/* Rating and Favorite */}
|
|
178
|
+
<div className="flex items-center justify-start gap-2">
|
|
179
|
+
<button
|
|
180
|
+
onClick={() => setIsFavorite(!isFavorite)}
|
|
181
|
+
className="w-10 h-10 bg-foreground/5 rounded-full flex items-center justify-center hover:bg-foreground/10 transition-colors"
|
|
182
|
+
>
|
|
183
|
+
<Icon
|
|
184
|
+
icon={isFavorite ? 'gravity-ui:heart-fill' : 'gravity-ui:heart'}
|
|
185
|
+
className={`w-5 h-5 ${isFavorite ? 'text-red-500' : 'text-muted-foreground/60'}`}
|
|
186
|
+
/>
|
|
187
|
+
</button>
|
|
188
|
+
<div className="h-10 bg-foreground/5 rounded-full px-3 flex items-center gap-1 text-sm font-caption text-muted-foreground">
|
|
189
|
+
<Icon icon="gravity-ui:star" className="w-4 h-4 text-muted-foreground" />
|
|
190
|
+
<span className="font-medium">4.2</span>
|
|
191
|
+
<span>(143)</span>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{/* Artwork Customizer Placeholder */}
|
|
197
|
+
{showArtworkCustomizer && (
|
|
198
|
+
<div className="aspect-square bg-muted rounded-2xl flex items-center justify-center border-2 border-dashed border-border">
|
|
199
|
+
<div className="text-center text-muted-foreground p-4">
|
|
200
|
+
<Icon icon="gravity-ui:hand-pointer-fill" className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
|
201
|
+
<p className="text-sm font-medium">Artwork Customizer</p>
|
|
202
|
+
<p className="text-xs opacity-70">Drag to position your design</p>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{/* Product Options - REAL COMPONENT */}
|
|
208
|
+
<ProductOptions />
|
|
209
|
+
|
|
210
|
+
{/* Add to Cart - REAL COMPONENT */}
|
|
211
|
+
<AddToCart />
|
|
212
|
+
|
|
213
|
+
{/* Product Details Accordion */}
|
|
214
|
+
<Accordion type="single" collapsible className="w-full [&>*]:border-0">
|
|
215
|
+
<AccordionItem value="description">
|
|
216
|
+
<AccordionTrigger className="text-left text-base font-bold text-foreground hover:no-underline">
|
|
217
|
+
Description
|
|
218
|
+
</AccordionTrigger>
|
|
219
|
+
<AccordionContent>
|
|
220
|
+
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
221
|
+
Premium quality with exceptional comfort and style.
|
|
222
|
+
</p>
|
|
223
|
+
</AccordionContent>
|
|
224
|
+
</AccordionItem>
|
|
225
|
+
|
|
226
|
+
<AccordionItem value="materials">
|
|
227
|
+
<AccordionTrigger className="text-left text-base font-bold text-foreground hover:no-underline">
|
|
228
|
+
Materials
|
|
229
|
+
</AccordionTrigger>
|
|
230
|
+
<AccordionContent>
|
|
231
|
+
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
232
|
+
100% organic cotton with sustainable manufacturing.
|
|
233
|
+
</p>
|
|
234
|
+
</AccordionContent>
|
|
235
|
+
</AccordionItem>
|
|
236
|
+
|
|
237
|
+
<AccordionItem value="shipping">
|
|
238
|
+
<AccordionTrigger className="text-left text-base font-bold text-foreground hover:no-underline">
|
|
239
|
+
Shipping & Returns
|
|
240
|
+
</AccordionTrigger>
|
|
241
|
+
<AccordionContent>
|
|
242
|
+
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
243
|
+
Free shipping on orders over $50. 30-day returns.
|
|
244
|
+
</p>
|
|
245
|
+
</AccordionContent>
|
|
246
|
+
</AccordionItem>
|
|
247
|
+
</Accordion>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
{/* Below the fold content */}
|
|
253
|
+
<section className="border-t border-border py-16 px-8 bg-background">
|
|
254
|
+
<div className="max-w-4xl mx-auto text-center">
|
|
255
|
+
<h2 className="text-3xl font-bold mb-4">Crafted with Precision</h2>
|
|
256
|
+
<p className="text-muted-foreground text-lg mb-8">
|
|
257
|
+
Every detail matters. Our products are designed with care.
|
|
258
|
+
</p>
|
|
259
|
+
|
|
260
|
+
<div className="grid grid-cols-3 gap-8">
|
|
261
|
+
<div className="text-center">
|
|
262
|
+
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
|
|
263
|
+
<Icon icon="gravity-ui:shield-check" className="w-8 h-8 text-primary" />
|
|
264
|
+
</div>
|
|
265
|
+
<h3 className="font-semibold mb-2">Quality Guaranteed</h3>
|
|
266
|
+
<p className="text-sm text-muted-foreground">Premium materials</p>
|
|
267
|
+
</div>
|
|
268
|
+
<div className="text-center">
|
|
269
|
+
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
|
|
270
|
+
<Icon icon="gravity-ui:leaf" className="w-8 h-8 text-primary" />
|
|
271
|
+
</div>
|
|
272
|
+
<h3 className="font-semibold mb-2">Eco-Friendly</h3>
|
|
273
|
+
<p className="text-sm text-muted-foreground">Sustainable</p>
|
|
274
|
+
</div>
|
|
275
|
+
<div className="text-center">
|
|
276
|
+
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
|
|
277
|
+
<Icon icon="gravity-ui:rocket" className="w-8 h-8 text-primary" />
|
|
278
|
+
</div>
|
|
279
|
+
<h3 className="font-semibold mb-2">Fast Shipping</h3>
|
|
280
|
+
<p className="text-sm text-muted-foreground">Quick delivery</p>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
</section>
|
|
285
|
+
</main>
|
|
286
|
+
</div>
|
|
287
|
+
</Product>
|
|
288
|
+
);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const meta: Meta<typeof PDPLayout> = {
|
|
292
|
+
title: 'Layouts/PDP Layout',
|
|
293
|
+
component: PDPLayout,
|
|
294
|
+
parameters: {
|
|
295
|
+
layout: 'fullscreen',
|
|
296
|
+
docs: {
|
|
297
|
+
description: {
|
|
298
|
+
component: `
|
|
299
|
+
# PDP Layout
|
|
300
|
+
|
|
301
|
+
The complete Product Detail Page layout using **real components** with Product context.
|
|
302
|
+
|
|
303
|
+
**Images are full-width** and the **info panel overlaps** on the right.
|
|
304
|
+
|
|
305
|
+
## Layout Structure
|
|
306
|
+
|
|
307
|
+
\`\`\`
|
|
308
|
+
┌─────────────────────────────────────────────────┐
|
|
309
|
+
│ Header (fixed) │
|
|
310
|
+
├─────────────────────────────────────────────────┤
|
|
311
|
+
│ ┌───────────┐│
|
|
312
|
+
│ Full-width Hero Images │ Info Panel││
|
|
313
|
+
│ (70vh each, stacked) │ (fixed, ││
|
|
314
|
+
│ │ overlaps) ││
|
|
315
|
+
│ ┌─────────────────────────────┐ │ ││
|
|
316
|
+
│ │ Front Mockup │ │ • Name ││
|
|
317
|
+
│ └─────────────────────────────┘ │ • Price ││
|
|
318
|
+
│ ┌─────────────────────────────┐ │ • Options ││
|
|
319
|
+
│ │ Back Mockup │ │ • Add ││
|
|
320
|
+
│ └─────────────────────────────┘ │ ││
|
|
321
|
+
│ ┌─────────────────────────────┐ └───────────┘│
|
|
322
|
+
│ │ Detail Mockup │ │
|
|
323
|
+
│ └─────────────────────────────┘ │
|
|
324
|
+
├─────────────────────────────────────────────────┤
|
|
325
|
+
│ Additional Content (reviews, etc) │
|
|
326
|
+
└─────────────────────────────────────────────────┘
|
|
327
|
+
\`\`\`
|
|
328
|
+
|
|
329
|
+
## Key Points
|
|
330
|
+
|
|
331
|
+
- **Images are full-width** - they span the entire viewport
|
|
332
|
+
- **Image content is centered left** - centered in the visible area (left of panel), so right portion is cropped/hidden
|
|
333
|
+
- **Panel overlaps** - positioned fixed on the right, on top of images
|
|
334
|
+
- **Glassmorphism** - panel has backdrop blur so images show through
|
|
335
|
+
- **Sticky behavior** - panel stays visible while scrolling through images
|
|
336
|
+
- **Real components** - uses actual ProductOptions and AddToCart with context
|
|
337
|
+
`,
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
tags: ['autodocs'],
|
|
342
|
+
argTypes: {
|
|
343
|
+
tag: {
|
|
344
|
+
control: 'text',
|
|
345
|
+
description: 'Product tag/badge (e.g., "Upgraded", "New", "Sale")',
|
|
346
|
+
},
|
|
347
|
+
showArtworkCustomizer: {
|
|
348
|
+
control: 'boolean',
|
|
349
|
+
description: 'Show artwork customizer for POD products',
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
export default meta;
|
|
355
|
+
type Story = StoryObj<typeof meta>;
|
|
356
|
+
|
|
357
|
+
export const Default: Story = {
|
|
358
|
+
args: {},
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
export const WithArtworkCustomizer: Story = {
|
|
362
|
+
args: {
|
|
363
|
+
showArtworkCustomizer: true,
|
|
364
|
+
},
|
|
365
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import {
|
|
5
|
+
getBrand,
|
|
6
|
+
brandAssets,
|
|
7
|
+
type SupportedLanguage,
|
|
8
|
+
type BrandLocale,
|
|
9
|
+
type BrandAssets,
|
|
10
|
+
} from "../lib/locale";
|
|
11
|
+
|
|
12
|
+
export interface UseBrandResult {
|
|
13
|
+
/** Brand data for the requested locale. */
|
|
14
|
+
brand: BrandLocale;
|
|
15
|
+
/** Global brand assets (icon, colors). */
|
|
16
|
+
assets: BrandAssets;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* React hook for accessing brand data. Throws if the locale is not in brand.json.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* function Header() {
|
|
25
|
+
* const { brand, assets } = useBrand("en");
|
|
26
|
+
* return (
|
|
27
|
+
* <header>
|
|
28
|
+
* <img src={assets.icon_svg} alt="" />
|
|
29
|
+
* <h1 translate="no" lang={brand.lang}>{brand.localized_name}</h1>
|
|
30
|
+
* <p>{brand.tagline}</p>
|
|
31
|
+
* </header>
|
|
32
|
+
* );
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function useBrand(language: SupportedLanguage = "en"): UseBrandResult {
|
|
37
|
+
return useMemo(
|
|
38
|
+
() => ({ brand: getBrand(language), assets: brandAssets }),
|
|
39
|
+
[language]
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import { useProductOptional, type Artwork } from '../patterns/Product';
|
|
5
|
+
import { useRealtimeOptional } from '../patterns/RealtimeProvider';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* useCanvasContext - Non-reactive context access for canvas components
|
|
9
|
+
*
|
|
10
|
+
* This hook provides ref-based access to ProductContext and RealtimeContext values
|
|
11
|
+
* that are needed during canvas drag operations. Components using this hook will NOT
|
|
12
|
+
* re-render when context changes - they always have access to the latest values via refs.
|
|
13
|
+
*
|
|
14
|
+
* **Why this exists:**
|
|
15
|
+
* During canvas drag operations, any React re-render causes lag. The standard
|
|
16
|
+
* `useProductOptional()` hook subscribes to the entire context, which means
|
|
17
|
+
* components re-render whenever any context value changes (including mockupResults).
|
|
18
|
+
*
|
|
19
|
+
* This hook solves that by:
|
|
20
|
+
* 1. Subscribing to context once on mount
|
|
21
|
+
* 2. Keeping refs updated with latest values (ref updates don't trigger re-renders)
|
|
22
|
+
* 3. Returning a stable object that never changes reference
|
|
23
|
+
*
|
|
24
|
+
* **Usage:**
|
|
25
|
+
* ```tsx
|
|
26
|
+
* const { sendCanvasBlobRef, isRealtimeEnabledRef, mockupCountRef } = useCanvasContext();
|
|
27
|
+
*
|
|
28
|
+
* const handleExport = useCallback(async (exports) => {
|
|
29
|
+
* if (isRealtimeEnabledRef.current && sendCanvasBlobRef.current) {
|
|
30
|
+
* sendCanvasBlobRef.current(placement, blob, mockupCountRef.current, 500);
|
|
31
|
+
* }
|
|
32
|
+
* }, []); // Empty deps - callback never recreated
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* **IMPORTANT:** This hook still subscribes to context (can't avoid that with React Context).
|
|
36
|
+
* To prevent re-renders, the PARENT component using this hook should be wrapped in React.memo
|
|
37
|
+
* with a custom comparison function that ignores context-driven prop changes.
|
|
38
|
+
*
|
|
39
|
+
* For truly zero re-renders, pass required values as props from a parent that does subscribe,
|
|
40
|
+
* and DON'T call useCanvasContext or useProductOptional in the canvas component itself.
|
|
41
|
+
*
|
|
42
|
+
* @returns Object containing refs to context values - object reference is stable
|
|
43
|
+
*/
|
|
44
|
+
export function useCanvasContext() {
|
|
45
|
+
const context = useProductOptional();
|
|
46
|
+
const realtimeContext = useRealtimeOptional();
|
|
47
|
+
|
|
48
|
+
// Store context values in refs - ref updates don't trigger re-renders
|
|
49
|
+
const sendCanvasBlobRef = useRef(realtimeContext?.sendCanvasBlob);
|
|
50
|
+
const isRealtimeEnabledRef = useRef(realtimeContext?.isEnabled ?? false);
|
|
51
|
+
const isConfiguredRef = useRef(realtimeContext?.isConfigured ?? false);
|
|
52
|
+
const mockupCountRef = useRef(context?.product?.mockups?.length ?? 1);
|
|
53
|
+
const selectedPlacementRef = useRef(context?.selectedPlacement);
|
|
54
|
+
const enableRealtimeRef = useRef(realtimeContext?.enableRealtime);
|
|
55
|
+
const selectedArtworkRef = useRef<Artwork | undefined>(context?.selectedArtwork);
|
|
56
|
+
|
|
57
|
+
// Keep refs current after every render (this effect has no deps, runs after every render)
|
|
58
|
+
// Ref updates don't cause re-renders, so this is safe
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
sendCanvasBlobRef.current = realtimeContext?.sendCanvasBlob;
|
|
61
|
+
isRealtimeEnabledRef.current = realtimeContext?.isEnabled ?? false;
|
|
62
|
+
isConfiguredRef.current = realtimeContext?.isConfigured ?? false;
|
|
63
|
+
mockupCountRef.current = context?.product?.mockups?.length ?? 1;
|
|
64
|
+
selectedPlacementRef.current = context?.selectedPlacement;
|
|
65
|
+
enableRealtimeRef.current = realtimeContext?.enableRealtime;
|
|
66
|
+
selectedArtworkRef.current = context?.selectedArtwork;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Return stable object that never changes reference (empty deps)
|
|
70
|
+
return useMemo(() => ({
|
|
71
|
+
/** Ref to sendCanvasBlob function - call via sendCanvasBlobRef.current?.(...) */
|
|
72
|
+
sendCanvasBlobRef,
|
|
73
|
+
/** Ref to isRealtimeEnabled boolean */
|
|
74
|
+
isRealtimeEnabledRef,
|
|
75
|
+
/** Ref to isConfigured boolean */
|
|
76
|
+
isConfiguredRef,
|
|
77
|
+
/** Ref to mockup count number */
|
|
78
|
+
mockupCountRef,
|
|
79
|
+
/** Ref to selected placement string */
|
|
80
|
+
selectedPlacementRef,
|
|
81
|
+
/** Ref to enableRealtime function */
|
|
82
|
+
enableRealtimeRef,
|
|
83
|
+
/** Ref to selected artwork */
|
|
84
|
+
selectedArtworkRef,
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Debug helper - logs current ref values
|
|
88
|
+
* Only use for debugging, not in production code
|
|
89
|
+
*/
|
|
90
|
+
__debug: () => ({
|
|
91
|
+
isRealtimeEnabled: isRealtimeEnabledRef.current,
|
|
92
|
+
isConfigured: isConfiguredRef.current,
|
|
93
|
+
mockupCount: mockupCountRef.current,
|
|
94
|
+
selectedPlacement: selectedPlacementRef.current,
|
|
95
|
+
hasSendCanvasBlob: !!sendCanvasBlobRef.current,
|
|
96
|
+
hasEnableRealtime: !!enableRealtimeRef.current,
|
|
97
|
+
selectedArtworkSrc: selectedArtworkRef.current?.src,
|
|
98
|
+
}),
|
|
99
|
+
}), []);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* useCanvasContextStatic - Truly non-reactive context access
|
|
104
|
+
*
|
|
105
|
+
* This hook captures context values ONCE on mount and never updates.
|
|
106
|
+
* Use this when you need initial values but never want to re-render.
|
|
107
|
+
*
|
|
108
|
+
* **Warning:** Values will be stale if context changes after mount!
|
|
109
|
+
* Only use for values that don't change during the component's lifetime.
|
|
110
|
+
*/
|
|
111
|
+
export function useCanvasContextStatic() {
|
|
112
|
+
const context = useProductOptional();
|
|
113
|
+
const realtimeContext = useRealtimeOptional();
|
|
114
|
+
|
|
115
|
+
// Capture values once on mount using lazy initialization
|
|
116
|
+
const [staticValues] = useState(() => ({
|
|
117
|
+
sendCanvasBlob: realtimeContext?.sendCanvasBlob,
|
|
118
|
+
isRealtimeEnabled: realtimeContext?.isEnabled ?? false,
|
|
119
|
+
isConfigured: realtimeContext?.isConfigured ?? false,
|
|
120
|
+
mockupCount: context?.product?.mockups?.length ?? 1,
|
|
121
|
+
selectedPlacement: context?.selectedPlacement,
|
|
122
|
+
enableRealtime: realtimeContext?.enableRealtime,
|
|
123
|
+
selectedArtwork: context?.selectedArtwork,
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
return staticValues;
|
|
127
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
export interface DeviceDetectionResult {
|
|
6
|
+
/** Whether the device supports touch input */
|
|
7
|
+
isTouchDevice: boolean;
|
|
8
|
+
/** Whether the browser is Safari (not Chrome/Firefox on iOS) */
|
|
9
|
+
isSafari: boolean;
|
|
10
|
+
/** Safari on non-touch devices (macOS). Use for desktop-only Safari workarounds like mask repaint. */
|
|
11
|
+
isDesktopSafari: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Hook to detect device capabilities: touch support and Safari browser.
|
|
16
|
+
*
|
|
17
|
+
* These capabilities are detected once on mount and don't change during
|
|
18
|
+
* the session, so no resize listener is needed.
|
|
19
|
+
*
|
|
20
|
+
* @returns Object with isTouchDevice and isSafari booleans
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* const { isTouchDevice, isSafari } = useDeviceDetection();
|
|
25
|
+
*
|
|
26
|
+
* // Use for Safari-specific workarounds (e.g., edge swipe prevention)
|
|
27
|
+
* if (isSafari && isTouchDevice) {
|
|
28
|
+
* // Apply iOS Safari workarounds
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function useDeviceDetection(): DeviceDetectionResult {
|
|
33
|
+
const [isTouchDevice, setIsTouchDevice] = useState(false);
|
|
34
|
+
const [isSafari, setIsSafari] = useState(false);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
// These only need to run ONCE on mount - they don't change during the session
|
|
38
|
+
const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
|
39
|
+
setIsTouchDevice(hasTouch);
|
|
40
|
+
|
|
41
|
+
// Safari detection - detect actual Safari browsers (not Chrome on iOS/iPad)
|
|
42
|
+
const userAgent = navigator.userAgent;
|
|
43
|
+
const isSafariBrowser =
|
|
44
|
+
// Safari on macOS/iOS that's not Chrome
|
|
45
|
+
(userAgent.includes("Safari") &&
|
|
46
|
+
userAgent.includes("Version") &&
|
|
47
|
+
!userAgent.includes("Chrome") &&
|
|
48
|
+
!userAgent.includes("CriOS") && // Chrome on iOS
|
|
49
|
+
!userAgent.includes("FxiOS") && // Firefox on iOS
|
|
50
|
+
!userAgent.includes("EdgiOS")) || // Edge on iOS
|
|
51
|
+
// Additional check for Safari without Version string
|
|
52
|
+
(/Safari/.test(userAgent) &&
|
|
53
|
+
!/Chrome|CriOS|FxiOS|EdgiOS/.test(userAgent));
|
|
54
|
+
setIsSafari(isSafariBrowser);
|
|
55
|
+
|
|
56
|
+
// NO RESIZE LISTENER - device capabilities don't change on resize
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
// Desktop Safari = Safari on non-touch device (macOS)
|
|
60
|
+
// Used for workarounds that should NOT apply to iPad/iPhone (e.g., mask repaint on window resize)
|
|
61
|
+
const isDesktopSafari = isSafari && !isTouchDevice;
|
|
62
|
+
|
|
63
|
+
return { isTouchDevice, isSafari, isDesktopSafari };
|
|
64
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hook to trap focus within a container element (for modals, drawers, etc.)
|
|
7
|
+
* Returns focus to the trigger element when the trap is deactivated
|
|
8
|
+
*/
|
|
9
|
+
export function useFocusTrap(isActive: boolean, onClose?: () => void) {
|
|
10
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
11
|
+
const previousActiveElement = useRef<HTMLElement | null>(null);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!isActive) return;
|
|
15
|
+
|
|
16
|
+
// Store the element that was focused before the trap activated
|
|
17
|
+
previousActiveElement.current = document.activeElement as HTMLElement;
|
|
18
|
+
|
|
19
|
+
const container = containerRef.current;
|
|
20
|
+
if (!container) return;
|
|
21
|
+
|
|
22
|
+
// Focus the first focusable element in the container
|
|
23
|
+
const focusableElements = container.querySelectorAll<HTMLElement>(
|
|
24
|
+
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
if (focusableElements.length > 0) {
|
|
28
|
+
focusableElements[0].focus();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Handle Tab and Shift+Tab to trap focus
|
|
32
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
33
|
+
if (e.key !== "Tab") return;
|
|
34
|
+
|
|
35
|
+
const focusableElements = container.querySelectorAll<HTMLElement>(
|
|
36
|
+
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const firstElement = focusableElements[0];
|
|
40
|
+
const lastElement = focusableElements[focusableElements.length - 1];
|
|
41
|
+
|
|
42
|
+
if (e.shiftKey) {
|
|
43
|
+
// Shift + Tab
|
|
44
|
+
if (document.activeElement === firstElement) {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
lastElement?.focus();
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
// Tab
|
|
50
|
+
if (document.activeElement === lastElement) {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
firstElement?.focus();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
container.addEventListener("keydown", handleKeyDown);
|
|
58
|
+
|
|
59
|
+
return () => {
|
|
60
|
+
container.removeEventListener("keydown", handleKeyDown);
|
|
61
|
+
|
|
62
|
+
// Return focus to the previously focused element
|
|
63
|
+
if (previousActiveElement.current) {
|
|
64
|
+
previousActiveElement.current.focus();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}, [isActive]);
|
|
68
|
+
|
|
69
|
+
return containerRef;
|
|
70
|
+
}
|