@shopify/shop-minis-react 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/_virtual/index10.js +2 -2
  2. package/dist/_virtual/index5.js +2 -3
  3. package/dist/_virtual/index5.js.map +1 -1
  4. package/dist/_virtual/index6.js +3 -2
  5. package/dist/_virtual/index6.js.map +1 -1
  6. package/dist/_virtual/index7.js +2 -3
  7. package/dist/_virtual/index7.js.map +1 -1
  8. package/dist/_virtual/index8.js +3 -2
  9. package/dist/_virtual/index8.js.map +1 -1
  10. package/dist/_virtual/index9.js +2 -2
  11. package/dist/components/atoms/list.js +106 -41
  12. package/dist/components/atoms/list.js.map +1 -1
  13. package/dist/components/commerce/add-to-cart.js +82 -0
  14. package/dist/components/commerce/add-to-cart.js.map +1 -0
  15. package/dist/components/{atoms → commerce}/favorite-button.js +1 -1
  16. package/dist/components/commerce/favorite-button.js.map +1 -0
  17. package/dist/components/commerce/product-card.js +10 -10
  18. package/dist/components/commerce/product-card.js.map +1 -1
  19. package/dist/components/commerce/product-link.js +6 -6
  20. package/dist/components/commerce/product-link.js.map +1 -1
  21. package/dist/index.js +276 -274
  22. package/dist/index.js.map +1 -1
  23. package/dist/internal/components/refresh-indicator.js +83 -0
  24. package/dist/internal/components/refresh-indicator.js.map +1 -0
  25. package/dist/internal/usePullToRefresh.js +149 -0
  26. package/dist/internal/usePullToRefresh.js.map +1 -0
  27. package/dist/internal/utils/virtuoso-dom.js +20 -0
  28. package/dist/internal/utils/virtuoso-dom.js.map +1 -0
  29. package/dist/shop-minis-react/node_modules/.pnpm/@radix-ui_react-use-is-hydrated@0.1.0_@types_react@19.1.6_react@19.1.0/node_modules/@radix-ui/react-use-is-hydrated/dist/index.js +1 -1
  30. package/dist/shop-minis-react/node_modules/.pnpm/@videojs_xhr@2.7.0/node_modules/@videojs/xhr/lib/index.js +1 -1
  31. package/dist/shop-minis-react/node_modules/.pnpm/@xmldom_xmldom@0.8.10/node_modules/@xmldom/xmldom/lib/index.js +1 -1
  32. package/dist/shop-minis-react/node_modules/.pnpm/color-string@1.9.1/node_modules/color-string/index.js +1 -1
  33. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/PopChild.js +55 -0
  34. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/PopChild.js.map +1 -0
  35. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/PresenceChild.js +35 -0
  36. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/PresenceChild.js.map +1 -0
  37. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/index.js +46 -0
  38. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/index.js.map +1 -0
  39. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/utils.js +13 -0
  40. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/utils.js.map +1 -0
  41. package/dist/shop-minis-react/node_modules/.pnpm/mpd-parser@1.3.1/node_modules/mpd-parser/dist/mpd-parser.es.js +1 -1
  42. package/dist/shop-minis-react/node_modules/.pnpm/simple-swizzle@0.2.2/node_modules/simple-swizzle/index.js +1 -1
  43. package/dist/shop-minis-react/node_modules/.pnpm/use-sync-external-store@1.5.0_react@19.1.0/node_modules/use-sync-external-store/shim/index.js +1 -1
  44. package/package.json +1 -1
  45. package/src/components/atoms/list.tsx +97 -12
  46. package/src/components/commerce/add-to-cart.test.tsx +73 -0
  47. package/src/components/commerce/add-to-cart.tsx +132 -0
  48. package/src/components/{atoms → commerce}/favorite-button.tsx +1 -1
  49. package/src/components/commerce/product-card.tsx +2 -1
  50. package/src/components/commerce/product-link.tsx +2 -1
  51. package/src/components/index.ts +2 -1
  52. package/src/internal/components/refresh-indicator.tsx +103 -0
  53. package/src/internal/usePullToRefresh.ts +286 -0
  54. package/src/internal/utils/virtuoso-dom.ts +26 -0
  55. package/src/stories/AddToCart.stories.tsx +186 -0
  56. package/src/stories/FavoriteButton.stories.tsx +2 -2
  57. package/src/stories/PullToRefreshList.stories.tsx +122 -0
  58. package/src/styles/animations.css +54 -0
  59. package/dist/components/atoms/favorite-button.js.map +0 -1
  60. /package/src/components/{atoms → commerce}/favorite-button.test.tsx +0 -0
@@ -0,0 +1,286 @@
1
+ import {useCallback, useEffect, useRef, useState} from 'react'
2
+
3
+ const DEFAULT_REFRESH_PULL_THRESHOLD = 200
4
+ const ANIMATION_DURATION = 400
5
+
6
+ export interface UsePullToRefreshOptions {
7
+ onRefresh?: () => Promise<void>
8
+ threshold?: number
9
+ indicatorThreshold?: number
10
+ enabled?: boolean
11
+ }
12
+
13
+ export interface PullToRefreshState {
14
+ isPulling: boolean
15
+ pullDistance: number
16
+ canRefresh: boolean
17
+ }
18
+
19
+ export function usePullToRefresh({
20
+ onRefresh,
21
+ threshold = DEFAULT_REFRESH_PULL_THRESHOLD,
22
+ indicatorThreshold = 0,
23
+ enabled = false,
24
+ }: UsePullToRefreshOptions) {
25
+ const [state, setState] = useState<PullToRefreshState>({
26
+ isPulling: false,
27
+ pullDistance: 0,
28
+ canRefresh: false,
29
+ })
30
+
31
+ const startY = useRef(0)
32
+ const currentY = useRef(0)
33
+ const containerRef = useRef<HTMLElement | null>(null)
34
+ const animationRef = useRef<number | null>(null)
35
+ const isRefreshingRef = useRef(false)
36
+
37
+ const handleTouchStart = useCallback(
38
+ (event: TouchEvent) => {
39
+ if (!enabled || !containerRef.current || isRefreshingRef.current) return
40
+
41
+ if (animationRef.current) {
42
+ cancelAnimationFrame(animationRef.current)
43
+ animationRef.current = null
44
+ }
45
+
46
+ setState(prev => {
47
+ const container = containerRef.current
48
+ if (!container) return prev
49
+
50
+ const isAtTop = container.scrollTop <= 0
51
+
52
+ if (isAtTop) {
53
+ const touch = event.touches[0]
54
+ startY.current = touch.clientY
55
+ } else {
56
+ startY.current = 0
57
+ return {
58
+ ...prev,
59
+ isPulling: false,
60
+ pullDistance: 0,
61
+ canRefresh: false,
62
+ }
63
+ }
64
+
65
+ return prev
66
+ })
67
+ },
68
+ [enabled]
69
+ )
70
+
71
+ const handleMove = useCallback(
72
+ (clientY: number, preventDefault?: () => void) => {
73
+ if (!enabled || !containerRef.current || startY.current === 0) {
74
+ return
75
+ }
76
+
77
+ setState(prev => {
78
+ if (isRefreshingRef.current) {
79
+ return prev
80
+ }
81
+
82
+ const container = containerRef.current
83
+ if (!container) return prev
84
+
85
+ const isAtTop = container.scrollTop <= 0
86
+ currentY.current = clientY
87
+ const deltaY = currentY.current - startY.current
88
+ const isValidPull = isAtTop && deltaY > 0
89
+
90
+ if (isValidPull) {
91
+ const pullDistance = deltaY
92
+ const shouldShowIndicator = pullDistance >= indicatorThreshold
93
+
94
+ if (shouldShowIndicator && preventDefault) {
95
+ preventDefault()
96
+ }
97
+
98
+ if (shouldShowIndicator || prev.isPulling) {
99
+ const elasticDistance =
100
+ pullDistance > threshold
101
+ ? threshold + (pullDistance - threshold) * 0.5
102
+ : pullDistance
103
+
104
+ return {
105
+ ...prev,
106
+ isPulling: shouldShowIndicator,
107
+ pullDistance: elasticDistance,
108
+ canRefresh: pullDistance >= threshold,
109
+ }
110
+ }
111
+
112
+ return prev
113
+ } else if (prev.isPulling) {
114
+ return {
115
+ ...prev,
116
+ isPulling: false,
117
+ pullDistance: 0,
118
+ canRefresh: false,
119
+ }
120
+ }
121
+
122
+ return prev
123
+ })
124
+ },
125
+ [threshold, indicatorThreshold, enabled]
126
+ )
127
+
128
+ const handleTouchMove = useCallback(
129
+ (event: TouchEvent) => {
130
+ if (isRefreshingRef.current) return
131
+
132
+ const touch = event.touches[0]
133
+ if (!touch) return
134
+
135
+ if (startY.current !== 0) {
136
+ handleMove(touch.clientY, () => event.preventDefault())
137
+ }
138
+ },
139
+ [handleMove]
140
+ )
141
+
142
+ const resetRefreshingState = useCallback(() => {
143
+ isRefreshingRef.current = false
144
+ }, [])
145
+
146
+ const animatePullDistanceToZero = useCallback((fromDistance?: number) => {
147
+ if (animationRef.current) {
148
+ cancelAnimationFrame(animationRef.current)
149
+ animationRef.current = null
150
+ }
151
+
152
+ let startDistance = fromDistance
153
+ if (startDistance === undefined) {
154
+ setState(prev => {
155
+ startDistance = prev.pullDistance
156
+ return prev
157
+ })
158
+ }
159
+
160
+ if (!startDistance || startDistance <= 0) {
161
+ setState(prev => ({
162
+ ...prev,
163
+ pullDistance: 0,
164
+ canRefresh: false,
165
+ isPulling: false,
166
+ }))
167
+ return
168
+ }
169
+
170
+ const duration = ANIMATION_DURATION
171
+ const startTime = Date.now()
172
+
173
+ const animate = () => {
174
+ const elapsed = Date.now() - startTime
175
+ const progress = Math.min(elapsed / duration, 1)
176
+
177
+ const easeOut = 1 - (1 - progress) ** 3
178
+ const currentDistance = startDistance! * (1 - easeOut)
179
+
180
+ setState(prev => ({
181
+ ...prev,
182
+ pullDistance: currentDistance,
183
+ canRefresh: progress < 1 ? prev.canRefresh : false,
184
+ }))
185
+
186
+ if (progress < 1) {
187
+ animationRef.current = requestAnimationFrame(animate)
188
+ } else {
189
+ animationRef.current = null
190
+ setState(prev => ({
191
+ ...prev,
192
+ pullDistance: 0,
193
+ canRefresh: false,
194
+ isPulling: false,
195
+ }))
196
+ }
197
+ }
198
+
199
+ animationRef.current = requestAnimationFrame(animate)
200
+ }, [])
201
+
202
+ const handleEnd = useCallback(async () => {
203
+ if (isRefreshingRef.current) {
204
+ startY.current = 0
205
+ return
206
+ }
207
+
208
+ startY.current = 0
209
+
210
+ if (animationRef.current) {
211
+ cancelAnimationFrame(animationRef.current)
212
+ animationRef.current = null
213
+ }
214
+
215
+ let shouldRefresh = false
216
+ let currentPullDistance = 0
217
+ const wasRefreshing = isRefreshingRef.current
218
+
219
+ setState(prev => {
220
+ shouldRefresh = prev.canRefresh && !wasRefreshing
221
+ currentPullDistance = prev.pullDistance
222
+
223
+ return {
224
+ ...prev,
225
+ isPulling: false,
226
+ }
227
+ })
228
+
229
+ if (currentPullDistance <= 0) {
230
+ setState(prev => ({
231
+ ...prev,
232
+ pullDistance: 0,
233
+ canRefresh: false,
234
+ }))
235
+ return
236
+ }
237
+
238
+ if (shouldRefresh && onRefresh) {
239
+ isRefreshingRef.current = true
240
+ try {
241
+ await onRefresh()
242
+ } catch (error) {
243
+ console.error('Pull to refresh failed:', error)
244
+ }
245
+ resetRefreshingState()
246
+ }
247
+
248
+ animatePullDistanceToZero(currentPullDistance)
249
+ }, [onRefresh, animatePullDistanceToZero, resetRefreshingState])
250
+
251
+ const bindToElement = useCallback(
252
+ (element: HTMLElement | null) => {
253
+ if (!element) return
254
+
255
+ containerRef.current = element
256
+
257
+ element.addEventListener('touchstart', handleTouchStart, {passive: false})
258
+ element.addEventListener('touchmove', handleTouchMove, {passive: false})
259
+ element.addEventListener('touchend', handleEnd)
260
+ element.addEventListener('touchcancel', handleEnd)
261
+
262
+ return () => {
263
+ element.removeEventListener('touchstart', handleTouchStart)
264
+ element.removeEventListener('touchmove', handleTouchMove)
265
+ element.removeEventListener('touchend', handleEnd)
266
+ element.removeEventListener('touchcancel', handleEnd)
267
+ }
268
+ },
269
+ [handleTouchStart, handleTouchMove, handleEnd]
270
+ )
271
+
272
+ useEffect(() => {
273
+ return () => {
274
+ if (animationRef.current) {
275
+ cancelAnimationFrame(animationRef.current)
276
+ animationRef.current = null
277
+ }
278
+ }
279
+ }, [])
280
+
281
+ return {
282
+ state,
283
+ bindToElement,
284
+ containerRef,
285
+ }
286
+ }
@@ -0,0 +1,26 @@
1
+ // For finding the scrollable element within a container for pull-to-refresh functionality.
2
+
3
+ export const findVirtuosoScrollableElement = (
4
+ container: HTMLElement
5
+ ): HTMLElement => {
6
+ const selector = '[data-virtuoso-scroller]'
7
+
8
+ let scrollableElement: HTMLElement | null = null
9
+
10
+ scrollableElement = container.querySelector(selector) as HTMLElement
11
+
12
+ if (scrollableElement) return scrollableElement
13
+
14
+ if (!scrollableElement) {
15
+ const allDivs = Array.from(container.querySelectorAll('div'))
16
+ for (const div of allDivs) {
17
+ const style = window.getComputedStyle(div)
18
+ if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
19
+ scrollableElement = div as HTMLElement
20
+ break
21
+ }
22
+ }
23
+ }
24
+
25
+ return scrollableElement || container
26
+ }
@@ -0,0 +1,186 @@
1
+ import {AddToCartButton} from '../components/commerce/add-to-cart'
2
+
3
+ import type {Meta, StoryObj} from '@storybook/react-vite'
4
+
5
+ const meta = {
6
+ title: 'Commerce/AddToCartButton',
7
+ component: AddToCartButton,
8
+ parameters: {},
9
+ tags: ['autodocs'],
10
+ argTypes: {
11
+ size: {
12
+ control: 'radio',
13
+ options: ['sm', 'default', 'lg'],
14
+ description: 'Button size',
15
+ },
16
+ disabled: {
17
+ control: 'boolean',
18
+ description: 'Whether the button is disabled',
19
+ },
20
+ className: {
21
+ control: 'text',
22
+ description: 'Additional CSS classes',
23
+ },
24
+ productId: {
25
+ control: 'text',
26
+ description: 'The GID of the product (e.g. gid://shopify/Product/123)',
27
+ },
28
+ productVariantId: {
29
+ control: 'text',
30
+ description:
31
+ 'The GID of the product variant (e.g. gid://shopify/ProductVariant/456)',
32
+ },
33
+ discountCodes: {
34
+ control: 'object',
35
+ description: 'Array of discount codes to apply',
36
+ },
37
+ },
38
+ args: {
39
+ disabled: false,
40
+ size: 'default',
41
+ productId: 'gid://shopify/Product/123',
42
+ productVariantId: 'gid://shopify/ProductVariant/456',
43
+ },
44
+ } satisfies Meta<typeof AddToCartButton>
45
+
46
+ export default meta
47
+ type Story = StoryObj<typeof meta>
48
+
49
+ export const Default: Story = {
50
+ args: {},
51
+ render: args => {
52
+ return <AddToCartButton {...args} />
53
+ },
54
+ }
55
+
56
+ export const SmallSize: Story = {
57
+ args: {
58
+ size: 'sm',
59
+ },
60
+ render: args => {
61
+ return <AddToCartButton {...args} />
62
+ },
63
+ }
64
+
65
+ export const LargeSize: Story = {
66
+ args: {
67
+ size: 'lg',
68
+ },
69
+ render: args => {
70
+ return <AddToCartButton {...args} />
71
+ },
72
+ }
73
+
74
+ export const WithDiscountCodes: Story = {
75
+ args: {
76
+ discountCodes: ['SUMMER20', 'FREESHIP'],
77
+ },
78
+ render: args => {
79
+ return (
80
+ <div className="space-y-2">
81
+ <p className="text-sm text-gray-600">
82
+ This button will apply discount codes:{' '}
83
+ {args.discountCodes?.join(', ')}
84
+ </p>
85
+ <AddToCartButton {...args} />
86
+ </div>
87
+ )
88
+ },
89
+ }
90
+
91
+ export const Disabled: Story = {
92
+ args: {
93
+ disabled: true,
94
+ },
95
+ render: args => {
96
+ return <AddToCartButton {...args} />
97
+ },
98
+ }
99
+
100
+ export const CustomStyling: Story = {
101
+ args: {
102
+ className: 'bg-purple-600 hover:bg-purple-700 text-white rounded-full px-8',
103
+ },
104
+ render: args => {
105
+ return (
106
+ <div className="space-y-2">
107
+ <p className="text-sm text-gray-600">
108
+ Custom styled button with additional classes
109
+ </p>
110
+ <AddToCartButton {...args} />
111
+ </div>
112
+ )
113
+ },
114
+ }
115
+
116
+ export const AllSizes: Story = {
117
+ render: () => {
118
+ return (
119
+ <div className="flex flex-col items-center space-y-4">
120
+ <h3 className="text-lg font-semibold">Button Sizes</h3>
121
+ <div className="flex gap-4 items-center">
122
+ <AddToCartButton
123
+ productId="gid://shopify/Product/123"
124
+ productVariantId="gid://shopify/ProductVariant/456"
125
+ size="sm"
126
+ />
127
+ <AddToCartButton
128
+ productId="gid://shopify/Product/123"
129
+ productVariantId="gid://shopify/ProductVariant/456"
130
+ size="default"
131
+ />
132
+ <AddToCartButton
133
+ productId="gid://shopify/Product/123"
134
+ productVariantId="gid://shopify/ProductVariant/456"
135
+ size="lg"
136
+ />
137
+ </div>
138
+ </div>
139
+ )
140
+ },
141
+ }
142
+
143
+ export const InteractiveExample: Story = {
144
+ render: () => {
145
+ return (
146
+ <div className="flex flex-col items-center space-y-6 p-8">
147
+ <div className="text-center">
148
+ <h3 className="text-lg font-semibold">Interactive Demo</h3>
149
+ <p className="text-sm text-gray-600 mt-2">
150
+ Click the buttons below to see the add to cart animation
151
+ </p>
152
+ </div>
153
+
154
+ <div className="grid gap-4">
155
+ <div className="text-center">
156
+ <p className="text-xs text-gray-500 mb-2">Product A</p>
157
+ <AddToCartButton
158
+ productId="gid://shopify/Product/111"
159
+ productVariantId="gid://shopify/ProductVariant/111"
160
+ />
161
+ </div>
162
+
163
+ <div className="text-center">
164
+ <p className="text-xs text-gray-500 mb-2">
165
+ Product B (with discount)
166
+ </p>
167
+ <AddToCartButton
168
+ productId="gid://shopify/Product/222"
169
+ productVariantId="gid://shopify/ProductVariant/222"
170
+ discountCodes={['SAVE10']}
171
+ />
172
+ </div>
173
+
174
+ <div className="text-center">
175
+ <p className="text-xs text-gray-500 mb-2">Product C (small size)</p>
176
+ <AddToCartButton
177
+ productId="gid://shopify/Product/333"
178
+ productVariantId="gid://shopify/ProductVariant/333"
179
+ size="sm"
180
+ />
181
+ </div>
182
+ </div>
183
+ </div>
184
+ )
185
+ },
186
+ }
@@ -1,11 +1,11 @@
1
1
  import {fn} from 'storybook/test'
2
2
 
3
- import {FavoriteButton} from '../components/atoms/favorite-button'
3
+ import {FavoriteButton} from '../components/commerce/favorite-button'
4
4
 
5
5
  import type {Meta, StoryObj} from '@storybook/react-vite'
6
6
 
7
7
  const meta = {
8
- title: 'Atoms/FavoriteButton',
8
+ title: 'Commerce/FavoriteButton',
9
9
  component: FavoriteButton,
10
10
  parameters: {},
11
11
  tags: ['autodocs'],
@@ -0,0 +1,122 @@
1
+ import {useState, useCallback} from 'react'
2
+
3
+ import {fn} from 'storybook/test'
4
+
5
+ import {List} from '../components/atoms/list'
6
+ import {ProductCard} from '../components/commerce/product-card'
7
+ import {ProductLink} from '../components/commerce/product-link'
8
+ import {createProduct, injectMocks} from '../mocks'
9
+
10
+ import type {Product} from '@shopify/shop-minis-platform'
11
+ import type {Meta, StoryObj} from '@storybook/react-vite'
12
+
13
+ const meta = {
14
+ title: 'Atoms/PullToRefreshList',
15
+ component: List as any,
16
+ parameters: {
17
+ layout: 'padded',
18
+ },
19
+ argTypes: {
20
+ height: {
21
+ control: 'number',
22
+ description: 'Height of the virtualized list container',
23
+ },
24
+ enablePullToRefresh: {
25
+ control: 'boolean',
26
+ description: 'Enable pull-to-refresh functionality',
27
+ defaultValue: true,
28
+ },
29
+ },
30
+ tags: ['autodocs'],
31
+ } satisfies Meta<typeof List>
32
+
33
+ export default meta
34
+ type Story = StoryObj<typeof meta>
35
+
36
+ injectMocks()
37
+
38
+ const createProductList = (count: number) => {
39
+ return Array.from({length: count}, (_, index) =>
40
+ createProduct(`product-${index + 1}`, `Product ${index + 1}`, '9.99')
41
+ )
42
+ }
43
+
44
+ const PullToRefreshListWrapper = ({
45
+ initialItems,
46
+ renderItem,
47
+ ...props
48
+ }: any) => {
49
+ const [items, setItems] = useState(initialItems)
50
+ const [refreshing, setRefreshing] = useState(false)
51
+
52
+ const handleRefresh = useCallback(async () => {
53
+ setRefreshing(true)
54
+
55
+ await new Promise(resolve => setTimeout(resolve, 2000))
56
+
57
+ const newItems = Array.from({length: 5}, (_, index) =>
58
+ createProduct(
59
+ `fresh-${Date.now()}-${index}`,
60
+ `Fresh Product ${index + 1}`,
61
+ '12.99'
62
+ )
63
+ )
64
+
65
+ setItems([...newItems, ...initialItems])
66
+ setRefreshing(false)
67
+ }, [initialItems])
68
+
69
+ return (
70
+ <List
71
+ {...props}
72
+ items={items}
73
+ renderItem={renderItem}
74
+ onRefresh={handleRefresh}
75
+ refreshing={refreshing}
76
+ enablePullToRefresh
77
+ />
78
+ )
79
+ }
80
+
81
+ const ProductLinkWithRefreshComponent = (args: any) => (
82
+ <PullToRefreshListWrapper
83
+ {...args}
84
+ initialItems={createProductList(20)}
85
+ renderItem={(product: Product) => (
86
+ <div className="p-4">
87
+ <ProductLink product={product} />
88
+ </div>
89
+ )}
90
+ />
91
+ )
92
+
93
+ export const ProductLinkWithRefresh: Story = {
94
+ render: ProductLinkWithRefreshComponent,
95
+ args: {
96
+ height: 600,
97
+ itemSizeForRow: () => 100,
98
+ showScrollbar: true,
99
+ },
100
+ }
101
+
102
+ const ProductGridWithRefreshComponent = (args: any) => (
103
+ <PullToRefreshListWrapper
104
+ {...args}
105
+ initialItems={createProductList(15)}
106
+ renderItem={(product: Product) => (
107
+ <div className="grid grid-cols-2 gap-4">
108
+ <ProductCard product={product} onFavoriteToggled={fn()} />
109
+ <ProductCard product={product} onFavoriteToggled={fn()} />
110
+ </div>
111
+ )}
112
+ />
113
+ )
114
+
115
+ export const ProductGridWithRefresh: Story = {
116
+ render: ProductGridWithRefreshComponent,
117
+ args: {
118
+ height: 600,
119
+ itemSizeForRow: () => 220,
120
+ showScrollbar: true,
121
+ },
122
+ }
@@ -48,6 +48,60 @@
48
48
  }
49
49
  }
50
50
 
51
+ @keyframes bump {
52
+ 0% {
53
+ transform: scale(1);
54
+ }
55
+ 16.7% {
56
+ transform: scale(1.16);
57
+ }
58
+ 38.9% {
59
+ transform: scale(1.16);
60
+ }
61
+ 100% {
62
+ transform: scale(1);
63
+ }
64
+ }
65
+
66
+ @keyframes shop-spin {
67
+ from {
68
+ stroke-dashoffset: 136;
69
+ }
70
+ to {
71
+ stroke-dashoffset: -272;
72
+ }
73
+ }
74
+
75
+ @keyframes shop-continuous-fill {
76
+ 0% {
77
+ stroke-dashoffset: 204;
78
+ }
79
+ 100% {
80
+ stroke-dashoffset: 0;
81
+ }
82
+ }
83
+
84
+ @media not (prefers-reduced-motion: reduce) {
85
+ .animate-bump {
86
+ animation: bump 360ms cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
87
+ }
88
+
89
+ .animate-shop-spin {
90
+ animation: shop-continuous-fill 1.5s linear infinite;
91
+ stroke-dasharray: 68 136 !important;
92
+ }
93
+ }
94
+
95
+ .shop-spinner-path {
96
+ stroke-dasharray: 136;
97
+ stroke-dashoffset: 0;
98
+ }
99
+
100
+ .shop-spinner-progress {
101
+ stroke-dashoffset: calc(136 - (136 * var(--spinner-progress, 0.54)));
102
+ transition: stroke-dashoffset 0.1s ease-out;
103
+ }
104
+
51
105
  /* --- View Transition Animations --- */
52
106
  @media not (prefers-reduced-motion: reduce) {
53
107
  /* FORWARD navigation animation */
@@ -1 +0,0 @@
1
- {"version":3,"file":"favorite-button.js","sources":["../../../src/components/atoms/favorite-button.tsx"],"sourcesContent":["import {Heart} from 'lucide-react'\n\nimport {IconButton} from './icon-button'\n\nexport function FavoriteButton({\n onClick,\n filled = false,\n}: {\n onClick?: () => void\n filled?: boolean\n}) {\n return (\n <IconButton\n Icon={Heart}\n filled={filled}\n onClick={onClick}\n buttonStyles={\n filled ? 'bg-primary' : 'bg-button-overlay/30 backdrop-blur-sm'\n }\n />\n )\n}\n"],"names":["FavoriteButton","onClick","filled","jsx","IconButton","Heart"],"mappings":";;;AAIO,SAASA,EAAe;AAAA,EAC7B,SAAAC;AAAA,EACA,QAAAC,IAAS;AACX,GAGG;AAEC,SAAA,gBAAAC;AAAA,IAACC;AAAA,IAAA;AAAA,MACC,MAAMC;AAAA,MACN,QAAAH;AAAA,MACA,SAAAD;AAAA,MACA,cACEC,IAAS,eAAe;AAAA,IAAA;AAAA,EAE5B;AAEJ;"}