@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.
- package/CHANGELOG.md +26 -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,290 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState, useCallback } from "react";
|
|
4
|
+
import { useDesignOptional, type Artwork } from "../patterns/Product";
|
|
5
|
+
import { useShopOptional } from "../patterns/ShopProvider";
|
|
6
|
+
import { checkProviders } from "../utils/devWarnings";
|
|
7
|
+
|
|
8
|
+
// Default artwork sources (dimensions will be loaded dynamically)
|
|
9
|
+
const DEFAULT_ARTWORK_SOURCES = [
|
|
10
|
+
{
|
|
11
|
+
id: "cool-cat",
|
|
12
|
+
name: "Cool Cat",
|
|
13
|
+
src: "https://images.unsplash.com/photo-1533738363-b7f9aef128ce?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "abstract-art",
|
|
17
|
+
name: "Abstract Art",
|
|
18
|
+
src: "https://images.unsplash.com/photo-1558865869-c93f6f8482af?q=80&w=2940&auto=format&fit=crop",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "mountain-landscape",
|
|
22
|
+
name: "Mountain Landscape",
|
|
23
|
+
src: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?q=80&w=2940&auto=format&fit=crop",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "geometric-pattern",
|
|
27
|
+
name: "Geometric Pattern",
|
|
28
|
+
src: "https://images.unsplash.com/photo-1557672172-298e090bd0f1?q=80&w=2940&auto=format&fit=crop",
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export interface ArtSelectorProps {
|
|
33
|
+
className?: string;
|
|
34
|
+
artworks: string[] | Artwork[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* ArtSelector - Interactive artwork selection gallery
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ **IMPORTANT:** This component renders visible UI (a scrollable gallery of thumbnails).
|
|
41
|
+
* For custom artwork selection UI, use the `useShop()` hook instead.
|
|
42
|
+
*
|
|
43
|
+
* @see https://developers.snowcone.app/docs/react/components/select-artwork
|
|
44
|
+
*
|
|
45
|
+
* A composed component that displays a scrollable gallery of artwork thumbnails
|
|
46
|
+
* with automatic dimension loading and selection state management. Works with
|
|
47
|
+
* both Shop and Product contexts.
|
|
48
|
+
*
|
|
49
|
+
* **🎯 Primary Purpose:**
|
|
50
|
+
* This component sets the `selectedArtwork` in Design context, which is then
|
|
51
|
+
* automatically read by components like `<ProductImage>` and `<ArtworkCustomizer>`.
|
|
52
|
+
*
|
|
53
|
+
* **Features:**
|
|
54
|
+
* - Renders a horizontal scrollable gallery of artwork thumbnails
|
|
55
|
+
* - Automatic image dimension detection on mount
|
|
56
|
+
* - Aspect ratio preservation in thumbnails (60px square)
|
|
57
|
+
* - Selection state with visual feedback (brightness filter)
|
|
58
|
+
* - Keyboard navigation support (Enter/Space to select)
|
|
59
|
+
* - Theme-aware borders using CSS variables
|
|
60
|
+
* - Checkerboard background pattern for transparent images
|
|
61
|
+
* - Focus visible ring on keyboard navigation
|
|
62
|
+
*
|
|
63
|
+
* **Context Integration:**
|
|
64
|
+
* - Works with `Shop` context for global artwork management
|
|
65
|
+
* - Also works with `Product`/`Design` context for product-specific artwork
|
|
66
|
+
* - **Automatically updates `selectedArtwork` in context on click**
|
|
67
|
+
* - Loads artwork dimensions asynchronously on mount
|
|
68
|
+
* - Auto-selects first artwork if none selected
|
|
69
|
+
* - Other components like `ProductImage` and `ArtworkCustomizer` read from this context
|
|
70
|
+
*
|
|
71
|
+
* **Visual Behavior:**
|
|
72
|
+
* - Selected artwork: Full brightness with accent border
|
|
73
|
+
* - Unselected artwork: 50% brightness, 80% on hover
|
|
74
|
+
* - Loading state: Shows "Loading artwork..." text
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```tsx
|
|
78
|
+
* // Basic usage with Shop context
|
|
79
|
+
* <Shop>
|
|
80
|
+
* <ArtSelector artworks={[
|
|
81
|
+
* 'https://example.com/art1.jpg',
|
|
82
|
+
* 'https://example.com/art2.jpg',
|
|
83
|
+
* 'https://example.com/art3.jpg'
|
|
84
|
+
* ]} />
|
|
85
|
+
* <Product productId="shirt-123">
|
|
86
|
+
* <ProductImage />
|
|
87
|
+
* </Product>
|
|
88
|
+
* </Shop>
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```tsx
|
|
93
|
+
* // Within Product context with ArtworkCustomizer
|
|
94
|
+
* <Product productId="shirt-123">
|
|
95
|
+
* <ArtSelector artworks={[
|
|
96
|
+
* { type: 'regular', src: 'https://example.com/photo.jpg' },
|
|
97
|
+
* { type: 'pattern', src: 'https://example.com/pattern.jpg', tileCount: 1 }
|
|
98
|
+
* ]} />
|
|
99
|
+
* <ProductImage />
|
|
100
|
+
* <ArtworkCustomizer />
|
|
101
|
+
* </Product>
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```tsx
|
|
106
|
+
* // Custom artwork selector using useShop() hook (recommended for custom UI)
|
|
107
|
+
* function MyCustomSelector() {
|
|
108
|
+
* const { setSelectedArtwork } = useShop();
|
|
109
|
+
*
|
|
110
|
+
* const selectArtwork = async (url: string) => {
|
|
111
|
+
* const img = new Image();
|
|
112
|
+
* img.onload = () => {
|
|
113
|
+
* setSelectedArtwork({
|
|
114
|
+
* type: 'regular',
|
|
115
|
+
* src: url,
|
|
116
|
+
* width: img.naturalWidth,
|
|
117
|
+
* height: img.naturalHeight,
|
|
118
|
+
* aspectRatio: img.naturalWidth / img.naturalHeight,
|
|
119
|
+
* });
|
|
120
|
+
* };
|
|
121
|
+
* img.src = url;
|
|
122
|
+
* };
|
|
123
|
+
*
|
|
124
|
+
* return <button onClick={() => selectArtwork(url)}>Select</button>;
|
|
125
|
+
* }
|
|
126
|
+
* ```
|
|
127
|
+
*
|
|
128
|
+
* @param artworks - Array of artwork URLs (strings) or Artwork objects ({ type: 'regular' | 'pattern', src: string, tileCount?: number }). Note: use "artworks" (plural), not "artwork" (singular).
|
|
129
|
+
* @param className - Additional CSS classes for the container element
|
|
130
|
+
*/
|
|
131
|
+
export function ArtSelector({
|
|
132
|
+
className,
|
|
133
|
+
artworks: customArtworkUrls,
|
|
134
|
+
}: ArtSelectorProps) {
|
|
135
|
+
// Try to use Shop context first, fallback to Product context
|
|
136
|
+
const shopContext = useShopOptional();
|
|
137
|
+
const productContext = useDesignOptional();
|
|
138
|
+
|
|
139
|
+
// Use whichever context is available
|
|
140
|
+
const context = shopContext || productContext;
|
|
141
|
+
|
|
142
|
+
// Dev-mode warning for missing providers
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
checkProviders(
|
|
145
|
+
"ArtSelector",
|
|
146
|
+
{
|
|
147
|
+
shop: shopContext,
|
|
148
|
+
product: productContext,
|
|
149
|
+
},
|
|
150
|
+
"/components/art-selector"
|
|
151
|
+
);
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
154
|
+
if (!context) {
|
|
155
|
+
throw new Error("ArtSelector must be used within either a Shop or Product provider");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Use ref to track if we've initialized
|
|
159
|
+
const hasInitialized = useRef(false);
|
|
160
|
+
|
|
161
|
+
// Load artwork dimensions on mount
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
// Only load once
|
|
164
|
+
if (hasInitialized.current) return;
|
|
165
|
+
hasInitialized.current = true;
|
|
166
|
+
|
|
167
|
+
const loadArtworkDimensions = async () => {
|
|
168
|
+
const loadedArtworks: Artwork[] = [];
|
|
169
|
+
|
|
170
|
+
// Handle both string URLs and Artwork objects
|
|
171
|
+
const artworkInputs = customArtworkUrls.map((item, index) => {
|
|
172
|
+
if (typeof item === 'string') {
|
|
173
|
+
// String URL - default to regular artwork
|
|
174
|
+
return { type: 'regular' as const, src: item };
|
|
175
|
+
}
|
|
176
|
+
// Already an Artwork object
|
|
177
|
+
return item;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
for (const artworkInput of artworkInputs) {
|
|
181
|
+
// Check if already exists before loading
|
|
182
|
+
const existingArtwork = context.artworks.find(
|
|
183
|
+
(a: Artwork) => a.src === artworkInput.src
|
|
184
|
+
);
|
|
185
|
+
if (existingArtwork) {
|
|
186
|
+
loadedArtworks.push(existingArtwork);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const img = new Image();
|
|
191
|
+
img.crossOrigin = 'anonymous';
|
|
192
|
+
img.src = artworkInput.src;
|
|
193
|
+
|
|
194
|
+
await new Promise((resolve) => {
|
|
195
|
+
img.onload = () => {
|
|
196
|
+
// Use the provided artwork object directly
|
|
197
|
+
loadedArtworks.push(artworkInput);
|
|
198
|
+
context.addArtwork(artworkInput);
|
|
199
|
+
resolve(true);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
img.onerror = () => {
|
|
203
|
+
console.error(`Failed to load image: ${artworkInput.src}`);
|
|
204
|
+
resolve(false);
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Set first artwork as selected if none selected
|
|
210
|
+
if (!context.selectedArtwork && loadedArtworks.length > 0) {
|
|
211
|
+
context.setSelectedArtwork(loadedArtworks[0]);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
loadArtworkDimensions();
|
|
216
|
+
// Empty dependency array - only run once on mount
|
|
217
|
+
}, []);
|
|
218
|
+
|
|
219
|
+
const handleArtworkSelect = useCallback((artwork: Artwork) => {
|
|
220
|
+
context.setSelectedArtwork(artwork);
|
|
221
|
+
}, [context]);
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div className={className}>
|
|
225
|
+
<div className="overflow-x-auto p-[6px]">
|
|
226
|
+
<div className="flex gap-3" style={{ minWidth: "min-content" }}>
|
|
227
|
+
{context.artworks.length === 0 ? (
|
|
228
|
+
<div className="text-sm text-muted-foreground">Loading artwork...</div>
|
|
229
|
+
) : null}
|
|
230
|
+
{context.artworks.map((artwork: Artwork) => {
|
|
231
|
+
// Fixed dimensions for thumbnails
|
|
232
|
+
const imageHeight = 60;
|
|
233
|
+
const imageWidth = imageHeight; // Square thumbnails
|
|
234
|
+
|
|
235
|
+
const isSelected = context.selectedArtwork?.src === artwork.src;
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<button
|
|
239
|
+
key={artwork.src}
|
|
240
|
+
type="button"
|
|
241
|
+
onClick={() => handleArtworkSelect(artwork)}
|
|
242
|
+
onKeyDown={(e) => {
|
|
243
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
handleArtworkSelect(artwork);
|
|
246
|
+
}
|
|
247
|
+
}}
|
|
248
|
+
className="relative flex-shrink-0 cursor-pointer group border-0 p-0 bg-transparent focus:outline-none rounded-[calc(var(--radius-image,4px)+3px)]"
|
|
249
|
+
style={{
|
|
250
|
+
width: `${imageWidth}px`,
|
|
251
|
+
height: `${imageHeight}px`,
|
|
252
|
+
}}
|
|
253
|
+
aria-label={`Select artwork`}
|
|
254
|
+
aria-pressed={isSelected}
|
|
255
|
+
>
|
|
256
|
+
{/* Selection ring - visible border around selected artwork */}
|
|
257
|
+
<div
|
|
258
|
+
className="absolute -inset-[5px] pointer-events-none opacity-0 group-[:focus-visible]:opacity-100 transition-opacity"
|
|
259
|
+
style={{
|
|
260
|
+
border: "2px solid var(--color-accent, hsl(var(--primary)))",
|
|
261
|
+
borderRadius: "calc(var(--radius-image, 4px) + 5px)",
|
|
262
|
+
opacity: isSelected ? 1 : undefined,
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
<span
|
|
266
|
+
className={`absolute inset-0 ${
|
|
267
|
+
isSelected
|
|
268
|
+
? "brightness-100"
|
|
269
|
+
: "brightness-50 hover:brightness-80"
|
|
270
|
+
} block bg-cover bg-center transition-[filter] duration-150 overflow-hidden`}
|
|
271
|
+
style={{
|
|
272
|
+
backgroundColor: "#fff",
|
|
273
|
+
backgroundPosition: "center, 0 0, 10px 10px",
|
|
274
|
+
backgroundImage: `
|
|
275
|
+
url(${artwork.src}),
|
|
276
|
+
linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5),
|
|
277
|
+
linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5)
|
|
278
|
+
`,
|
|
279
|
+
backgroundSize: `cover, 20px 20px, 20px 20px`,
|
|
280
|
+
borderRadius: "var(--radius-image, 4px)",
|
|
281
|
+
}}
|
|
282
|
+
/>
|
|
283
|
+
</button>
|
|
284
|
+
);
|
|
285
|
+
})}
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from "react";
|
|
4
|
+
import { ArtAlignment, type ArtAlignmentProps } from "./ArtAlignment";
|
|
5
|
+
import { TileCount } from "./TileCount";
|
|
6
|
+
import { ColorPicker } from "./ColorPicker";
|
|
7
|
+
import type { Artwork, RegularArtwork, SeamlessPattern } from "./ProductImage";
|
|
8
|
+
import { useProduct, useDesign } from "../patterns/Product";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Props for ArtworkCustomizer component
|
|
12
|
+
*/
|
|
13
|
+
export type ArtworkCustomizerProps = {
|
|
14
|
+
/** Optional artwork to customize (for backwards compatibility). If not provided, reads from Design context. */
|
|
15
|
+
artwork?: Artwork;
|
|
16
|
+
height?: string | number;
|
|
17
|
+
maxHeight?: string | number;
|
|
18
|
+
minHeight?: string | number;
|
|
19
|
+
className?: string;
|
|
20
|
+
/** Callback when image gallery button is clicked */
|
|
21
|
+
onOpenImageGallery?: () => void;
|
|
22
|
+
/** Callback when typography button is clicked */
|
|
23
|
+
onOpenTypography?: () => void;
|
|
24
|
+
/** Optional canvas editor component to render inside CanvasEditor */
|
|
25
|
+
children?: React.ReactNode;
|
|
26
|
+
/** Controlled mode: selected placement */
|
|
27
|
+
selectedPlacement?: string;
|
|
28
|
+
/** Controlled mode: callback when placement changes */
|
|
29
|
+
onPlacementChange?: (placement: string) => void;
|
|
30
|
+
/** Display mode - determines spacing between elements */
|
|
31
|
+
mode?: "canvas" | "alignment";
|
|
32
|
+
/** Editor mode for switching between simple and advanced editing */
|
|
33
|
+
editorMode?: "alignment" | "editor";
|
|
34
|
+
/** Callback when editor mode should change */
|
|
35
|
+
onEditorModeChange?: (mode: "alignment" | "editor") => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ArtworkCustomizer - Smart component that automatically renders the correct customization UI
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ **IMPORTANT: This component reads artwork from Design context, not from props!**
|
|
42
|
+
*
|
|
43
|
+
* This component intelligently chooses between ArtAlignment (for regular artwork) and
|
|
44
|
+
* TileCount (for seamless patterns) based on the artwork type from context. This eliminates
|
|
45
|
+
* the need for manual conditional rendering and prevents mistakes.
|
|
46
|
+
*
|
|
47
|
+
* **🎯 How It Works:**
|
|
48
|
+
* 1. Use `<ArtSelector>` to set artwork in the Design context
|
|
49
|
+
* 2. `<ArtworkCustomizer>` automatically reads from context and renders the right UI
|
|
50
|
+
* 3. Changes to artwork selection automatically update the customizer UI
|
|
51
|
+
*
|
|
52
|
+
* **🚀 Automatic Behavior:**
|
|
53
|
+
* - `artwork.type === 'regular'` → Renders `<ArtAlignment />` with positioning controls
|
|
54
|
+
* - `artwork.type === 'pattern'` → Renders `<TileCount />` with tile density slider
|
|
55
|
+
* - No manual if/else needed!
|
|
56
|
+
* - Seamlessly switches when user selects different artwork types
|
|
57
|
+
*
|
|
58
|
+
* **✅ Type Safety Benefits:**
|
|
59
|
+
* - TypeScript enforces correct artwork types in context
|
|
60
|
+
* - Impossible to show both ArtAlignment and TileCount simultaneously
|
|
61
|
+
* - Perfect for LLMs - single component, context-based API
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```tsx
|
|
65
|
+
* // CORRECT: Use with ArtSelector to manage artwork via context
|
|
66
|
+
* import { Product, ArtSelector, ArtworkCustomizer } from '@snowcone-app/ui';
|
|
67
|
+
*
|
|
68
|
+
* <Product productId="BEEB77">
|
|
69
|
+
* <ArtSelector artworks={[
|
|
70
|
+
* { type: 'regular', src: 'https://example.com/photo.jpg' },
|
|
71
|
+
* { type: 'pattern', src: 'https://example.com/pattern.jpg', tileCount: 1 }
|
|
72
|
+
* ]} />
|
|
73
|
+
* <ArtworkCustomizer maxHeight={200} />
|
|
74
|
+
* </Product>
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```tsx
|
|
79
|
+
* // CORRECT: Full example with ProductImage
|
|
80
|
+
* import { Product, ProductImage, ArtSelector, ArtworkCustomizer } from '@snowcone-app/ui';
|
|
81
|
+
*
|
|
82
|
+
* <Product productId="BEEB77">
|
|
83
|
+
* <div className="grid grid-cols-2 gap-8">
|
|
84
|
+
* <ProductImage />
|
|
85
|
+
* <div>
|
|
86
|
+
* <ArtSelector artworks={artworkUrls} />
|
|
87
|
+
* <ArtworkCustomizer maxHeight={200} />
|
|
88
|
+
* </div>
|
|
89
|
+
* </div>
|
|
90
|
+
* </Product>
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```tsx
|
|
95
|
+
* // WRONG: Don't try to pass artwork as a prop - it won't work!
|
|
96
|
+
* // ❌ This will NOT work:
|
|
97
|
+
* const artwork = { type: 'regular', src: '...' };
|
|
98
|
+
* <ArtworkCustomizer artwork={artwork} /> // ❌ artwork prop doesn't exist!
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* @param maxHeight - (Regular artwork only) Maximum height constraint in pixels (defaults to 120)
|
|
102
|
+
* @param height - (Regular artwork only) Fixed height in pixels
|
|
103
|
+
* @param minHeight - (Regular artwork only) Minimum height constraint in pixels
|
|
104
|
+
* @param className - Additional CSS classes for the component
|
|
105
|
+
*/
|
|
106
|
+
export function ArtworkCustomizer(props: ArtworkCustomizerProps) {
|
|
107
|
+
// Get artwork from props (for backwards compatibility) or context
|
|
108
|
+
const designContext = useDesign();
|
|
109
|
+
const artwork = props.artwork || designContext.selectedArtwork;
|
|
110
|
+
|
|
111
|
+
// Try to get product context for placements (only for regular artwork)
|
|
112
|
+
let productCtx: any = null;
|
|
113
|
+
try {
|
|
114
|
+
productCtx = useProduct();
|
|
115
|
+
} catch {
|
|
116
|
+
// Not in a Product context, that's OK
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Get placements from product if available
|
|
120
|
+
const placements = productCtx?.product?.placements || [];
|
|
121
|
+
|
|
122
|
+
// Use Design context for placement selection (standardized pattern)
|
|
123
|
+
const selectedPlacement = productCtx?.selectedPlacement;
|
|
124
|
+
const setSelectedPlacement = productCtx?.setSelectedPlacement;
|
|
125
|
+
|
|
126
|
+
// Initialize selectedPlacement to first placement if not set
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (placements.length > 0 && !selectedPlacement && setSelectedPlacement) {
|
|
129
|
+
setSelectedPlacement(placements[0].label);
|
|
130
|
+
}
|
|
131
|
+
}, [placements, selectedPlacement, setSelectedPlacement]);
|
|
132
|
+
|
|
133
|
+
// Find the currently selected placement object to check its type
|
|
134
|
+
const currentPlacement = placements.find(
|
|
135
|
+
(p: any) => p.label === selectedPlacement
|
|
136
|
+
);
|
|
137
|
+
const isColorPlacement = currentPlacement?.type === "color";
|
|
138
|
+
const isFitMode = currentPlacement?.defaultScaleMode === "fit";
|
|
139
|
+
|
|
140
|
+
// Debug: Log whenever selectedPlacement changes
|
|
141
|
+
// IMPORTANT: This must be BEFORE early returns to satisfy React's rules of hooks
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
}, [selectedPlacement]);
|
|
144
|
+
|
|
145
|
+
// If no artwork selected, show placeholder
|
|
146
|
+
if (!artwork) {
|
|
147
|
+
return (
|
|
148
|
+
<div className={`text-sm text-muted-foreground ${props.className || ""}`}>
|
|
149
|
+
Select artwork to customize
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Type guard and render appropriate component
|
|
155
|
+
if (artwork.type === "pattern") {
|
|
156
|
+
return (
|
|
157
|
+
<TileCount
|
|
158
|
+
artwork={artwork}
|
|
159
|
+
imageUrl={artwork.src}
|
|
160
|
+
placement={selectedPlacement} // ← Pass placement for product-specific scoping
|
|
161
|
+
className={props.className}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Detect if we're in canvas editor mode (explicit mode prop or when children are provided without mode)
|
|
167
|
+
const isCanvasEditorMode =
|
|
168
|
+
props.mode === "canvas" || (!!props.children && props.mode !== "alignment");
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div className="flex flex-col gap-4">
|
|
172
|
+
{/* Placement tabs - show ALL placements (image + color) */}
|
|
173
|
+
{placements.length > 1 && (
|
|
174
|
+
<div className="text-sm leading-relaxed">
|
|
175
|
+
{placements.map((p: any) => (
|
|
176
|
+
<button
|
|
177
|
+
key={p.label}
|
|
178
|
+
onClick={() => setSelectedPlacement?.(p.label)}
|
|
179
|
+
className={`inline transition-all duration-200 cursor-pointer mr-3 border-b-2 ${
|
|
180
|
+
selectedPlacement === p.label
|
|
181
|
+
? "text-foreground border-primary"
|
|
182
|
+
: "text-foreground/70 hover:text-foreground border-muted-foreground/20 hover:border-muted-foreground/40"
|
|
183
|
+
}`}
|
|
184
|
+
>
|
|
185
|
+
{p.label}
|
|
186
|
+
</button>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{/* Conditionally render ColorPicker for color placements or canvas editor for image placements */}
|
|
192
|
+
{isColorPlacement ? (
|
|
193
|
+
<ColorPicker placement={selectedPlacement} />
|
|
194
|
+
) : isFitMode ? (
|
|
195
|
+
null
|
|
196
|
+
) : (
|
|
197
|
+
<div className="relative">
|
|
198
|
+
{props.children || (
|
|
199
|
+
<ArtAlignment
|
|
200
|
+
artwork={{ src: artwork.src }}
|
|
201
|
+
placement={selectedPlacement}
|
|
202
|
+
height={props.height}
|
|
203
|
+
maxHeight={props.maxHeight ?? 120}
|
|
204
|
+
minHeight={props.minHeight}
|
|
205
|
+
className={props.className}
|
|
206
|
+
/>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { type ReactNode, type ComponentType } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Image as LucideImage,
|
|
6
|
+
Type as LucideType,
|
|
7
|
+
Shapes as LucideShapes,
|
|
8
|
+
Upload as LucideUpload,
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
|
|
11
|
+
// Cast to fix React 19 type compatibility with lucide-react
|
|
12
|
+
type IconProps = { className?: string };
|
|
13
|
+
const ImageIcon = LucideImage as ComponentType<IconProps>;
|
|
14
|
+
const TypeIcon = LucideType as ComponentType<IconProps>;
|
|
15
|
+
const ShapesIcon = LucideShapes as ComponentType<IconProps>;
|
|
16
|
+
const UploadIcon = LucideUpload as ComponentType<IconProps>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* CanvasEditor - Product customization editor
|
|
20
|
+
*
|
|
21
|
+
* This component provides a canvas editing area with toolbar buttons.
|
|
22
|
+
* The actual editor (like Polotno) should be passed as the children prop.
|
|
23
|
+
*/
|
|
24
|
+
export function CanvasEditor({
|
|
25
|
+
artworkSrc,
|
|
26
|
+
onOpenImageGallery,
|
|
27
|
+
onOpenTypography,
|
|
28
|
+
children,
|
|
29
|
+
}: {
|
|
30
|
+
artworkSrc?: string;
|
|
31
|
+
onOpenImageGallery?: () => void;
|
|
32
|
+
onOpenTypography?: () => void;
|
|
33
|
+
children?: ReactNode;
|
|
34
|
+
}) {
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex flex-col gap-6">
|
|
37
|
+
{/* Canvas Editor Area */}
|
|
38
|
+
<div className="bg-background border-2 border-border rounded-lg aspect-square overflow-hidden relative">
|
|
39
|
+
{children || (
|
|
40
|
+
<div className="flex items-center justify-center w-full h-full">
|
|
41
|
+
<div className="text-muted-foreground text-sm">No editor provided</div>
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
{/* Toolbar */}
|
|
47
|
+
<div className="flex items-center justify-center gap-8 py-2">
|
|
48
|
+
<button
|
|
49
|
+
onClick={onOpenImageGallery}
|
|
50
|
+
className="flex flex-col items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
|
51
|
+
aria-label="Open image gallery"
|
|
52
|
+
>
|
|
53
|
+
<div className="w-10 h-10 flex items-center justify-center">
|
|
54
|
+
<ImageIcon className="w-6 h-6" />
|
|
55
|
+
</div>
|
|
56
|
+
</button>
|
|
57
|
+
<button
|
|
58
|
+
onClick={onOpenTypography}
|
|
59
|
+
className="flex flex-col items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
|
60
|
+
aria-label="Add text"
|
|
61
|
+
>
|
|
62
|
+
<div className="w-10 h-10 flex items-center justify-center">
|
|
63
|
+
<TypeIcon className="w-6 h-6" />
|
|
64
|
+
</div>
|
|
65
|
+
</button>
|
|
66
|
+
<button className="flex flex-col items-center gap-2 text-muted-foreground hover:text-foreground transition-colors">
|
|
67
|
+
<div className="w-10 h-10 flex items-center justify-center">
|
|
68
|
+
<ShapesIcon className="w-6 h-6" />
|
|
69
|
+
</div>
|
|
70
|
+
</button>
|
|
71
|
+
<button className="flex flex-col items-center gap-2 text-muted-foreground hover:text-foreground transition-colors">
|
|
72
|
+
<div className="w-10 h-10 flex items-center justify-center">
|
|
73
|
+
<UploadIcon className="w-6 h-6" />
|
|
74
|
+
</div>
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useProduct } from "../patterns/Product";
|
|
3
|
+
|
|
4
|
+
export interface ColorPickerProps {
|
|
5
|
+
/** The placement name this color picker controls */
|
|
6
|
+
placement: string;
|
|
7
|
+
/** Optional className for styling */
|
|
8
|
+
className?: string;
|
|
9
|
+
/** Optional default color if no value is set */
|
|
10
|
+
defaultColor?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const QUICK_COLORS = [
|
|
14
|
+
"#000000", // Black
|
|
15
|
+
"#FFFFFF", // White
|
|
16
|
+
"#FF0000", // Red
|
|
17
|
+
"#00FF00", // Green
|
|
18
|
+
"#0000FF", // Blue
|
|
19
|
+
"#FFFF00", // Yellow
|
|
20
|
+
"#FF00FF", // Magenta
|
|
21
|
+
"#00FFFF", // Cyan
|
|
22
|
+
"#808080", // Gray
|
|
23
|
+
"#FFA500", // Orange
|
|
24
|
+
"#800080", // Purple
|
|
25
|
+
"#008000", // Dark Green
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* ColorPicker - A color selection component for product placements
|
|
30
|
+
*
|
|
31
|
+
* This component provides an intuitive interface for selecting colors for specific
|
|
32
|
+
* product placements (like Crown, Strap, etc.). It integrates with the Product context
|
|
33
|
+
* to automatically save color selections.
|
|
34
|
+
*
|
|
35
|
+
* **Features:**
|
|
36
|
+
* - Large circular color wheel button for easy color selection
|
|
37
|
+
* - Quick color palette for common colors
|
|
38
|
+
* - Displays current color in hex format
|
|
39
|
+
* - Automatically syncs with Product context
|
|
40
|
+
* - Accessible with proper ARIA labels
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```tsx
|
|
44
|
+
* import { Product, ColorPicker } from '@snowcone-app/ui';
|
|
45
|
+
*
|
|
46
|
+
* <Product productId="AR2P3G">
|
|
47
|
+
* <ColorPicker placement="Crown" defaultColor="#ff0000" />
|
|
48
|
+
* </Product>
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function ColorPicker({
|
|
52
|
+
placement,
|
|
53
|
+
className = "",
|
|
54
|
+
defaultColor = "#000000",
|
|
55
|
+
}: ColorPickerProps) {
|
|
56
|
+
const { selection, updateSelection } = useProduct();
|
|
57
|
+
|
|
58
|
+
// Get current color from selection or use default
|
|
59
|
+
const currentColor = (selection?.[placement] as string) || defaultColor;
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
const handleColorChange = (color: string) => {
|
|
63
|
+
updateSelection?.({ [placement]: color });
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const inputId = `color-picker-${placement}`;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className={`flex flex-col gap-4 ${className}`}>
|
|
70
|
+
<div className="flex items-center gap-3">
|
|
71
|
+
{/* Color wheel button */}
|
|
72
|
+
<label
|
|
73
|
+
htmlFor={inputId}
|
|
74
|
+
aria-label={`Select color for ${placement}`}
|
|
75
|
+
className="relative h-12 w-12 cursor-pointer rounded-full"
|
|
76
|
+
style={{
|
|
77
|
+
background: `conic-gradient(from 0deg,
|
|
78
|
+
hsl(0, 100%, 50%),
|
|
79
|
+
hsl(30, 100%, 50%),
|
|
80
|
+
hsl(60, 100%, 50%),
|
|
81
|
+
hsl(90, 100%, 50%),
|
|
82
|
+
hsl(120, 100%, 50%),
|
|
83
|
+
hsl(150, 100%, 50%),
|
|
84
|
+
hsl(180, 100%, 50%),
|
|
85
|
+
hsl(210, 100%, 50%),
|
|
86
|
+
hsl(240, 100%, 50%),
|
|
87
|
+
hsl(270, 100%, 50%),
|
|
88
|
+
hsl(300, 100%, 50%),
|
|
89
|
+
hsl(330, 100%, 50%),
|
|
90
|
+
hsl(360, 100%, 50%))`,
|
|
91
|
+
padding: "3px",
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<div
|
|
95
|
+
className="w-full h-full rounded-full border-2 border-background"
|
|
96
|
+
style={{ backgroundColor: currentColor }}
|
|
97
|
+
/>
|
|
98
|
+
</label>
|
|
99
|
+
|
|
100
|
+
{/* Hidden native color input */}
|
|
101
|
+
<input
|
|
102
|
+
id={inputId}
|
|
103
|
+
className="sr-only"
|
|
104
|
+
type="color"
|
|
105
|
+
value={currentColor}
|
|
106
|
+
onChange={(e) => handleColorChange(e.target.value)}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|