@thorprovider/blocks 0.1.1 → 0.1.2

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 (56) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +32 -5
  3. package/AGENTS.md +1 -1
  4. package/README.md +52 -0
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/contracts.d.mts +1111 -90
  7. package/dist/contracts.d.ts +1111 -90
  8. package/dist/contracts.js +189 -6
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/contracts.mjs +173 -7
  11. package/dist/contracts.mjs.map +1 -1
  12. package/dist/index.d.mts +467 -89
  13. package/dist/index.d.ts +467 -89
  14. package/dist/index.js +2967 -2250
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +2989 -2282
  17. package/dist/index.mjs.map +1 -1
  18. package/docs/standards/theming.md +32 -5
  19. package/package.json +2 -2
  20. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
  21. package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
  22. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
  23. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
  24. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
  25. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
  26. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
  27. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
  28. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
  29. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
  30. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
  31. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
  32. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
  33. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
  34. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
  35. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
  36. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
  37. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +137 -0
  38. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +231 -89
  39. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +65 -0
  40. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +19 -0
  41. package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
  42. package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +54 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +43 -13
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +112 -0
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +101 -28
  47. package/src/index.ts +38 -0
  48. package/src/lib/cart.test.ts +15 -0
  49. package/src/lib/cart.ts +11 -0
  50. package/src/lib/contracts-curated.test.ts +207 -0
  51. package/src/lib/contracts-server-boundary.test.ts +52 -0
  52. package/src/lib/contracts.ts +182 -4
  53. package/src/lib/money.test.ts +64 -0
  54. package/src/lib/money.ts +65 -0
  55. package/tsup.config.ts +0 -1
  56. package/vitest.config.ts +9 -1
@@ -0,0 +1,513 @@
1
+ import type { ReactElement, ReactNode } from 'react'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import {
5
+ CategoryFilter2,
6
+ CategoryFilter2StatusView,
7
+ buildFilterChange,
8
+ reconcileSearchDraft,
9
+ toggleFilterValue,
10
+ type CategoryFilter2ActiveFilter,
11
+ type FilterProduct,
12
+ } from './category-filter-2'
13
+
14
+ const products: FilterProduct[] = [
15
+ { id: 'pantalla-samsung-a54', name: 'Pantalla Samsung Galaxy A54 OLED', price: '$49.99', image: '/demo/pantalla.svg' },
16
+ { id: 'bateria-iphone-13', name: 'Batería iPhone 13', price: '$29.99' },
17
+ ]
18
+
19
+ const advertisedSorts = [
20
+ { value: 'featured', label: 'Destacados' },
21
+ { value: 'name', label: 'Nombre' },
22
+ { value: 'newest', label: 'Más recientes' },
23
+ ]
24
+
25
+ const activeChips: CategoryFilter2ActiveFilter[] = [
26
+ { id: 'text', kind: 'text', label: 'pantalla', params: ['q'], value: 'pantalla' },
27
+ { id: 'category:pantallas', kind: 'category', label: 'Pantallas', params: ['categories'], value: 'pantallas' },
28
+ { id: 'price', kind: 'price', label: '10 – 40', params: ['priceMin', 'priceMax'] },
29
+ ]
30
+
31
+ const countOccurrences = (html: string, needle: string) => html.split(needle).length - 1
32
+
33
+ /** Find every element whose prop `key` equals `value`, walking the returned
34
+ * element tree (no DOM needed — `CategoryFilter2StatusView` is hooks-free). */
35
+ function findAllByProp(
36
+ node: ReactNode,
37
+ key: string,
38
+ value: string,
39
+ ): ReactElement<{ children?: ReactNode; onClick?: () => void; [prop: string]: unknown }>[] {
40
+ const found: ReactElement<{
41
+ children?: ReactNode
42
+ onClick?: () => void
43
+ [prop: string]: unknown
44
+ }>[] = []
45
+ const walk = (current: ReactNode): void => {
46
+ if (current === null || current === undefined) return
47
+ if (Array.isArray(current)) {
48
+ current.forEach(walk)
49
+ return
50
+ }
51
+ if (typeof current !== 'object') return
52
+ const element = current as ReactElement<{
53
+ children?: ReactNode
54
+ onClick?: () => void
55
+ [prop: string]: unknown
56
+ }>
57
+ if (element.props && element.props[key] === value) found.push(element)
58
+ walk(element.props?.children)
59
+ }
60
+ walk(node)
61
+ return found
62
+ }
63
+
64
+ describe('CategoryFilter2 — controlled canonical query', () => {
65
+ it('renders the query-selected search, category, sort and page values', () => {
66
+ const html = renderToStaticMarkup(
67
+ <CategoryFilter2
68
+ products={products}
69
+ categories={[
70
+ { id: 'pantallas', label: 'Pantallas' },
71
+ { id: 'baterias', label: 'Baterías' },
72
+ ]}
73
+ sortOptions={advertisedSorts}
74
+ pagination={[1, 2, 3]}
75
+ showSearch
76
+ searchValue='pantalla'
77
+ selectedCategories={['pantallas']}
78
+ selectedSort='name'
79
+ selectedPage={2}
80
+ />,
81
+ )
82
+
83
+ expect(html).toContain('value="pantalla"')
84
+ // The sort control renders (its options are portaled client-side, so the
85
+ // advertised label set is asserted by the mapper test instead).
86
+ expect(html).toContain('aria-label="Ordenar por"')
87
+ expect(html).toContain('aria-checked="true"')
88
+ expect(html).toContain('aria-current="page"')
89
+ // The selected page is page 2; only one link is active.
90
+ expect(countOccurrences(html, 'aria-current="page"')).toBe(1)
91
+ })
92
+
93
+ it('hides the search control by default and shows it when requested', () => {
94
+ const hidden = renderToStaticMarkup(<CategoryFilter2 products={products} sortOptions={advertisedSorts} />)
95
+ expect(hidden).not.toContain('type="search"')
96
+
97
+ const shown = renderToStaticMarkup(<CategoryFilter2 products={products} sortOptions={advertisedSorts} showSearch />)
98
+ expect(shown).toContain('type="search"')
99
+ expect(shown).toContain('aria-label="Buscar"')
100
+ })
101
+
102
+ it('renders collection controls when collections are supplied', () => {
103
+ const html = renderToStaticMarkup(
104
+ <CategoryFilter2
105
+ products={products}
106
+ collections={[{ id: 'ofertas', label: 'Ofertas' }]}
107
+ selectedCollections={['ofertas']}
108
+ />,
109
+ )
110
+ expect(html).toContain('Colección')
111
+ expect(html).toContain('Ofertas')
112
+ expect(html).toContain('aria-checked="true"')
113
+ })
114
+ })
115
+
116
+ describe('CategoryFilter2 — no fabricated price or sort choices', () => {
117
+ it('omits the price control without a real backend range', () => {
118
+ const html = renderToStaticMarkup(<CategoryFilter2 products={products} sortOptions={advertisedSorts} />)
119
+ expect(html).not.toContain('Rango de precio')
120
+ })
121
+
122
+ it('shows the price control only from a real range', () => {
123
+ const html = renderToStaticMarkup(
124
+ <CategoryFilter2 products={products} priceRange={{ min: 0, max: 100, step: 5 }} selectedPriceRange={[10, 40]} />,
125
+ )
126
+ expect(html).toContain('Rango de precio')
127
+ expect(html).toContain('value="10"')
128
+ expect(html).toContain('value="40"')
129
+ })
130
+
131
+ it('renders the sort control only when advertised options are supplied', () => {
132
+ const withOptions = renderToStaticMarkup(<CategoryFilter2 products={products} sortOptions={advertisedSorts} />)
133
+ expect(withOptions).toContain('aria-label="Ordenar por"')
134
+
135
+ const withoutOptions = renderToStaticMarkup(<CategoryFilter2 products={products} />)
136
+ expect(withoutOptions).not.toContain('aria-label="Ordenar por"')
137
+ })
138
+
139
+ it('renders no sort control when no options are advertised', () => {
140
+ const html = renderToStaticMarkup(<CategoryFilter2 products={products} />)
141
+ expect(html).not.toContain('Ordenar por')
142
+ })
143
+ })
144
+
145
+ describe('CategoryFilter2 — provider-paged products are not re-paginated', () => {
146
+ it('renders exactly the supplied page of products with backend page links', () => {
147
+ const html = renderToStaticMarkup(
148
+ <CategoryFilter2 products={products} pagination={[1, 2, 3]} selectedPage={2} sortOptions={advertisedSorts} />,
149
+ )
150
+ expect(html).toContain('Pantalla Samsung Galaxy A54 OLED')
151
+ expect(html).toContain('Batería iPhone 13')
152
+ expect(countOccurrences(html, 'data-slot="pagination-link"')).toBe(3)
153
+ // The second product has no image; the block omits src rather than emitting "".
154
+ expect(html).not.toContain('src=""')
155
+ })
156
+ })
157
+
158
+ describe('CategoryFilter2 — product cards link and placeholder (tasks 3.1/3.2)', () => {
159
+ it('renders each product card as a real anchor when href is present', () => {
160
+ const linked: FilterProduct[] = [
161
+ { id: 'p1', name: 'Pantalla', price: '$49.99', href: '/productos/pantalla-samsung-a54' },
162
+ ]
163
+ const html = renderToStaticMarkup(<CategoryFilter2 products={linked} />)
164
+ expect(html).toMatch(/<a[^>]*href="\/productos\/pantalla-samsung-a54"/)
165
+ expect(html).toContain('Pantalla')
166
+ // The card is not rendered as an article when it is a link.
167
+ expect(html).not.toContain('<article')
168
+ })
169
+
170
+ it('keeps the onClick path (article, no anchor) when href is absent', () => {
171
+ const html = renderToStaticMarkup(
172
+ <CategoryFilter2 products={products} onProductClick={() => {}} />,
173
+ )
174
+ expect(html).not.toMatch(/<a[^>]*href="\/productos\//)
175
+ expect(html).toContain('<article')
176
+ })
177
+
178
+ it('links only the cards that carry an href', () => {
179
+ const mixed: FilterProduct[] = [
180
+ { id: 'p1', name: 'Pantalla', price: '$49.99', href: '/productos/p1' },
181
+ { id: 'p2', name: 'Batería', price: '$29.99' },
182
+ ]
183
+ const html = renderToStaticMarkup(<CategoryFilter2 products={mixed} />)
184
+ expect(countOccurrences(html, '<a ')).toBe(1)
185
+ expect(html).toMatch(/<a[^>]*href="\/productos\/p1"/)
186
+ expect(html).toContain('Batería')
187
+ })
188
+
189
+ it('renders a neutral placeholder and no <img> when the image is absent', () => {
190
+ const html = renderToStaticMarkup(<CategoryFilter2 products={products} />)
191
+ expect(html).toContain('data-slot="product-image-placeholder"')
192
+ // The product without an image never emits an <img> without src.
193
+ expect(html).not.toContain('src=""')
194
+ // One product has an image (Pantalla), one does not (Batería): exactly one img.
195
+ expect(countOccurrences(html, '<img')).toBe(1)
196
+ expect(html).toMatch(/<img[^>]*src="\/demo\/pantalla.svg"/)
197
+ })
198
+ })
199
+
200
+ describe('CategoryFilter2 — emitted changes (pure reducer)', () => {
201
+ it('adds and removes a selection without duplicates', () => {
202
+ expect(toggleFilterValue([], 'a', true)).toEqual(['a'])
203
+ expect(toggleFilterValue(['a'], 'b', true)).toEqual(['a', 'b'])
204
+ expect(toggleFilterValue(['a', 'b'], 'a', false)).toEqual(['b'])
205
+ })
206
+
207
+ it('emits the canonical filter change including collections', () => {
208
+ expect(
209
+ buildFilterChange({
210
+ categories: ['pantallas'],
211
+ collections: ['ofertas'],
212
+ priceRange: [10, 40],
213
+ size: null,
214
+ color: null,
215
+ }),
216
+ ).toEqual({
217
+ categories: ['pantallas'],
218
+ collections: ['ofertas'],
219
+ priceRange: [10, 40],
220
+ size: null,
221
+ color: null,
222
+ })
223
+ })
224
+ })
225
+
226
+ describe('CategoryFilter2 — controlled search draft (E2 task 8.2 corrective)', () => {
227
+ // Simulates the real client loop: the shopper types into the controlled input
228
+ // (mutating the draft immediately and emitting the full value), while the
229
+ // URL-backed `searchValue` prop arrives later from the server. The draft must
230
+ // keep every typed character and never be reverted by a stale response.
231
+ function type(seq: string) {
232
+ let draft = ''
233
+ let prevSearchValue: string | undefined = ''
234
+ const emitted: string[] = []
235
+ const deliver = (searchValue: string) => {
236
+ const next = reconcileSearchDraft({ searchValue, prevSearchValue, draft })
237
+ if (next) {
238
+ draft = next.draft
239
+ prevSearchValue = next.prevSearchValue
240
+ }
241
+ }
242
+ for (const ch of seq) {
243
+ const before = draft
244
+ draft += ch
245
+ emitted.push(draft)
246
+ // A fast server round-trip can deliver the response to the PREVIOUS
247
+ // keystroke after the shopper has already typed past it; each delivery
248
+ // must leave the draft intact.
249
+ deliver(before)
250
+ }
251
+ // The final server response reflects the whole committed query.
252
+ deliver(seq)
253
+ return { draft, emitted }
254
+ }
255
+
256
+ it('keeps the full multi-character query under fast typing', () => {
257
+ const { draft, emitted } = type('alpha-001')
258
+ expect(emitted.at(-1)).toBe('alpha-001')
259
+ expect(draft).toBe('alpha-001')
260
+ })
261
+
262
+ it('ignores a stale (unchanged) response that would revert the draft', () => {
263
+ expect(
264
+ reconcileSearchDraft({ searchValue: 'al', prevSearchValue: 'al', draft: 'alp' }),
265
+ ).toBeNull()
266
+ })
267
+
268
+ it('records a stale older response without reverting the draft', () => {
269
+ expect(
270
+ reconcileSearchDraft({ searchValue: 'a', prevSearchValue: 'al', draft: 'alp' }),
271
+ ).toEqual({ draft: 'alp', prevSearchValue: 'a' })
272
+ })
273
+
274
+ it('adopts an external navigation value when no local edit is pending', () => {
275
+ expect(
276
+ reconcileSearchDraft({ searchValue: 'beta', prevSearchValue: '', draft: '' }),
277
+ ).toEqual({ draft: 'beta', prevSearchValue: 'beta' })
278
+ })
279
+
280
+ it('does not adopt while an uncommitted edit is pending', () => {
281
+ expect(
282
+ reconcileSearchDraft({ searchValue: 'other', prevSearchValue: 'alp', draft: 'alpha' }),
283
+ ).toEqual({ draft: 'alpha', prevSearchValue: 'other' })
284
+ })
285
+
286
+ it('is a no-op for an unchanged prop', () => {
287
+ expect(reconcileSearchDraft({ searchValue: 'q', prevSearchValue: 'q', draft: 'q' })).toBeNull()
288
+ })
289
+ })
290
+
291
+ describe('CategoryFilter2 — observable listing states (task 4.5)', () => {
292
+ it('shows skeleton placeholders while loading instead of products', () => {
293
+ const html = renderToStaticMarkup(<CategoryFilter2 products={products} status={{ state: 'loading' }} />)
294
+ expect(html).toContain('data-slot="skeleton"')
295
+ expect(html).toContain('aria-busy="true"')
296
+ expect(html).not.toContain('Pantalla Samsung Galaxy A54 OLED')
297
+ })
298
+
299
+ it('renders a clearable empty state, distinct from an error', () => {
300
+ const html = renderToStaticMarkup(
301
+ <CategoryFilter2 products={[]} status={{ state: 'empty' }} onClearFilters={() => {}} />,
302
+ )
303
+ expect(html).toContain('Sin resultados')
304
+ expect(html).toContain('Limpiar filtros')
305
+ expect(html).not.toContain('Reintentar')
306
+ })
307
+
308
+ it('renders an invalid-query repair state naming the invalid fields', () => {
309
+ const html = renderToStaticMarkup(
310
+ <CategoryFilter2
311
+ products={[]}
312
+ status={{ state: 'invalid-query', error: { code: 'invalid-query', fields: ['page'] } }}
313
+ onRepair={() => {}}
314
+ />,
315
+ )
316
+ expect(html).toContain('Consulta no válida')
317
+ expect(html).toContain('page')
318
+ expect(html).toContain('Corregir búsqueda')
319
+ })
320
+
321
+ it('renders an unsupported-query repair state naming the unsupported fields', () => {
322
+ const html = renderToStaticMarkup(
323
+ <CategoryFilter2
324
+ products={[]}
325
+ status={{ state: 'unsupported-query', error: { code: 'unsupported-query', fields: ['priceMin'] } }}
326
+ onRepair={() => {}}
327
+ />,
328
+ )
329
+ expect(html).toContain('Filtro no disponible')
330
+ expect(html).toContain('priceMin')
331
+ })
332
+
333
+ it('renders an unmounted-capability repair state naming the missing feature', () => {
334
+ const html = renderToStaticMarkup(
335
+ <CategoryFilter2
336
+ products={[]}
337
+ status={{
338
+ state: 'unmounted-capability',
339
+ error: { code: 'unmounted-capability', fields: ['q'], feature: 'catalog-search' },
340
+ }}
341
+ onRepair={() => {}}
342
+ />,
343
+ )
344
+ expect(html).toContain('Función no disponible')
345
+ expect(html).toContain('catalog-search')
346
+ })
347
+
348
+ it('renders an error state with a retry action and the failure message', () => {
349
+ const html = renderToStaticMarkup(
350
+ <CategoryFilter2 products={[]} status={{ state: 'error', message: 'Network down' }} onRetry={() => {}} />,
351
+ )
352
+ expect(html).toContain('Network down')
353
+ expect(html).toContain('Reintentar')
354
+ })
355
+
356
+ it('renders an accessible removable chip per active filter', () => {
357
+ const html = renderToStaticMarkup(<CategoryFilter2 products={products} activeFilters={activeChips} />)
358
+ expect(html).toContain('aria-label="Filtros activos"')
359
+ expect(html).toContain('pantalla')
360
+ expect(html).toContain('Pantallas')
361
+ expect(html).toContain('aria-label="Quitar filtro: Pantallas"')
362
+ })
363
+
364
+ it('keeps the legacy ready surface when no status is supplied', () => {
365
+ const html = renderToStaticMarkup(<CategoryFilter2 products={products} sortOptions={advertisedSorts} />)
366
+ expect(html).toContain('Pantalla Samsung Galaxy A54 OLED')
367
+ })
368
+ })
369
+
370
+ describe('CategoryFilter2StatusView — state and chip interactions (task 4.5)', () => {
371
+ it('removes exactly the clicked chip and preserves the others', () => {
372
+ const onRemoveFilter = vi.fn()
373
+ const tree = CategoryFilter2StatusView({
374
+ status: { state: 'ready' },
375
+ activeFilters: activeChips,
376
+ onRemoveFilter,
377
+ })
378
+
379
+ const removeButtons = findAllByProp(tree, 'data-action', 'remove-filter')
380
+ expect(removeButtons).toHaveLength(3)
381
+
382
+ const categoryButton = removeButtons.find(button => button.props['data-filter-id'] === 'category:pantallas')
383
+ categoryButton?.props.onClick?.()
384
+
385
+ expect(onRemoveFilter).toHaveBeenCalledTimes(1)
386
+ expect(onRemoveFilter).toHaveBeenCalledWith(activeChips[1])
387
+ })
388
+
389
+ it('repairs by emitting the exact bad fields of the typed error', () => {
390
+ const onRepair = vi.fn()
391
+ const tree = CategoryFilter2StatusView({
392
+ status: {
393
+ state: 'unsupported-query',
394
+ error: { code: 'unsupported-query', fields: ['priceMin', 'inStock'], message: 'Unsupported' },
395
+ },
396
+ onRepair,
397
+ })
398
+
399
+ const repair = findAllByProp(tree, 'data-action', 'repair')
400
+ expect(repair).toHaveLength(1)
401
+ repair[0].props.onClick?.()
402
+
403
+ expect(onRepair).toHaveBeenCalledWith(['priceMin', 'inStock'])
404
+ })
405
+
406
+ it('retries through the callback without emitting any query mutation', () => {
407
+ const onRetry = vi.fn()
408
+ const tree = CategoryFilter2StatusView({ status: { state: 'error' }, onRetry })
409
+
410
+ const retry = findAllByProp(tree, 'data-action', 'retry')
411
+ retry[0].props.onClick?.()
412
+
413
+ expect(onRetry).toHaveBeenCalledTimes(1)
414
+ expect(onRetry).toHaveBeenCalledWith()
415
+ })
416
+
417
+ it('clears active filters from the empty state', () => {
418
+ const onClearFilters = vi.fn()
419
+ const tree = CategoryFilter2StatusView({
420
+ status: { state: 'empty' },
421
+ activeFilters: activeChips,
422
+ onClearFilters,
423
+ })
424
+
425
+ const clear = findAllByProp(tree, 'data-action', 'clear-filters')
426
+ expect(clear).toHaveLength(1)
427
+ clear[0].props.onClick?.()
428
+
429
+ expect(onClearFilters).toHaveBeenCalledTimes(1)
430
+ })
431
+
432
+ it('offers no repair/retry action when the host binds no callback', () => {
433
+ const tree = CategoryFilter2StatusView({
434
+ status: { state: 'invalid-query', error: { code: 'invalid-query', fields: ['page'] } },
435
+ })
436
+ expect(findAllByProp(tree, 'data-action', 'repair')).toHaveLength(0)
437
+ })
438
+ })
439
+
440
+ describe('CategoryFilter2 — keyboard, labels and mobile sheet (task 4.6)', () => {
441
+ it('renders pagination as native buttons (keyboard-operable) with aria-current, not href-less anchors', () => {
442
+ const html = renderToStaticMarkup(
443
+ <CategoryFilter2 products={products} pagination={[1, 2, 3]} selectedPage={2} sortOptions={advertisedSorts} />,
444
+ )
445
+ // A bare `<a>` without href is not focusable and not a link role; the control
446
+ // must be a native button so it is reachable and operable by keyboard.
447
+ expect(html).toMatch(/<button[^>]*data-slot="pagination-link"/)
448
+ expect(html).not.toMatch(/<a[^>]*data-slot="pagination-link"/)
449
+ expect(countOccurrences(html, 'data-slot="pagination-link"')).toBe(3)
450
+ expect(countOccurrences(html, 'aria-current="page"')).toBe(1)
451
+ })
452
+
453
+ it('shows no pagination and no product count when the backend supplies no total (task 7.6)', () => {
454
+ // `pagination` is derived by the runtime seam from the backend `total`; an
455
+ // absent total yields `[]`, and the block renders no page links and no
456
+ // manufactured count.
457
+ const html = renderToStaticMarkup(
458
+ <CategoryFilter2 products={products} pagination={[]} sortOptions={advertisedSorts} />,
459
+ )
460
+ expect(countOccurrences(html, 'data-slot="pagination-link"')).toBe(0)
461
+ expect(html).not.toContain('aria-label="Paginación"')
462
+ expect(html).not.toMatch(/\d+\s*(producto|resultado)/i)
463
+ })
464
+
465
+ it('wires the mobile filter trigger as a dialog trigger so focus can return on close', () => {
466
+ const html = renderToStaticMarkup(<CategoryFilter2 products={products} />)
467
+ expect(html).toContain('aria-haspopup="dialog"')
468
+ expect(html).toContain('aria-expanded="false"')
469
+ expect(html).toMatch(/<button[^>]*lg:hidden[^>]*>[\s\S]*Filtros/)
470
+ })
471
+
472
+ it('labels the search, sort, view and chip controls', () => {
473
+ const html = renderToStaticMarkup(
474
+ <CategoryFilter2
475
+ products={products}
476
+ sortOptions={advertisedSorts}
477
+ viewOptions={[10, 25]}
478
+ showSearch
479
+ searchValue='pantalla'
480
+ activeFilters={activeChips}
481
+ />,
482
+ )
483
+ expect(html).toContain('aria-label="Buscar"')
484
+ expect(html).toContain('aria-label="Ordenar por"')
485
+ expect(html).toContain('aria-label="Ver"')
486
+ expect(html).toContain('aria-label="Filtros activos"')
487
+ expect(html).toContain('aria-label="Quitar filtro: Pantallas"')
488
+ })
489
+
490
+ it('labels the filter-panel inputs and checkboxes it renders', () => {
491
+ const html = renderToStaticMarkup(
492
+ <CategoryFilter2
493
+ products={products}
494
+ categories={[{ id: 'pantallas', label: 'Pantallas' }]}
495
+ collections={[{ id: 'ofertas', label: 'Ofertas' }]}
496
+ priceRange={{ min: 0, max: 100, step: 5 }}
497
+ />,
498
+ )
499
+ expect(html).toContain('>Mínimo</label>')
500
+ expect(html).toContain('>Máximo</label>')
501
+ expect(html).toMatch(/<label[^>]*for="[^"]*"[^>]*>\s*Pantallas\s*<\/label>/)
502
+ expect(html).toMatch(/<label[^>]*for="[^"]*"[^>]*>\s*Ofertas\s*<\/label>/)
503
+ })
504
+
505
+ it('adds no positive tabindex and keeps search and pagination in the natural tab order', () => {
506
+ const html = renderToStaticMarkup(
507
+ <CategoryFilter2 products={products} sortOptions={advertisedSorts} pagination={[1, 2]} showSearch />,
508
+ )
509
+ expect(html).not.toMatch(/tabindex="[1-9]/)
510
+ expect(html).not.toMatch(/<input[^>]*type="search"[^>]*tabindex="-1"/)
511
+ expect(html).not.toMatch(/<button[^>]*data-slot="pagination-link"[^>]*tabindex="-1"/)
512
+ })
513
+ })