@thorprovider/blocks 0.1.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +108 -23
  3. package/docs/standards/data-contracts.md +13 -2
  4. package/package.json +26 -10
  5. package/scripts/fix-esm-extensions.mjs +40 -0
  6. package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
  7. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  8. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  9. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  10. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  11. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  12. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  13. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  14. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  15. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  16. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  17. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  18. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  19. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  20. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  21. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  22. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  23. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  24. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  25. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  26. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  27. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  28. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  29. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  30. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  31. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  32. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  33. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  34. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  35. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  36. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
  37. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  38. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  39. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  40. package/src/blocks/marketing/features/feature.tsx +195 -0
  41. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
  42. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
  47. package/src/index.ts +93 -46
  48. package/src/lib/contracts-curated.test.ts +42 -24
  49. package/src/lib/contracts.test.ts +143 -2
  50. package/src/lib/contracts.ts +395 -13
  51. package/src/ui/button-group.tsx +1 -1
  52. package/src/ui/carousel.tsx +1 -1
  53. package/src/ui/dialog.tsx +1 -1
  54. package/src/ui/field.tsx +2 -2
  55. package/src/ui/input-group.tsx +3 -3
  56. package/src/ui/pagination.tsx +1 -1
  57. package/src/ui/sidebar.tsx +7 -7
  58. package/src/ui/toggle-group.tsx +1 -1
  59. package/tsconfig.build.json +12 -0
  60. package/tsup.config.ts +11 -7
  61. package/.turbo/turbo-build.log +0 -24
  62. package/.turbo/turbo-test.log +0 -37
  63. package/.turbo/turbo-type-check.log +0 -0
  64. package/dist/.tsbuildinfo +0 -1
  65. package/dist/contracts.d.mts +0 -1584
  66. package/dist/contracts.d.ts +0 -1584
  67. package/dist/contracts.js +0 -390
  68. package/dist/contracts.js.map +0 -1
  69. package/dist/contracts.mjs +0 -334
  70. package/dist/contracts.mjs.map +0 -1
  71. package/dist/index.d.mts +0 -2786
  72. package/dist/index.d.ts +0 -2786
  73. package/dist/index.js +0 -17139
  74. package/dist/index.js.map +0 -1
  75. package/dist/index.mjs +0 -17059
  76. package/dist/index.mjs.map +0 -1
  77. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  78. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  79. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  80. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  81. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  82. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  83. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
  84. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  85. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  86. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  87. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  88. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  89. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  90. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  91. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  92. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  93. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  94. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  95. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  96. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  97. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  98. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  99. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
  105. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
  106. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  107. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  108. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  109. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  110. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  111. package/src/blocks/marketing/features/feature-2.tsx +0 -82
@@ -0,0 +1,266 @@
1
+ // @vitest-environment jsdom
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { beforeAll, describe, expect, it, vi } from 'vitest'
4
+ import { render, screen } from '@testing-library/react'
5
+ import userEvent from '@testing-library/user-event'
6
+ import { ProductCategory } from './product-category'
7
+ import type { ProductCategoryItem } from '../../../lib/contracts'
8
+
9
+ beforeAll(() => {
10
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
11
+ })
12
+
13
+ const base: ProductCategoryItem = {
14
+ id: 'pcat_herramientas',
15
+ handle: 'herramientas',
16
+ name: 'Herramientas',
17
+ description: 'Taladros y accesorios',
18
+ image: '/products/drill.png',
19
+ trending: false,
20
+ }
21
+
22
+ const renderGrid = (category: ProductCategoryItem, extra: Record<string, unknown> = {}) =>
23
+ renderToStaticMarkup(<ProductCategory categories={[category]} {...extra} />)
24
+
25
+ // The count is the only text node that ends in "items" (the `items-center`
26
+ // utility lives in a class attribute and is never followed by `</span>`).
27
+ const hasGridCount = (html: string) => html.includes('items</span>')
28
+
29
+ describe('ProductCategory — optional itemCount (grid)', () => {
30
+ it('renders no count when itemCount is omitted (unknown projection)', () => {
31
+ const html = renderGrid(base, { itemsLabel: 'items' })
32
+ expect(hasGridCount(html)).toBe(false)
33
+ // The tile itself still renders.
34
+ expect(html).toContain('Herramientas')
35
+ })
36
+
37
+ it('preserves a real zero count', () => {
38
+ expect(renderGrid({ ...base, itemCount: 0 }, { itemsLabel: 'items' })).toContain('0 items</span>')
39
+ })
40
+
41
+ it('renders a positive count', () => {
42
+ expect(renderGrid({ ...base, itemCount: 7 }, { itemsLabel: 'items' })).toContain('7 items</span>')
43
+ })
44
+
45
+ it('renders the bare number when no items label is supplied', () => {
46
+ const html = renderGrid({ ...base, itemCount: 7 })
47
+ expect(html).toContain('>7<')
48
+ expect(html).not.toContain('items</span>')
49
+ })
50
+
51
+ it('omits a negative count', () => {
52
+ expect(hasGridCount(renderGrid({ ...base, itemCount: -1 }, { itemsLabel: 'items' }))).toBe(false)
53
+ })
54
+
55
+ it('omits a non-finite count', () => {
56
+ expect(hasGridCount(renderGrid({ ...base, itemCount: Number.NaN }, { itemsLabel: 'items' }))).toBe(false)
57
+ expect(hasGridCount(renderGrid({ ...base, itemCount: Number.POSITIVE_INFINITY }, { itemsLabel: 'items' }))).toBe(
58
+ false,
59
+ )
60
+ })
61
+ })
62
+
63
+ describe('ProductCategory — no demo defaults or hardcoded locale copy', () => {
64
+ it('ships no copy when the host supplies none', () => {
65
+ const html = renderGrid(base)
66
+ expect(html).toContain('Herramientas')
67
+ expect(html).not.toContain('Browse')
68
+ expect(html).not.toContain('Trending')
69
+ expect(html).not.toContain('items</span>')
70
+ })
71
+
72
+ it('renders the browse action only when a handler and label are supplied', () => {
73
+ expect(renderGrid({ ...base, trending: true })).not.toContain('Browse')
74
+ expect(renderGrid(base, { onBrowse: () => {}, browseLabel: 'Explorar' })).toContain('Explorar')
75
+ })
76
+
77
+ it('renders the trending badge only when a label is supplied', () => {
78
+ expect(renderGrid({ ...base, trending: true })).not.toContain('Trending')
79
+ expect(renderGrid({ ...base, trending: true }, { trendingLabel: 'Tendencia' })).toContain('Tendencia')
80
+ })
81
+ })
82
+
83
+ describe('ProductCategory — honest imagery', () => {
84
+ it('never emits src="" and falls back to a neutral placeholder', () => {
85
+ const html = renderGrid({ ...base, image: undefined })
86
+ expect(html).not.toContain('src=""')
87
+ expect(html).toContain('data:image/svg+xml')
88
+ })
89
+
90
+ it('treats a blank image as missing', () => {
91
+ expect(renderGrid({ ...base, image: ' ' })).toContain('data:image/svg+xml')
92
+ })
93
+ })
94
+
95
+ describe('ProductCategory — bounded presentations', () => {
96
+ it('icons presentation renders no image and a different structure than grid', () => {
97
+ const category: ProductCategoryItem = { id: 'pcat_x', name: 'Categoría', icon: 'shirt' }
98
+ const icons = renderToStaticMarkup(<ProductCategory categories={[category]} presentation='icons' />)
99
+ expect(icons).toContain('Categoría')
100
+ expect(icons).not.toContain('<img')
101
+ expect(icons).not.toContain('aspect-[5/4]')
102
+ })
103
+
104
+ it('icons presentation ignores category counts (no demo metrics)', () => {
105
+ const category: ProductCategoryItem = { id: 'pcat_x', name: 'Categoría', icon: 'shirt', itemCount: 42 }
106
+ const icons = renderToStaticMarkup(
107
+ <ProductCategory categories={[category]} presentation='icons' itemsLabel='items' />,
108
+ )
109
+ expect(icons).not.toContain('42')
110
+ expect(icons).not.toContain('items</span>')
111
+ expect(icons).not.toContain('items</div>')
112
+ })
113
+
114
+ it('falls back to a neutral icon for an unknown or absent key', () => {
115
+ const unknown = renderToStaticMarkup(
116
+ <ProductCategory categories={[{ id: 'pcat_x', name: 'X', icon: 'not-a-real-icon' }]} presentation='icons' />,
117
+ )
118
+ const absent = renderToStaticMarkup(
119
+ <ProductCategory categories={[{ id: 'pcat_y', name: 'Y' }]} presentation='icons' />,
120
+ )
121
+ expect(unknown).toContain('X')
122
+ expect(absent).toContain('Y')
123
+ })
124
+ })
125
+
126
+ describe('ProductCategory — icons: keyboard-operable interactive category', () => {
127
+ const category: ProductCategoryItem = { id: 'pcat_herramientas', name: 'Herramientas', icon: 'wrench' }
128
+
129
+ it('renders a native button whose accessible name is the category name', () => {
130
+ render(<ProductCategory categories={[category]} presentation='icons' onBrowse={() => {}} />)
131
+ const control = screen.getByRole('button', { name: 'Herramientas' })
132
+ expect(control.tagName).toBe('BUTTON')
133
+ expect(control.getAttribute('type')).toBe('button')
134
+ })
135
+
136
+ it('is focusable and activates with the Enter key, reporting onBrowse', async () => {
137
+ const user = userEvent.setup()
138
+ const onBrowse = vi.fn()
139
+ render(<ProductCategory categories={[category]} presentation='icons' onBrowse={onBrowse} />)
140
+ await user.tab()
141
+ const control = screen.getByRole('button', { name: 'Herramientas' })
142
+ expect(document.activeElement).toBe(control)
143
+ await user.keyboard('{Enter}')
144
+ expect(onBrowse).toHaveBeenCalledWith(category)
145
+ })
146
+
147
+ it('activates with the Space key, reporting onBrowse', async () => {
148
+ const user = userEvent.setup()
149
+ const onBrowse = vi.fn()
150
+ render(<ProductCategory categories={[category]} presentation='icons' onBrowse={onBrowse} />)
151
+ await user.tab()
152
+ await user.keyboard(' ')
153
+ expect(onBrowse).toHaveBeenCalledWith(category)
154
+ })
155
+
156
+ it('is not a focusable control when no browse handler is supplied', async () => {
157
+ const user = userEvent.setup()
158
+ render(<ProductCategory categories={[category]} presentation='icons' />)
159
+ expect(screen.queryByRole('button')).toBeNull()
160
+ await user.tab()
161
+ expect(document.activeElement).toBe(document.body)
162
+ expect(screen.getByText('Herramientas')).toBeTruthy()
163
+ })
164
+
165
+ it('uses an inset focus ring so the indicator is not clipped by the card', () => {
166
+ render(<ProductCategory categories={[category]} presentation='icons' onBrowse={() => {}} />)
167
+ const control = screen.getByRole('button', { name: 'Herramientas' })
168
+ expect(control.className).toContain('focus-visible:inset-ring-2')
169
+ expect(control.className).toContain('focus-visible:inset-ring-ring')
170
+ expect(control.className).not.toContain('focus-visible:ring-2')
171
+ })
172
+ })
173
+
174
+ describe('ProductCategory — grid/list browse controls expose the category name', () => {
175
+ const category: ProductCategoryItem = { id: 'pcat_herramientas', name: 'Herramientas', image: '/products/drill.png' }
176
+
177
+ it('names the grid browse control with the host label and the category name', () => {
178
+ render(
179
+ <ProductCategory categories={[category]} presentation='grid' onBrowse={() => {}} browseLabel='Explorar' />,
180
+ )
181
+ const control = screen.getByRole('button', { name: 'Explorar Herramientas' })
182
+ expect(control.textContent).toContain('Explorar')
183
+ })
184
+
185
+ it('names the list browse control with the host label and the category name', () => {
186
+ render(
187
+ <ProductCategory categories={[category]} presentation='list' onBrowse={() => {}} browseLabel='Explorar' />,
188
+ )
189
+ const control = screen.getByRole('button', { name: 'Explorar Herramientas' })
190
+ expect(control.textContent).toContain('Explorar')
191
+ })
192
+ })
193
+
194
+ describe('ProductCategory — list presentation owns no filtering', () => {
195
+ const categories: ProductCategoryItem[] = [
196
+ { id: 'pcat_shirts', name: 'Camisas', icon: 'shirt', subcategories: ['Manga larga', 'Manga corta'], itemCount: 12 },
197
+ { id: 'pcat_boots', name: 'Botas', icon: 'shirt', featured: true },
198
+ ]
199
+
200
+ it('renders only the categories the host supplies for the current query', () => {
201
+ const html = renderToStaticMarkup(
202
+ <ProductCategory categories={[categories[1]]} presentation='list' query='bot' onQueryChange={() => {}} />,
203
+ )
204
+ expect(html).toContain('Botas')
205
+ expect(html).not.toContain('Camisas')
206
+ })
207
+
208
+ it('renders subcategory chips and the count with the host label', () => {
209
+ const html = renderToStaticMarkup(
210
+ <ProductCategory categories={[categories[0]]} presentation='list' itemsLabel='productos' />,
211
+ )
212
+ expect(html).toContain('Manga larga')
213
+ expect(html).toContain('12 productos')
214
+ })
215
+
216
+ it('renders the featured badge only when a label is supplied', () => {
217
+ const without = renderToStaticMarkup(<ProductCategory categories={[categories[1]]} presentation='list' />)
218
+ const with_ = renderToStaticMarkup(
219
+ <ProductCategory categories={[categories[1]]} presentation='list' featuredLabel='Destacado' />,
220
+ )
221
+ expect(without).not.toContain('Destacado')
222
+ expect(with_).toContain('Destacado')
223
+ })
224
+
225
+ it('reflects the controlled query in the search input', () => {
226
+ const html = renderToStaticMarkup(
227
+ <ProductCategory categories={categories} presentation='list' query='cam' onQueryChange={() => {}} />,
228
+ )
229
+ expect(html).toContain('value="cam"')
230
+ })
231
+
232
+ it('shows the no-results copy for an empty searched list and the empty copy otherwise', () => {
233
+ const searched = renderToStaticMarkup(
234
+ <ProductCategory
235
+ categories={[]}
236
+ presentation='list'
237
+ query='zzz'
238
+ onQueryChange={() => {}}
239
+ noResultsLabel='Sin resultados'
240
+ emptyLabel='Sin categorías'
241
+ />,
242
+ )
243
+ const empty = renderToStaticMarkup(
244
+ <ProductCategory categories={[]} presentation='list' emptyLabel='Sin categorías' noResultsLabel='Sin resultados' />,
245
+ )
246
+ expect(searched).toContain('Sin resultados')
247
+ expect(empty).toContain('Sin categorías')
248
+ })
249
+ })
250
+
251
+ describe('ProductCategory — reusable across sectors without demo content', () => {
252
+ it('renders distinct sector datasets through the same component and contract', () => {
253
+ const fashion: ProductCategoryItem[] = [
254
+ { id: 'pcat_fashion_shirts', name: 'Camisas', icon: 'shirt' },
255
+ ]
256
+ const electronics: ProductCategoryItem[] = [
257
+ { id: 'pcat_electronics_laptops', name: 'Portátiles', icon: 'laptop' },
258
+ ]
259
+ const fashionHtml = renderToStaticMarkup(<ProductCategory categories={fashion} presentation='icons' />)
260
+ const electronicsHtml = renderToStaticMarkup(<ProductCategory categories={electronics} presentation='icons' />)
261
+ // Same component and contract; only the supplied data differs (no branch on sector).
262
+ expect(fashionHtml).toContain('Camisas')
263
+ expect(electronicsHtml).toContain('Portátiles')
264
+ expect(fashionHtml).not.toContain('Portátiles')
265
+ })
266
+ })