@shopify/shop-minis-react 0.0.33 → 0.0.35
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/dist/_virtual/index10.js +2 -2
- package/dist/_virtual/index2.js +4 -4
- package/dist/_virtual/index3.js +4 -4
- package/dist/_virtual/index8.js +2 -2
- package/dist/_virtual/index9.js +2 -2
- package/dist/components/atoms/alert-dialog.js.map +1 -1
- package/dist/components/atoms/icon-button.js +12 -12
- package/dist/components/atoms/icon-button.js.map +1 -1
- package/dist/components/atoms/image.js +52 -0
- package/dist/components/atoms/image.js.map +1 -0
- package/dist/components/atoms/text-input.js +22 -0
- package/dist/components/atoms/text-input.js.map +1 -0
- package/dist/components/commerce/merchant-card.js +2 -1
- package/dist/components/commerce/merchant-card.js.map +1 -1
- package/dist/components/commerce/product-card.js +11 -11
- package/dist/components/commerce/product-card.js.map +1 -1
- package/dist/components/content/image-content-wrapper.js +29 -22
- package/dist/components/content/image-content-wrapper.js.map +1 -1
- package/dist/components/ui/input.js +15 -9
- package/dist/components/ui/input.js.map +1 -1
- package/dist/hooks/content/useCreateImageContent.js +16 -22
- package/dist/hooks/content/useCreateImageContent.js.map +1 -1
- package/dist/hooks/storage/useImageUpload.js +36 -37
- package/dist/hooks/storage/useImageUpload.js.map +1 -1
- package/dist/hooks/util/useKeyboardAvoidingView.js +23 -0
- package/dist/hooks/util/useKeyboardAvoidingView.js.map +1 -0
- package/dist/index.js +218 -212
- package/dist/index.js.map +1 -1
- package/dist/mocks.js +4 -1
- package/dist/mocks.js.map +1 -1
- package/dist/shop-minis-platform/src/types/content.js.map +1 -1
- package/dist/shop-minis-react/node_modules/.pnpm/@xmldom_xmldom@0.8.10/node_modules/@xmldom/xmldom/lib/index.js +1 -1
- package/dist/shop-minis-react/node_modules/.pnpm/color-string@1.9.1/node_modules/color-string/index.js +1 -1
- 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
- package/dist/shop-minis-react/node_modules/.pnpm/video.js@8.23.3/node_modules/video.js/dist/video.es.js +1 -1
- package/dist/utils/colors.js +1 -1
- package/dist/utils/image.js +46 -9
- package/dist/utils/image.js.map +1 -1
- package/package.json +21 -4
- package/src/components/atoms/alert-dialog.test.tsx +67 -0
- package/src/components/atoms/alert-dialog.tsx +13 -11
- package/src/components/atoms/favorite-button.test.tsx +56 -0
- package/src/components/atoms/icon-button.tsx +1 -1
- package/src/components/atoms/image.test.tsx +108 -0
- package/src/components/atoms/{thumbhash-image.tsx → image.tsx} +14 -14
- package/src/components/atoms/product-variant-price.test.tsx +128 -0
- package/src/components/atoms/text-input.test.tsx +104 -0
- package/src/components/atoms/text-input.tsx +31 -0
- package/src/components/commerce/merchant-card.test.tsx +261 -0
- package/src/components/commerce/merchant-card.tsx +4 -2
- package/src/components/commerce/product-card.test.tsx +364 -0
- package/src/components/commerce/product-card.tsx +2 -2
- package/src/components/commerce/product-link.test.tsx +483 -0
- package/src/components/commerce/quantity-selector.test.tsx +382 -0
- package/src/components/commerce/search.test.tsx +487 -0
- package/src/components/content/image-content-wrapper.test.tsx +92 -0
- package/src/components/content/image-content-wrapper.tsx +9 -2
- package/src/components/index.ts +2 -1
- package/src/components/navigation/transition-link.test.tsx +155 -0
- package/src/components/ui/input.test.tsx +21 -0
- package/src/components/ui/input.tsx +10 -1
- package/src/hooks/content/useCreateImageContent.test.ts +352 -0
- package/src/hooks/content/useCreateImageContent.ts +1 -7
- package/src/hooks/index.ts +1 -0
- package/src/hooks/navigation/useNavigateWithTransition.test.ts +371 -0
- package/src/hooks/navigation/useViewTransitions.test.ts +469 -0
- package/src/hooks/product/useProductSearch.test.ts +470 -0
- package/src/hooks/storage/useAsyncStorage.test.ts +225 -0
- package/src/hooks/storage/useImageUpload.test.ts +322 -0
- package/src/hooks/storage/useImageUpload.ts +22 -20
- package/src/hooks/util/useKeyboardAvoidingView.ts +37 -0
- package/src/internal/useHandleAction.test.ts +265 -0
- package/src/internal/useShopActionsDataFetching.test.ts +465 -0
- package/src/mocks.ts +3 -1
- package/src/providers/ImagePickerProvider.test.tsx +467 -0
- package/src/stories/ProductCard.stories.tsx +2 -2
- package/src/stories/TextInput.stories.tsx +26 -0
- package/src/test-setup.ts +34 -0
- package/src/test-utils.tsx +167 -0
- package/src/utils/image.ts +73 -0
- package/src/utils/index.ts +1 -1
- package/dist/components/atoms/thumbhash-image.js +0 -54
- package/dist/components/atoms/thumbhash-image.js.map +0 -1
- package/dist/utils/imageToDataUri.js +0 -10
- package/dist/utils/imageToDataUri.js.map +0 -1
- package/src/utils/imageToDataUri.ts +0 -8
|
@@ -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
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
|
2
1
|
import {ContentWrapper} from '../atoms/content-wrapper'
|
|
2
|
+
import {Image} from '../atoms/image'
|
|
3
3
|
|
|
4
4
|
type ImageContentWrapperProps = (
|
|
5
5
|
| {publicId: string; externalId?: never}
|
|
@@ -26,14 +26,21 @@ export function ImageContentWrapper({
|
|
|
26
26
|
{({content, loading}) => {
|
|
27
27
|
if (loading) return Loader ? <>{Loader}</> : null
|
|
28
28
|
|
|
29
|
+
const aspectRatio =
|
|
30
|
+
content?.image?.width && content?.image?.height
|
|
31
|
+
? content.image.width / content.image.height
|
|
32
|
+
: undefined
|
|
33
|
+
|
|
29
34
|
return (
|
|
30
|
-
<
|
|
35
|
+
<Image
|
|
31
36
|
src={content?.image?.url}
|
|
37
|
+
thumbhash={content?.image?.thumbhash}
|
|
32
38
|
width={width}
|
|
33
39
|
height={height}
|
|
34
40
|
alt={content?.title}
|
|
35
41
|
onLoad={onLoad}
|
|
36
42
|
className={className}
|
|
43
|
+
aspectRatio={aspectRatio}
|
|
37
44
|
/>
|
|
38
45
|
)
|
|
39
46
|
}}
|
package/src/components/index.ts
CHANGED
|
@@ -16,12 +16,13 @@ export * from './navigation/transition-link'
|
|
|
16
16
|
export * from './atoms/button'
|
|
17
17
|
export * from './atoms/favorite-button'
|
|
18
18
|
export * from './atoms/icon-button'
|
|
19
|
-
export * from './atoms/
|
|
19
|
+
export * from './atoms/image'
|
|
20
20
|
export * from './atoms/touchable'
|
|
21
21
|
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'
|