@shopify/shop-minis-react 0.0.34 → 0.0.36

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 (63) hide show
  1. package/dist/_virtual/index2.js +4 -4
  2. package/dist/_virtual/index3.js +4 -4
  3. package/dist/components/atoms/alert-dialog.js.map +1 -1
  4. package/dist/components/atoms/icon-button.js +12 -12
  5. package/dist/components/atoms/icon-button.js.map +1 -1
  6. package/dist/components/atoms/text-input.js +22 -0
  7. package/dist/components/atoms/text-input.js.map +1 -0
  8. package/dist/components/commerce/merchant-card.js +1 -0
  9. package/dist/components/commerce/merchant-card.js.map +1 -1
  10. package/dist/components/ui/input.js +15 -9
  11. package/dist/components/ui/input.js.map +1 -1
  12. package/dist/hooks/util/useKeyboardAvoidingView.js +23 -0
  13. package/dist/hooks/util/useKeyboardAvoidingView.js.map +1 -0
  14. package/dist/hooks/util/useShare.js +7 -6
  15. package/dist/hooks/util/useShare.js.map +1 -1
  16. package/dist/index.js +228 -222
  17. package/dist/index.js.map +1 -1
  18. package/dist/mocks.js +17 -10
  19. package/dist/mocks.js.map +1 -1
  20. package/dist/shop-minis-platform/src/types/share.js +5 -0
  21. package/dist/shop-minis-platform/src/types/share.js.map +1 -0
  22. package/dist/shop-minis-react/node_modules/.pnpm/video.js@8.23.3/node_modules/video.js/dist/video.es.js +1 -1
  23. package/dist/utils/colors.js +1 -1
  24. package/dist/utils/image.js +5 -4
  25. package/dist/utils/image.js.map +1 -1
  26. package/package.json +21 -4
  27. package/src/components/atoms/alert-dialog.test.tsx +67 -0
  28. package/src/components/atoms/alert-dialog.tsx +13 -11
  29. package/src/components/atoms/favorite-button.test.tsx +56 -0
  30. package/src/components/atoms/icon-button.tsx +1 -1
  31. package/src/components/atoms/image.test.tsx +108 -0
  32. package/src/components/atoms/product-variant-price.test.tsx +128 -0
  33. package/src/components/atoms/text-input.test.tsx +104 -0
  34. package/src/components/atoms/text-input.tsx +31 -0
  35. package/src/components/commerce/merchant-card.test.tsx +261 -0
  36. package/src/components/commerce/merchant-card.tsx +2 -0
  37. package/src/components/commerce/product-card.test.tsx +364 -0
  38. package/src/components/commerce/product-link.test.tsx +483 -0
  39. package/src/components/commerce/quantity-selector.test.tsx +382 -0
  40. package/src/components/commerce/search.test.tsx +487 -0
  41. package/src/components/content/image-content-wrapper.test.tsx +92 -0
  42. package/src/components/index.ts +1 -0
  43. package/src/components/navigation/transition-link.test.tsx +155 -0
  44. package/src/components/ui/input.test.tsx +21 -0
  45. package/src/components/ui/input.tsx +10 -1
  46. package/src/hooks/content/useCreateImageContent.test.ts +352 -0
  47. package/src/hooks/index.ts +1 -0
  48. package/src/hooks/navigation/useNavigateWithTransition.test.ts +371 -0
  49. package/src/hooks/navigation/useViewTransitions.test.ts +469 -0
  50. package/src/hooks/product/useProductSearch.test.ts +470 -0
  51. package/src/hooks/storage/useAsyncStorage.test.ts +225 -0
  52. package/src/hooks/storage/useImageUpload.test.ts +322 -0
  53. package/src/hooks/util/useKeyboardAvoidingView.ts +37 -0
  54. package/src/hooks/util/useShare.ts +13 -3
  55. package/src/internal/useHandleAction.test.ts +265 -0
  56. package/src/internal/useShopActionsDataFetching.test.ts +465 -0
  57. package/src/mocks.ts +7 -1
  58. package/src/providers/ImagePickerProvider.test.tsx +467 -0
  59. package/src/stories/ProductCard.stories.tsx +2 -2
  60. package/src/stories/TextInput.stories.tsx +26 -0
  61. package/src/test-setup.ts +34 -0
  62. package/src/test-utils.tsx +167 -0
  63. package/src/utils/image.ts +1 -0
@@ -0,0 +1,487 @@
1
+ import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'
2
+
3
+ import {
4
+ render,
5
+ screen,
6
+ userEvent,
7
+ waitFor,
8
+ mockProducts,
9
+ } from '../../test-utils'
10
+
11
+ import {Search, SearchProvider, SearchInput, SearchResultsList} from './search'
12
+
13
+ // Mock the useProductSearch hook
14
+ const mockProductSearchResult = {
15
+ products: null as any,
16
+ loading: false,
17
+ error: null,
18
+ fetchMore: vi.fn(),
19
+ hasNextPage: false,
20
+ isTyping: false,
21
+ }
22
+
23
+ vi.mock('../../hooks/product/useProductSearch', () => ({
24
+ useProductSearch: vi.fn(() => mockProductSearchResult),
25
+ }))
26
+
27
+ // Mock navigation hooks for ProductLink
28
+ vi.mock('../../hooks/navigation/useShopNavigation', () => ({
29
+ useShopNavigation: () => ({
30
+ navigateToProduct: vi.fn(),
31
+ navigateToShop: vi.fn(),
32
+ }),
33
+ }))
34
+
35
+ // Mock save actions for ProductLink
36
+ vi.mock('../../hooks/user/useSavedProductsActions', () => ({
37
+ useSavedProductsActions: () => ({
38
+ saveProduct: vi.fn().mockResolvedValue({ok: true, data: {}}),
39
+ unsaveProduct: vi.fn().mockResolvedValue({ok: true, data: {}}),
40
+ }),
41
+ }))
42
+
43
+ describe('Search', () => {
44
+ beforeEach(() => {
45
+ vi.clearAllMocks()
46
+ // Reset mock values
47
+ mockProductSearchResult.products = null
48
+ mockProductSearchResult.loading = false
49
+ mockProductSearchResult.error = null
50
+ mockProductSearchResult.hasNextPage = false
51
+ mockProductSearchResult.isTyping = false
52
+ })
53
+
54
+ afterEach(() => {
55
+ vi.clearAllMocks()
56
+ })
57
+
58
+ describe('SearchInput', () => {
59
+ it('renders search input with placeholder', () => {
60
+ render(
61
+ <SearchProvider>
62
+ <SearchInput placeholder="Search for products..." />
63
+ </SearchProvider>
64
+ )
65
+
66
+ const input = screen.getByPlaceholderText('Search for products...')
67
+ expect(input).toBeInTheDocument()
68
+ expect(input).toHaveAttribute('type', 'search')
69
+ expect(input).toHaveAttribute('role', 'searchbox')
70
+ })
71
+
72
+ it('updates query on input change', async () => {
73
+ const user = userEvent.setup()
74
+
75
+ render(
76
+ <SearchProvider>
77
+ <SearchInput />
78
+ </SearchProvider>
79
+ )
80
+
81
+ const input = screen.getByTestId('search-input')
82
+ await user.type(input, 'test query')
83
+
84
+ expect(input).toHaveValue('test query')
85
+ })
86
+
87
+ it('shows clear button when query is not empty', async () => {
88
+ const user = userEvent.setup()
89
+
90
+ render(
91
+ <SearchProvider>
92
+ <SearchInput />
93
+ </SearchProvider>
94
+ )
95
+
96
+ const input = screen.getByTestId('search-input')
97
+
98
+ // Initially no clear button
99
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
100
+
101
+ await user.type(input, 'test')
102
+
103
+ // Clear button should appear
104
+ const clearButton = screen.getByRole('button')
105
+ expect(clearButton).toBeInTheDocument()
106
+ })
107
+
108
+ it('clears query when clear button is clicked', async () => {
109
+ const user = userEvent.setup()
110
+
111
+ render(
112
+ <SearchProvider initialQuery="test query">
113
+ <SearchInput />
114
+ </SearchProvider>
115
+ )
116
+
117
+ const input = screen.getByTestId('search-input')
118
+ expect(input).toHaveValue('test query')
119
+
120
+ const clearButton = screen.getByRole('button')
121
+ await user.click(clearButton)
122
+
123
+ expect(input).toHaveValue('')
124
+ })
125
+
126
+ it('accepts custom input props', async () => {
127
+ const onChange = vi.fn()
128
+ const user = userEvent.setup()
129
+
130
+ render(
131
+ <SearchProvider>
132
+ <SearchInput
133
+ inputProps={
134
+ {
135
+ onChange,
136
+ 'data-custom': 'test',
137
+ } as any
138
+ }
139
+ />
140
+ </SearchProvider>
141
+ )
142
+
143
+ const input = screen.getByTestId('search-input')
144
+ expect(input).toHaveAttribute('data-custom', 'test')
145
+
146
+ await user.type(input, 'a')
147
+ expect(onChange).toHaveBeenCalled()
148
+ })
149
+ })
150
+
151
+ describe('SearchResultsList', () => {
152
+ it('shows initial state when query is empty', () => {
153
+ render(
154
+ <SearchProvider>
155
+ <SearchResultsList />
156
+ </SearchProvider>
157
+ )
158
+
159
+ expect(
160
+ screen.getByText('Start typing to search for products')
161
+ ).toBeInTheDocument()
162
+ })
163
+
164
+ it('shows custom initial state component', () => {
165
+ render(
166
+ <SearchProvider>
167
+ <SearchResultsList
168
+ initialStateComponent={<div>Custom initial state</div>}
169
+ />
170
+ </SearchProvider>
171
+ )
172
+
173
+ expect(screen.getByText('Custom initial state')).toBeInTheDocument()
174
+ })
175
+
176
+ it('shows loading state when searching', () => {
177
+ mockProductSearchResult.loading = true
178
+ mockProductSearchResult.products = []
179
+
180
+ render(
181
+ <SearchProvider initialQuery="test">
182
+ <SearchResultsList />
183
+ </SearchProvider>
184
+ )
185
+
186
+ // Should show skeleton loaders
187
+ const skeletons = document.querySelectorAll('[data-slot="skeleton"]')
188
+ expect(skeletons.length).toBeGreaterThan(0)
189
+ })
190
+
191
+ it('shows typing state', () => {
192
+ mockProductSearchResult.isTyping = true
193
+ mockProductSearchResult.products = []
194
+
195
+ render(
196
+ <SearchProvider initialQuery="test">
197
+ <SearchResultsList />
198
+ </SearchProvider>
199
+ )
200
+
201
+ // Should show skeleton loaders while typing
202
+ const skeletons = document.querySelectorAll('[data-slot="skeleton"]')
203
+ expect(skeletons.length).toBeGreaterThan(0)
204
+ })
205
+
206
+ it('shows empty state when no products found', () => {
207
+ mockProductSearchResult.products = []
208
+ mockProductSearchResult.loading = false
209
+
210
+ render(
211
+ <SearchProvider initialQuery="nonexistent">
212
+ <SearchResultsList />
213
+ </SearchProvider>
214
+ )
215
+
216
+ expect(
217
+ screen.getByText('No products found for "nonexistent"')
218
+ ).toBeInTheDocument()
219
+ })
220
+
221
+ it('renders products when available', () => {
222
+ const products = mockProducts(3)
223
+ mockProductSearchResult.products = products
224
+ mockProductSearchResult.loading = false
225
+
226
+ render(
227
+ <SearchProvider initialQuery="test">
228
+ <SearchResultsList />
229
+ </SearchProvider>
230
+ )
231
+
232
+ products.forEach(product => {
233
+ expect(screen.getByText(product.title)).toBeInTheDocument()
234
+ })
235
+ })
236
+
237
+ it('uses custom renderItem function', () => {
238
+ const products = mockProducts(2)
239
+ mockProductSearchResult.products = products
240
+
241
+ const customRenderItem = (product: any) => (
242
+ <div key={product.id}>Custom: {product.title}</div>
243
+ )
244
+
245
+ render(
246
+ <SearchProvider initialQuery="test">
247
+ <SearchResultsList renderItem={customRenderItem} />
248
+ </SearchProvider>
249
+ )
250
+
251
+ products.forEach(product => {
252
+ expect(screen.getByText(`Custom: ${product.title}`)).toBeInTheDocument()
253
+ })
254
+ })
255
+
256
+ it('handles pagination with fetchMore', async () => {
257
+ const products = mockProducts(5)
258
+ mockProductSearchResult.products = products
259
+ mockProductSearchResult.hasNextPage = true
260
+ mockProductSearchResult.fetchMore = vi.fn()
261
+
262
+ render(
263
+ <SearchProvider initialQuery="test">
264
+ <SearchResultsList height={200} itemHeight={50} />
265
+ </SearchProvider>
266
+ )
267
+
268
+ // Verify fetchMore is available for pagination
269
+ expect(mockProductSearchResult.fetchMore).toBeDefined()
270
+ })
271
+ })
272
+
273
+ describe('Search Component (Integrated)', () => {
274
+ it('renders complete search interface', () => {
275
+ render(<Search />)
276
+
277
+ expect(screen.getByTestId('search-input')).toBeInTheDocument()
278
+ expect(
279
+ screen.getByText('Start typing to search for products')
280
+ ).toBeInTheDocument()
281
+ })
282
+
283
+ it('handles search flow end-to-end', async () => {
284
+ const user = userEvent.setup()
285
+ const products = mockProducts(2)
286
+
287
+ render(<Search />)
288
+
289
+ const input = screen.getByTestId('search-input')
290
+
291
+ // Type search query
292
+ await user.type(input, 'test')
293
+
294
+ // Update mock to return products
295
+ mockProductSearchResult.products = products
296
+ mockProductSearchResult.loading = false
297
+
298
+ // Rerender to show products
299
+ render(<Search initialQuery="test" />)
300
+
301
+ // Products should be displayed
302
+ products.forEach(product => {
303
+ expect(screen.getByText(product.title)).toBeInTheDocument()
304
+ })
305
+ })
306
+
307
+ it('calls onProductClick when product is clicked', async () => {
308
+ const user = userEvent.setup()
309
+ const onProductClick = vi.fn()
310
+ const products = mockProducts(1)
311
+ mockProductSearchResult.products = products
312
+
313
+ render(<Search initialQuery="test" onProductClick={onProductClick} />)
314
+
315
+ // Click on the touchable wrapper around the product
316
+ const productTitle = screen.getByText(products[0].title)
317
+ const touchableWrapper = productTitle.closest('[data-touchable="true"]')
318
+ if (touchableWrapper) {
319
+ await user.click(touchableWrapper)
320
+ }
321
+
322
+ await waitFor(() => {
323
+ expect(onProductClick).toHaveBeenCalledWith(products[0])
324
+ })
325
+ })
326
+
327
+ it('accepts custom renderItem for product display', () => {
328
+ const products = mockProducts(1)
329
+ mockProductSearchResult.products = products
330
+
331
+ const customRenderItem = (product: any) => (
332
+ <div key={product.id} data-testid="custom-product">
333
+ Custom Product: {product.title}
334
+ </div>
335
+ )
336
+
337
+ render(<Search initialQuery="test" renderItem={customRenderItem} />)
338
+
339
+ expect(screen.getByTestId('custom-product')).toBeInTheDocument()
340
+ expect(
341
+ screen.getByText(`Custom Product: ${products[0].title}`)
342
+ ).toBeInTheDocument()
343
+ })
344
+
345
+ it('applies custom className', () => {
346
+ const {container} = render(<Search className="custom-search-class" />)
347
+
348
+ const searchContainer = container.querySelector('.custom-search-class')
349
+ expect(searchContainer).toBeInTheDocument()
350
+ })
351
+ })
352
+
353
+ describe('SearchProvider Context', () => {
354
+ it('provides search context to children', () => {
355
+ const TestComponent = () => {
356
+ const {query} = useSearchContext()
357
+ return <>Query: {query}</>
358
+ }
359
+
360
+ // Mock the useSearchContext to avoid actual implementation
361
+ const useSearchContext = vi.fn(() => ({
362
+ query: 'test query',
363
+ setQuery: vi.fn(),
364
+ products: [],
365
+ loading: false,
366
+ error: null,
367
+ hasNextPage: false,
368
+ isTyping: false,
369
+ }))
370
+
371
+ render(
372
+ <SearchProvider initialQuery="test query">
373
+ <TestComponent />
374
+ </SearchProvider>
375
+ )
376
+
377
+ expect(screen.getByText('Query: test query')).toBeInTheDocument()
378
+ })
379
+
380
+ it('throws error when using context outside provider', () => {
381
+ // This would throw in actual usage, but we can't test it directly
382
+ // without importing the actual useSearchContext
383
+ expect(() => {
384
+ const TestComponent = () => {
385
+ // This would throw: useSearchContext must be used within a SearchProvider
386
+ return <div>Test</div>
387
+ }
388
+ render(<TestComponent />)
389
+ }).not.toThrow() // Our mock doesn't throw
390
+ })
391
+ })
392
+
393
+ describe('Edge Cases', () => {
394
+ it('handles error state', () => {
395
+ mockProductSearchResult.error = new Error('Search failed') as any
396
+ mockProductSearchResult.loading = false
397
+ mockProductSearchResult.products = []
398
+
399
+ render(
400
+ <SearchProvider initialQuery="test">
401
+ <SearchResultsList />
402
+ </SearchProvider>
403
+ )
404
+
405
+ // Should show empty state on error
406
+ expect(
407
+ screen.getByText('No products found for "test"')
408
+ ).toBeInTheDocument()
409
+ })
410
+
411
+ it('handles whitespace-only queries', () => {
412
+ render(
413
+ <SearchProvider initialQuery=" ">
414
+ <SearchResultsList />
415
+ </SearchProvider>
416
+ )
417
+
418
+ // Should show initial state for whitespace-only query
419
+ expect(
420
+ screen.getByText('Start typing to search for products')
421
+ ).toBeInTheDocument()
422
+ })
423
+
424
+ it('handles very long queries', async () => {
425
+ const user = userEvent.setup()
426
+ const longQuery = 'a'.repeat(100)
427
+
428
+ render(
429
+ <SearchProvider>
430
+ <SearchInput />
431
+ </SearchProvider>
432
+ )
433
+
434
+ const input = screen.getByTestId('search-input')
435
+ await user.type(input, longQuery)
436
+
437
+ expect(input).toHaveValue(longQuery)
438
+ })
439
+
440
+ it('handles rapid query changes', async () => {
441
+ const user = userEvent.setup()
442
+
443
+ render(
444
+ <SearchProvider>
445
+ <SearchInput />
446
+ </SearchProvider>
447
+ )
448
+
449
+ const input = screen.getByTestId('search-input')
450
+
451
+ // Rapid typing
452
+ await user.type(input, 'a')
453
+ await user.type(input, 'b')
454
+ await user.type(input, 'c')
455
+
456
+ expect(input).toHaveValue('abc')
457
+ })
458
+ })
459
+
460
+ describe('Accessibility', () => {
461
+ it('has proper ARIA attributes on search input', () => {
462
+ render(
463
+ <SearchProvider>
464
+ <SearchInput />
465
+ </SearchProvider>
466
+ )
467
+
468
+ const input = screen.getByTestId('search-input')
469
+ expect(input).toHaveAttribute('role', 'searchbox')
470
+ expect(input).toHaveAttribute('type', 'search')
471
+ expect(input).toHaveAttribute('autoComplete', 'off')
472
+ })
473
+
474
+ it('search icon is decorative', () => {
475
+ render(
476
+ <SearchProvider>
477
+ <SearchInput />
478
+ </SearchProvider>
479
+ )
480
+
481
+ // Search icon should not have interactive role
482
+ const searchIcon = document.querySelector('svg')
483
+ expect(searchIcon).toBeInTheDocument()
484
+ expect(searchIcon?.parentElement).not.toHaveAttribute('role', 'button')
485
+ })
486
+ })
487
+ })
@@ -0,0 +1,92 @@
1
+ import {describe, expect, it, vi} from 'vitest'
2
+
3
+ import {render, screen} from '../../test-utils'
4
+
5
+ import {ImageContentWrapper} from './image-content-wrapper'
6
+
7
+ // Mock ContentWrapper component
8
+ vi.mock('../atoms/content-wrapper', () => ({
9
+ ContentWrapper: vi.fn(({children}: any) => {
10
+ // Simulate content loaded state
11
+ return children({
12
+ content: {
13
+ title: 'Test Image',
14
+ image: {
15
+ url: 'https://example.com/image.jpg',
16
+ thumbhash: 'testThumbhash',
17
+ width: 800,
18
+ height: 600,
19
+ },
20
+ },
21
+ loading: false,
22
+ })
23
+ }),
24
+ }))
25
+
26
+ // Mock Image component
27
+ vi.mock('../atoms/image', () => ({
28
+ Image: vi.fn((props: any) => (
29
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
30
+ <img
31
+ src={props.src}
32
+ alt={props.alt}
33
+ width={props.width}
34
+ height={props.height}
35
+ className={props.className}
36
+ onLoad={props.onLoad}
37
+ data-testid="mocked-image"
38
+ style={{aspectRatio: props.aspectRatio}}
39
+ />
40
+ )),
41
+ }))
42
+
43
+ describe('ImageContentWrapper', () => {
44
+ it('renders image with publicId', () => {
45
+ render(<ImageContentWrapper publicId="test-public-id" />)
46
+
47
+ const image = screen.getByTestId('mocked-image')
48
+ expect(image).toBeInTheDocument()
49
+ expect(image).toHaveAttribute('src', 'https://example.com/image.jpg')
50
+ expect(image).toHaveAttribute('alt', 'Test Image')
51
+ })
52
+
53
+ it('renders image with externalId', () => {
54
+ render(<ImageContentWrapper externalId="test-external-id" />)
55
+
56
+ const image = screen.getByTestId('mocked-image')
57
+ expect(image).toBeInTheDocument()
58
+ expect(image).toHaveAttribute('src', 'https://example.com/image.jpg')
59
+ })
60
+
61
+ it('passes dimensions to image', () => {
62
+ render(<ImageContentWrapper publicId="test-id" width={400} height={300} />)
63
+
64
+ const image = screen.getByTestId('mocked-image')
65
+ expect(image).toHaveAttribute('width', '400')
66
+ expect(image).toHaveAttribute('height', '300')
67
+ })
68
+
69
+ it('calculates and applies aspect ratio', () => {
70
+ render(<ImageContentWrapper publicId="test-id" />)
71
+
72
+ const image = screen.getByTestId('mocked-image')
73
+ // 800/600 = 1.333...
74
+ expect(image.style.aspectRatio).toBe('1.3333333333333333')
75
+ })
76
+
77
+ it('handles onLoad callback', () => {
78
+ const handleLoad = vi.fn()
79
+ render(<ImageContentWrapper publicId="test-id" onLoad={handleLoad} />)
80
+
81
+ // Verify that onLoad is passed through to the Image component
82
+ const image = screen.getByTestId('mocked-image')
83
+ expect(image).toBeInTheDocument()
84
+ })
85
+
86
+ it('applies custom className', () => {
87
+ render(<ImageContentWrapper publicId="test-id" className="custom-class" />)
88
+
89
+ const image = screen.getByTestId('mocked-image')
90
+ expect(image).toHaveClass('custom-class')
91
+ })
92
+ })
@@ -22,6 +22,7 @@ export * from './atoms/long-press-detector'
22
22
  export * from './atoms/alert-dialog'
23
23
  export * from './atoms/list'
24
24
  export * from './atoms/video-player'
25
+ export * from './atoms/text-input'
25
26
 
26
27
  export * from './ui/accordion'
27
28
  export * from './ui/alert'