@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,190 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
export interface DragHintAnimationProps {
|
|
4
|
+
/** The position to center the animation */
|
|
5
|
+
position: { x: number; y: number };
|
|
6
|
+
/** Whether the drag direction is horizontal or vertical */
|
|
7
|
+
direction: "horizontal" | "vertical";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* DragHintAnimation - Animated hand gesture hint for draggable elements
|
|
12
|
+
*
|
|
13
|
+
* Displays an animated hand icon that demonstrates drag interaction.
|
|
14
|
+
* Automatically fades in and out with a smooth animation cycle.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* <DragHintAnimation
|
|
19
|
+
* position={{ x: 0, y: 10 }}
|
|
20
|
+
* direction="horizontal"
|
|
21
|
+
* />
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function DragHintAnimation({ position, direction }: DragHintAnimationProps) {
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className="drag-hint-container"
|
|
28
|
+
style={{
|
|
29
|
+
position: "absolute",
|
|
30
|
+
top: "50%",
|
|
31
|
+
left: "50%",
|
|
32
|
+
transform:
|
|
33
|
+
direction === "horizontal"
|
|
34
|
+
? `translate(calc(-50% + ${position.x}px), -50%)`
|
|
35
|
+
: `translate(-50%, calc(-50% + ${position.y}px))`,
|
|
36
|
+
pointerEvents: "none",
|
|
37
|
+
zIndex: 3,
|
|
38
|
+
animation: "fadeInOut 2.2s ease-in-out forwards",
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<style>{`
|
|
42
|
+
@keyframes fadeInOut {
|
|
43
|
+
0% {
|
|
44
|
+
opacity: 0;
|
|
45
|
+
}
|
|
46
|
+
15% {
|
|
47
|
+
opacity: 1;
|
|
48
|
+
}
|
|
49
|
+
65% {
|
|
50
|
+
opacity: 1;
|
|
51
|
+
}
|
|
52
|
+
100% {
|
|
53
|
+
opacity: 0;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@keyframes handAnim {
|
|
58
|
+
0% {
|
|
59
|
+
left: 0;
|
|
60
|
+
opacity: 0;
|
|
61
|
+
}
|
|
62
|
+
10% {
|
|
63
|
+
left: 0;
|
|
64
|
+
opacity: 1;
|
|
65
|
+
}
|
|
66
|
+
35% {
|
|
67
|
+
left: 20px;
|
|
68
|
+
opacity: 1;
|
|
69
|
+
}
|
|
70
|
+
60% {
|
|
71
|
+
left: -20px;
|
|
72
|
+
opacity: 1;
|
|
73
|
+
}
|
|
74
|
+
80% {
|
|
75
|
+
left: 0;
|
|
76
|
+
opacity: 1;
|
|
77
|
+
}
|
|
78
|
+
100% {
|
|
79
|
+
left: 0;
|
|
80
|
+
opacity: 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@keyframes handAnimVertical {
|
|
85
|
+
0% {
|
|
86
|
+
top: 0;
|
|
87
|
+
opacity: 0;
|
|
88
|
+
}
|
|
89
|
+
10% {
|
|
90
|
+
top: 0;
|
|
91
|
+
opacity: 1;
|
|
92
|
+
}
|
|
93
|
+
35% {
|
|
94
|
+
top: 20px;
|
|
95
|
+
opacity: 1;
|
|
96
|
+
}
|
|
97
|
+
60% {
|
|
98
|
+
top: -20px;
|
|
99
|
+
opacity: 1;
|
|
100
|
+
}
|
|
101
|
+
80% {
|
|
102
|
+
top: 0;
|
|
103
|
+
opacity: 1;
|
|
104
|
+
}
|
|
105
|
+
100% {
|
|
106
|
+
top: 0;
|
|
107
|
+
opacity: 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@keyframes circleAnim {
|
|
112
|
+
0% {
|
|
113
|
+
transform: scale(0);
|
|
114
|
+
opacity: 0;
|
|
115
|
+
}
|
|
116
|
+
15% {
|
|
117
|
+
transform: scale(1);
|
|
118
|
+
opacity: 0.5;
|
|
119
|
+
}
|
|
120
|
+
30% {
|
|
121
|
+
transform: scale(1.5);
|
|
122
|
+
opacity: 0;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.hand-icon {
|
|
127
|
+
position: relative;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.hand {
|
|
131
|
+
background: var(--color-background, #fff);
|
|
132
|
+
width: 5px;
|
|
133
|
+
height: 16px;
|
|
134
|
+
border-radius: 20px;
|
|
135
|
+
position: relative;
|
|
136
|
+
left: -5px;
|
|
137
|
+
margin-bottom: 17px;
|
|
138
|
+
transform: rotate(0deg);
|
|
139
|
+
animation: ${
|
|
140
|
+
direction === "horizontal" ? "handAnim" : "handAnimVertical"
|
|
141
|
+
} 2s ease-in-out infinite;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.hand.vertical {
|
|
145
|
+
transform: rotate(90deg);
|
|
146
|
+
animation: handAnimVertical 2s ease-in-out infinite;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.hand:after {
|
|
150
|
+
content: "";
|
|
151
|
+
background: var(--color-background, #fff);
|
|
152
|
+
width: 17px;
|
|
153
|
+
height: 18px;
|
|
154
|
+
border-radius: 4px 8px 38px 15px;
|
|
155
|
+
transform: rotate(6deg) skewY(10deg);
|
|
156
|
+
position: absolute;
|
|
157
|
+
top: 13px;
|
|
158
|
+
left: -1px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.hand:before {
|
|
162
|
+
content: "";
|
|
163
|
+
background: var(--color-background, #fff);
|
|
164
|
+
width: 6px;
|
|
165
|
+
height: 17px;
|
|
166
|
+
border-radius: 2px 40px 20px 20px;
|
|
167
|
+
position: absolute;
|
|
168
|
+
top: 12px;
|
|
169
|
+
left: -6px;
|
|
170
|
+
transform: rotate(-38deg);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.hand .circle {
|
|
174
|
+
background-color: var(--color-background, #fff);
|
|
175
|
+
width: 20px;
|
|
176
|
+
height: 20px;
|
|
177
|
+
border-radius: 50%;
|
|
178
|
+
position: absolute;
|
|
179
|
+
top: -7px;
|
|
180
|
+
left: -7px;
|
|
181
|
+
opacity: 0;
|
|
182
|
+
animation: circleAnim 0.5s ease-out forwards;
|
|
183
|
+
}
|
|
184
|
+
`}</style>
|
|
185
|
+
<div className="hand-icon">
|
|
186
|
+
<div className={`hand ${direction === "vertical" ? "vertical" : ""}`} />
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
export interface EdgeSwipeGuardsProps {
|
|
6
|
+
/** Width of the edge zones in pixels (default: 20) */
|
|
7
|
+
width?: number;
|
|
8
|
+
/** Custom className for the guards */
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* EdgeSwipeGuards - Prevents iOS Safari's back/forward navigation gestures
|
|
14
|
+
* by intercepting horizontal touch-moves that start near the screen edges.
|
|
15
|
+
*
|
|
16
|
+
* Uses document-level touch listeners that check the touch origin position
|
|
17
|
+
* instead of overlay divs. This avoids blocking taps on UI elements (buttons,
|
|
18
|
+
* links) that happen to sit near the edges.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <EdgeSwipeGuards />
|
|
23
|
+
* <EdgeSwipeGuards width={30} />
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function EdgeSwipeGuards({
|
|
27
|
+
width = 20,
|
|
28
|
+
}: EdgeSwipeGuardsProps) {
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
let touchStartX = -1;
|
|
31
|
+
let isEdgeTouch = false;
|
|
32
|
+
|
|
33
|
+
const handleTouchStart = (e: TouchEvent) => {
|
|
34
|
+
const x = e.touches[0].clientX;
|
|
35
|
+
const screenWidth = window.innerWidth;
|
|
36
|
+
isEdgeTouch = x <= width || x >= screenWidth - width;
|
|
37
|
+
touchStartX = x;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const handleTouchMove = (e: TouchEvent) => {
|
|
41
|
+
if (!isEdgeTouch) return;
|
|
42
|
+
const deltaX = Math.abs(e.touches[0].clientX - touchStartX);
|
|
43
|
+
// Only block horizontal swipes, not vertical scrolling
|
|
44
|
+
if (deltaX > 5) {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
document.addEventListener("touchstart", handleTouchStart, { passive: true });
|
|
50
|
+
document.addEventListener("touchmove", handleTouchMove, { passive: false });
|
|
51
|
+
|
|
52
|
+
return () => {
|
|
53
|
+
document.removeEventListener("touchstart", handleTouchStart);
|
|
54
|
+
document.removeEventListener("touchmove", handleTouchMove);
|
|
55
|
+
};
|
|
56
|
+
}, [width]);
|
|
57
|
+
|
|
58
|
+
// No DOM elements needed — this is purely event-based
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
export interface FloatingActionItem {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
children?: React.ReactNode;
|
|
7
|
+
onClick?: (e?: any) => void;
|
|
8
|
+
selected?: boolean;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
variant?: "circular" | "pill";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FloatingActionGroupProps {
|
|
14
|
+
items: FloatingActionItem[];
|
|
15
|
+
className?: string;
|
|
16
|
+
scrollable?: boolean;
|
|
17
|
+
leftAligned?: boolean;
|
|
18
|
+
justify?: "start" | "end" | "center";
|
|
19
|
+
ariaLabel?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* FloatingActionGroup - A primitive component for displaying a horizontal group of action buttons
|
|
24
|
+
*
|
|
25
|
+
* Creates a row of circular or pill-shaped buttons with selection states and disabled states.
|
|
26
|
+
* Commonly used for filters, tabs, or quick actions. Supports horizontal scrolling for
|
|
27
|
+
* overflow scenarios.
|
|
28
|
+
*
|
|
29
|
+
* Features:
|
|
30
|
+
* - Two button variants: circular (icon-only) or pill (text-based)
|
|
31
|
+
* - Visual selection states with background effects
|
|
32
|
+
* - Optional horizontal scrolling with hidden scrollbars
|
|
33
|
+
* - Smart alignment modes (left, center, right)
|
|
34
|
+
* - Accessible with ARIA roles and labels
|
|
35
|
+
* - Dark mode support
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* // Simple button group
|
|
40
|
+
* <FloatingActionGroup
|
|
41
|
+
* items={[
|
|
42
|
+
* { id: '1', label: 'All', selected: true, onClick: () => setFilter('all') },
|
|
43
|
+
* { id: '2', label: 'Active', onClick: () => setFilter('active') },
|
|
44
|
+
* { id: '3', label: 'Done', onClick: () => setFilter('done') }
|
|
45
|
+
* ]}
|
|
46
|
+
* ariaLabel="Filter tasks"
|
|
47
|
+
* />
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```tsx
|
|
52
|
+
* // Scrollable with custom icons
|
|
53
|
+
* <FloatingActionGroup
|
|
54
|
+
* scrollable={true}
|
|
55
|
+
* leftAligned={true}
|
|
56
|
+
* items={[
|
|
57
|
+
* { id: 'edit', label: 'Edit', variant: 'circular', children: <PencilIcon /> },
|
|
58
|
+
* { id: 'delete', label: 'Delete', variant: 'circular', children: <TrashIcon />, disabled: true }
|
|
59
|
+
* ]}
|
|
60
|
+
* />
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* @param items - Array of action items to display
|
|
64
|
+
* @param className - Additional CSS classes
|
|
65
|
+
* @param scrollable - Enable horizontal scrolling for overflow (default: false)
|
|
66
|
+
* @param leftAligned - Add left padding in scrollable mode (default: false)
|
|
67
|
+
* @param justify - Horizontal alignment ("start", "end", or "center", default: "start")
|
|
68
|
+
* @param ariaLabel - Accessible label for the button group
|
|
69
|
+
*/
|
|
70
|
+
export function FloatingActionGroup({
|
|
71
|
+
items,
|
|
72
|
+
className = "",
|
|
73
|
+
scrollable = false,
|
|
74
|
+
leftAligned = false,
|
|
75
|
+
justify = "start",
|
|
76
|
+
ariaLabel,
|
|
77
|
+
}: FloatingActionGroupProps) {
|
|
78
|
+
const baseContainerClass = "flex gap-2";
|
|
79
|
+
|
|
80
|
+
const containerClass = scrollable
|
|
81
|
+
? `${baseContainerClass} overflow-x-auto scrollbar-hide ${
|
|
82
|
+
leftAligned ? "pl-6" : ""
|
|
83
|
+
}`
|
|
84
|
+
: baseContainerClass;
|
|
85
|
+
|
|
86
|
+
const justifyClass = {
|
|
87
|
+
start: "justify-start",
|
|
88
|
+
end: "justify-end",
|
|
89
|
+
center: "justify-center",
|
|
90
|
+
}[justify];
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
className={`${containerClass} ${justifyClass} ${className}`}
|
|
95
|
+
role="group"
|
|
96
|
+
aria-label={ariaLabel}
|
|
97
|
+
>
|
|
98
|
+
{items.map((item, index) => (
|
|
99
|
+
<FloatingActionButton
|
|
100
|
+
key={item.id}
|
|
101
|
+
{...item}
|
|
102
|
+
isFirstButton={index === 0}
|
|
103
|
+
leftAligned={leftAligned}
|
|
104
|
+
scrollable={scrollable}
|
|
105
|
+
/>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function FloatingActionButton({
|
|
112
|
+
label,
|
|
113
|
+
children,
|
|
114
|
+
onClick,
|
|
115
|
+
selected = false,
|
|
116
|
+
disabled = false,
|
|
117
|
+
variant = "circular",
|
|
118
|
+
isFirstButton = false,
|
|
119
|
+
leftAligned = false,
|
|
120
|
+
scrollable = false,
|
|
121
|
+
}: FloatingActionItem & {
|
|
122
|
+
isFirstButton?: boolean;
|
|
123
|
+
leftAligned?: boolean;
|
|
124
|
+
scrollable?: boolean;
|
|
125
|
+
}) {
|
|
126
|
+
const baseButtonClass =
|
|
127
|
+
"flex items-center justify-center transition-all text-sm font-medium";
|
|
128
|
+
|
|
129
|
+
// Use rounded-fixed-full for circular to ensure it stays circular regardless of theme
|
|
130
|
+
// (e.g., brutalist themes set rounded-full to 0)
|
|
131
|
+
const shapeClass = variant === "circular" ? "rounded-fixed-full" : "rounded-button";
|
|
132
|
+
|
|
133
|
+
// When children are provided, don't apply padding/border - let the children define the size
|
|
134
|
+
const variantClass = children
|
|
135
|
+
? ""
|
|
136
|
+
: variant === "circular"
|
|
137
|
+
? "w-10 h-10"
|
|
138
|
+
: "px-4 py-2 border-2";
|
|
139
|
+
|
|
140
|
+
// When children are provided, don't apply background/border/opacity styles - let children control appearance
|
|
141
|
+
const stateClass = children
|
|
142
|
+
? ""
|
|
143
|
+
: selected
|
|
144
|
+
? "bg-background text-foreground border-primary"
|
|
145
|
+
: "bg-background text-foreground border-border/30 hover:border-border/60 opacity-60 hover:opacity-75";
|
|
146
|
+
|
|
147
|
+
const disabledClass = disabled
|
|
148
|
+
? "opacity-50 cursor-not-allowed"
|
|
149
|
+
: "cursor-pointer";
|
|
150
|
+
|
|
151
|
+
// Smart alignment: add margin when first button is selected in left-aligned scrollable mode
|
|
152
|
+
const marginClass =
|
|
153
|
+
leftAligned && scrollable && isFirstButton && selected ? "ml-4" : "";
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
onClick={disabled ? undefined : (e) => onClick?.(e)}
|
|
159
|
+
className={`${baseButtonClass} ${shapeClass} ${variantClass} ${stateClass} ${disabledClass} ${marginClass}`}
|
|
160
|
+
aria-label={label}
|
|
161
|
+
aria-pressed={selected}
|
|
162
|
+
disabled={disabled}
|
|
163
|
+
>
|
|
164
|
+
{children ? (
|
|
165
|
+
children
|
|
166
|
+
) : (
|
|
167
|
+
<span className="relative">
|
|
168
|
+
{label}
|
|
169
|
+
{disabled && (
|
|
170
|
+
<span className="absolute inset-0 top-1/2 -translate-y-1/2 h-px bg-border opacity-50 pointer-events-none" aria-hidden="true" />
|
|
171
|
+
)}
|
|
172
|
+
</span>
|
|
173
|
+
)}
|
|
174
|
+
</button>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
describeProductPrice,
|
|
4
|
+
formatPrice,
|
|
5
|
+
type ProductPriceOptions,
|
|
6
|
+
} from "@snowcone-app/sdk";
|
|
7
|
+
import { useProduct } from "../patterns/Product";
|
|
8
|
+
|
|
9
|
+
export interface ProductPriceProps extends ProductPriceOptions {
|
|
10
|
+
contextPrice?: number;
|
|
11
|
+
showCents?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ProductPrice - Formatted price display with currency and locale support
|
|
16
|
+
*
|
|
17
|
+
* A primitive component for displaying product prices with automatic currency
|
|
18
|
+
* formatting, smart cents display, and seamless Product context integration.
|
|
19
|
+
* Uses semantic HTML for machine-readable price data.
|
|
20
|
+
*
|
|
21
|
+
* Features:
|
|
22
|
+
* - Automatic currency formatting based on locale
|
|
23
|
+
* - Smart cents display (hides .00, shows actual cents)
|
|
24
|
+
* - Semantic HTML using `<data>` element with value attribute
|
|
25
|
+
* - Accessible with ARIA labels for screen readers
|
|
26
|
+
* - Works standalone or within Product context
|
|
27
|
+
* - Automatically uses currentPrice from context (reflects variant selection)
|
|
28
|
+
* - Supports international currencies and locales
|
|
29
|
+
* - Machine-readable price in value attribute for SEO/scrapers
|
|
30
|
+
*
|
|
31
|
+
* **Price Format:**
|
|
32
|
+
* - Prices are stored in cents to avoid floating point issues
|
|
33
|
+
* - Example: 2999 cents = $29.99
|
|
34
|
+
* - Automatically converts to display format
|
|
35
|
+
*
|
|
36
|
+
* **Context Integration:**
|
|
37
|
+
* - When used in `Product` context, automatically shows current price
|
|
38
|
+
* - Updates when user selects different variants
|
|
39
|
+
* - Falls back to prop value when used standalone
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```tsx
|
|
43
|
+
* // Standalone usage with explicit price
|
|
44
|
+
* <ProductPrice price={2999} showCents={true} />
|
|
45
|
+
* // Output: $29.99
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```tsx
|
|
50
|
+
* // Within Product context (automatically uses current price)
|
|
51
|
+
* <Product productId="shirt-123">
|
|
52
|
+
* <ProductOptions />
|
|
53
|
+
* <ProductPrice showCents={false} />
|
|
54
|
+
* </Product>
|
|
55
|
+
* // Output: $29 (or $35 if user selects a pricier variant)
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```tsx
|
|
60
|
+
* // International currency and locale
|
|
61
|
+
* <ProductPrice
|
|
62
|
+
* price={2999}
|
|
63
|
+
* currency="EUR"
|
|
64
|
+
* locale="de-DE"
|
|
65
|
+
* showCurrency={true}
|
|
66
|
+
* />
|
|
67
|
+
* // Output: €29,99
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```tsx
|
|
72
|
+
* // Custom styling for sale prices
|
|
73
|
+
* <div className="flex items-center gap-2">
|
|
74
|
+
* <ProductPrice
|
|
75
|
+
* price={1999}
|
|
76
|
+
* className="text-2xl font-bold text-green-600"
|
|
77
|
+
* />
|
|
78
|
+
* <ProductPrice
|
|
79
|
+
* price={2999}
|
|
80
|
+
* showCents={false}
|
|
81
|
+
* className="text-sm line-through text-gray-400"
|
|
82
|
+
* />
|
|
83
|
+
* </div>
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* @param price - Price in cents (e.g., 2999 = $29.99). Optional if used within Product context
|
|
87
|
+
* @param currency - ISO 4217 currency code (default: "USD")
|
|
88
|
+
* @param locale - BCP 47 locale string for formatting (default: "en-US")
|
|
89
|
+
* @param showCurrency - Display full currency symbol (default: false, shows $ only)
|
|
90
|
+
* @param showCents - Display cents portion (default: true, hides if .00)
|
|
91
|
+
* @param className - Additional CSS classes for styling
|
|
92
|
+
* @param contextPrice - Internal: Override context price (used by Product component)
|
|
93
|
+
*/
|
|
94
|
+
export function ProductPrice({
|
|
95
|
+
price,
|
|
96
|
+
currency = "USD",
|
|
97
|
+
locale = "en-US",
|
|
98
|
+
showCurrency = false,
|
|
99
|
+
className,
|
|
100
|
+
contextPrice,
|
|
101
|
+
showCents = true,
|
|
102
|
+
}: ProductPriceProps) {
|
|
103
|
+
// Try to get price from context
|
|
104
|
+
let priceValue = contextPrice || price;
|
|
105
|
+
if (!priceValue) {
|
|
106
|
+
try {
|
|
107
|
+
const context = useProduct();
|
|
108
|
+
// Use the currentPrice from context which is already calculated
|
|
109
|
+
priceValue = context.currentPrice || context.product?.price;
|
|
110
|
+
} catch {
|
|
111
|
+
// Not in a Product context, that's OK
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const descriptor = describeProductPrice(
|
|
116
|
+
{ price, currency, locale, showCurrency, className },
|
|
117
|
+
priceValue
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
if (!descriptor) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Convert cents to dollars for display
|
|
125
|
+
const dollars = Math.floor(descriptor.price / 100);
|
|
126
|
+
const cents = descriptor.price % 100;
|
|
127
|
+
|
|
128
|
+
// Format with currency symbol if needed
|
|
129
|
+
const currencySymbol = showCurrency
|
|
130
|
+
? new Intl.NumberFormat(descriptor.locale, {
|
|
131
|
+
style: "currency",
|
|
132
|
+
currency: descriptor.currency,
|
|
133
|
+
})
|
|
134
|
+
.format(0)
|
|
135
|
+
.replace(/[\d.,\s]/g, "")
|
|
136
|
+
: "$";
|
|
137
|
+
|
|
138
|
+
// Apply font-display only when no custom className is provided.
|
|
139
|
+
// When consumers pass className, they own the typography.
|
|
140
|
+
const priceClassName = descriptor.className || "font-display";
|
|
141
|
+
|
|
142
|
+
// If showing cents and there are cents to show
|
|
143
|
+
if (showCents && cents > 0) {
|
|
144
|
+
return (
|
|
145
|
+
<data
|
|
146
|
+
value={descriptor.price / 100}
|
|
147
|
+
className={priceClassName}
|
|
148
|
+
aria-label={`Price: ${currencySymbol}${dollars}.${cents.toString().padStart(2, "0")}`}
|
|
149
|
+
>
|
|
150
|
+
<span aria-hidden="true">
|
|
151
|
+
{currencySymbol}
|
|
152
|
+
{dollars}.{cents.toString().padStart(2, "0")}
|
|
153
|
+
</span>
|
|
154
|
+
</data>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Just show dollars
|
|
159
|
+
return (
|
|
160
|
+
<data
|
|
161
|
+
value={descriptor.price / 100}
|
|
162
|
+
className={priceClassName}
|
|
163
|
+
aria-label={`Price: ${currencySymbol}${dollars}`}
|
|
164
|
+
>
|
|
165
|
+
<span aria-hidden="true">
|
|
166
|
+
{currencySymbol}
|
|
167
|
+
{dollars}
|
|
168
|
+
</span>
|
|
169
|
+
</data>
|
|
170
|
+
);
|
|
171
|
+
}
|