@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,174 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { Breadcrumb, breadcrumbItemSchema, breadcrumbPropsSchema } from './breadcrumb'
5
+
6
+ function countNestedListItems(html: string): number {
7
+ const tokens = html.match(/<\/?li\b[^>]*>/g) ?? []
8
+ let depth = 0
9
+ let nested = 0
10
+ for (const token of tokens) {
11
+ if (token.startsWith('</')) {
12
+ depth = Math.max(0, depth - 1)
13
+ } else {
14
+ if (depth > 0) nested += 1
15
+ depth += 1
16
+ }
17
+ }
18
+ return nested
19
+ }
20
+
21
+ describe('Breadcrumb', () => {
22
+ it('parses a valid props object', () => {
23
+ const result = breadcrumbPropsSchema.parse({
24
+ items: [{ label: 'Home', href: '/' }, { label: 'Current' }],
25
+ })
26
+ expect(result.items).toHaveLength(2)
27
+ })
28
+
29
+ it('rejects a maxItems below 2', () => {
30
+ expect(() =>
31
+ breadcrumbPropsSchema.parse({ items: [], maxItems: 1 }),
32
+ ).toThrow()
33
+ })
34
+
35
+ it('requires a label on each item', () => {
36
+ expect(() => breadcrumbItemSchema.parse({ href: '/' })).toThrow()
37
+ })
38
+
39
+ it('renders nothing when there are no items', () => {
40
+ expect(renderToStaticMarkup(<Breadcrumb items={[]} />)).toBe('')
41
+ })
42
+
43
+ it('renders nothing when every label is blank', () => {
44
+ const html = renderToStaticMarkup(
45
+ <Breadcrumb items={[{ label: ' ' }, { label: '' }]} />,
46
+ )
47
+ expect(html).toBe('')
48
+ })
49
+
50
+ it('renders a labelled landmark with the current item as a non-link', () => {
51
+ const html = renderToStaticMarkup(
52
+ <Breadcrumb
53
+ items={[
54
+ { label: 'Home', href: '/' },
55
+ { label: 'Tools', href: '/categories/tools' },
56
+ { label: 'Drill' },
57
+ ]}
58
+ />,
59
+ )
60
+ expect(html).toContain('aria-label="Breadcrumb"')
61
+ expect(html).toContain('aria-current="page"')
62
+ expect(html).toContain('href="/categories/tools"')
63
+ expect(html).not.toContain('href="Drill"')
64
+ expect(html).toContain('Drill')
65
+ })
66
+
67
+ it('honours a custom landmark label', () => {
68
+ const html = renderToStaticMarkup(
69
+ <Breadcrumb
70
+ ariaLabel="You are here"
71
+ items={[{ label: 'Home', href: '/' }, { label: 'Current' }]}
72
+ />,
73
+ )
74
+ expect(html).toContain('aria-label="You are here"')
75
+ })
76
+
77
+ it('wraps the trail in the shared page container', () => {
78
+ const html = renderToStaticMarkup(
79
+ <Breadcrumb items={[{ label: 'Home', href: '/' }, { label: 'Current' }]} />,
80
+ )
81
+ expect(html).toContain('mx-auto')
82
+ expect(html).toContain('max-w-7xl')
83
+ expect(html).toContain('px-4')
84
+ })
85
+
86
+ it('merges a custom className onto the container', () => {
87
+ const html = renderToStaticMarkup(
88
+ <Breadcrumb
89
+ className="bg-primary text-primary-foreground"
90
+ items={[{ label: 'Home', href: '/' }, { label: 'Current' }]}
91
+ />,
92
+ )
93
+ expect(html).toContain('bg-primary')
94
+ expect(html).toContain('max-w-7xl')
95
+ })
96
+
97
+ it('lets a custom className override the container utility', () => {
98
+ const html = renderToStaticMarkup(
99
+ <Breadcrumb
100
+ className="px-0"
101
+ items={[{ label: 'Home', href: '/' }, { label: 'Current' }]}
102
+ />,
103
+ )
104
+ expect(html).toContain('px-0')
105
+ expect(html).not.toContain('px-4')
106
+ })
107
+
108
+ it('collapses intermediate levels behind an ellipsis past maxItems', () => {
109
+ const html = renderToStaticMarkup(
110
+ <Breadcrumb
111
+ maxItems={3}
112
+ items={[
113
+ { label: 'Home', href: '/' },
114
+ { label: 'A', href: '/a' },
115
+ { label: 'B', href: '/b' },
116
+ { label: 'C', href: '/c' },
117
+ { label: 'Current' },
118
+ ]}
119
+ />,
120
+ )
121
+ expect(html).toContain('data-slot="breadcrumb-ellipsis"')
122
+ expect(html).toContain('Current')
123
+ expect(html).not.toContain('>A<')
124
+ })
125
+
126
+ it('truncates long labels', () => {
127
+ const html = renderToStaticMarkup(
128
+ <Breadcrumb
129
+ items={[
130
+ { label: 'Home', href: '/' },
131
+ { label: 'A very long category name that would overflow the trail' },
132
+ ]}
133
+ />,
134
+ )
135
+ expect(html).toContain('max-w-[24ch]')
136
+ expect(html).toContain('truncate')
137
+ })
138
+
139
+ it('renders a mobile back link to the parent and hides the full list at base', () => {
140
+ const html = renderToStaticMarkup(
141
+ <Breadcrumb
142
+ items={[
143
+ { label: 'Home', href: '/' },
144
+ { label: 'Tools', href: '/categories/tools' },
145
+ { label: 'Drill' },
146
+ ]}
147
+ />,
148
+ )
149
+ expect(html).toContain('md:hidden')
150
+ expect(html).toContain('href="/categories/tools"')
151
+ expect(html).toContain('hidden md:flex')
152
+ })
153
+
154
+ it('renders no back link for a single-level trail', () => {
155
+ const html = renderToStaticMarkup(
156
+ <Breadcrumb items={[{ label: 'Only' }]} />,
157
+ )
158
+ expect(html).not.toContain('md:hidden')
159
+ })
160
+
161
+ it('renders separators as siblings of items, never nesting list items', () => {
162
+ const html = renderToStaticMarkup(
163
+ <Breadcrumb
164
+ items={[
165
+ { label: 'Home', href: '/' },
166
+ { label: 'Tools', href: '/categories/tools' },
167
+ { label: 'Drill' },
168
+ ]}
169
+ />,
170
+ )
171
+ expect(html).toContain('data-slot="breadcrumb-separator"')
172
+ expect(countNestedListItems(html)).toBe(0)
173
+ })
174
+ })
@@ -0,0 +1,130 @@
1
+ import { Fragment } from 'react'
2
+ import { ChevronLeft } from 'lucide-react'
3
+ import { z } from 'zod'
4
+
5
+ import { cn } from '../../../lib/utils'
6
+ import {
7
+ Breadcrumb as BreadcrumbRoot,
8
+ BreadcrumbEllipsis,
9
+ BreadcrumbItem,
10
+ BreadcrumbLink,
11
+ BreadcrumbList,
12
+ BreadcrumbPage,
13
+ BreadcrumbSeparator,
14
+ } from '../../../ui/breadcrumb'
15
+
16
+ export const breadcrumbItemSchema = z.object({
17
+ label: z.string(),
18
+ href: z.string().optional(),
19
+ })
20
+
21
+ export type BreadcrumbItemData = z.infer<typeof breadcrumbItemSchema>
22
+
23
+ export const breadcrumbPropsSchema = z.object({
24
+ items: z.array(breadcrumbItemSchema),
25
+ maxItems: z.number().int().min(2).optional(),
26
+ ariaLabel: z.string().optional(),
27
+ backLabel: z.string().optional(),
28
+ className: z.string().optional(),
29
+ })
30
+
31
+ export type BreadcrumbProps = z.infer<typeof breadcrumbPropsSchema>
32
+
33
+ const DEFAULT_MAX_ITEMS = 4
34
+ const ELLIPSIS = Symbol('breadcrumb-ellipsis')
35
+
36
+ // Shared horizontal rhythm: the same max-width and responsive gutters the
37
+ // sibling blocks (category listing, product overview) use, so the trail aligns
38
+ // with the page content instead of sitting flush against the viewport edge.
39
+ const CONTAINER = 'mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8'
40
+
41
+ type DisplayEntry = BreadcrumbItemData | typeof ELLIPSIS
42
+
43
+ function collapseItems(
44
+ items: BreadcrumbItemData[],
45
+ maxItems: number,
46
+ ): DisplayEntry[] {
47
+ if (items.length <= maxItems) return items
48
+ return [items[0], ELLIPSIS, ...items.slice(items.length - (maxItems - 1))]
49
+ }
50
+
51
+ /**
52
+ * Breadcrumb trail for a real navigation path (Home → … → current).
53
+ *
54
+ * Framework-agnostic and prop-driven: plain anchors (no router) and the shared
55
+ * shadcn breadcrumb primitives. The last item is the current page and is never
56
+ * a link (`aria-current="page"`); earlier items link only when an `href` is
57
+ * given. Long labels truncate, and trails longer than `maxItems` collapse the
58
+ * intermediate levels behind an ellipsis. On mobile the full list is replaced
59
+ * by a single back link to the parent, so a wide trail never overflows.
60
+ *
61
+ * Renders nothing when there are no labelled items.
62
+ */
63
+ export function Breadcrumb({
64
+ items,
65
+ maxItems = DEFAULT_MAX_ITEMS,
66
+ ariaLabel = 'Breadcrumb',
67
+ backLabel,
68
+ className,
69
+ }: BreadcrumbProps) {
70
+ const clean = items.filter((item) => item.label.trim().length > 0)
71
+ if (clean.length === 0) return null
72
+
73
+ const parent = clean.length > 1 ? clean[clean.length - 2] : undefined
74
+ const entries = collapseItems(clean, maxItems)
75
+
76
+ return (
77
+ <BreadcrumbRoot aria-label={ariaLabel} className={cn(CONTAINER, className)}>
78
+ {parent ? (
79
+ <a
80
+ href={parent.href ?? '#'}
81
+ className="inline-flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground md:hidden"
82
+ >
83
+ <ChevronLeft className="size-4" aria-hidden="true" />
84
+ <span className="truncate">{backLabel ?? parent.label}</span>
85
+ </a>
86
+ ) : null}
87
+ <BreadcrumbList className="hidden md:flex">
88
+ {entries.map((entry, index) => {
89
+ const isLast = index === entries.length - 1
90
+ const key =
91
+ entry === ELLIPSIS
92
+ ? 'ellipsis'
93
+ : `${index}:${entry.href ?? ''}:${entry.label}`
94
+ return (
95
+ <Fragment key={key}>
96
+ <BreadcrumbItem>
97
+ {entry === ELLIPSIS ? (
98
+ <BreadcrumbEllipsis />
99
+ ) : isLast ? (
100
+ <BreadcrumbPage
101
+ className="max-w-[24ch] truncate"
102
+ title={entry.label}
103
+ >
104
+ {entry.label}
105
+ </BreadcrumbPage>
106
+ ) : entry.href ? (
107
+ <BreadcrumbLink
108
+ href={entry.href}
109
+ className="max-w-[24ch] truncate"
110
+ title={entry.label}
111
+ >
112
+ {entry.label}
113
+ </BreadcrumbLink>
114
+ ) : (
115
+ <span
116
+ className="max-w-[24ch] truncate"
117
+ title={entry.label}
118
+ >
119
+ {entry.label}
120
+ </span>
121
+ )}
122
+ </BreadcrumbItem>
123
+ {!isLast ? <BreadcrumbSeparator /> : null}
124
+ </Fragment>
125
+ )
126
+ })}
127
+ </BreadcrumbList>
128
+ </BreadcrumbRoot>
129
+ )
130
+ }
@@ -0,0 +1,74 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import {
5
+ MobileBottomNav,
6
+ mobileBottomNavItemSchema,
7
+ mobileBottomNavPropsSchema,
8
+ } from './mobile-bottom-nav'
9
+
10
+ describe('MobileBottomNav', () => {
11
+ it('parses a valid props object', () => {
12
+ const result = mobileBottomNavPropsSchema.parse({
13
+ items: [{ label: 'Home', href: '/', icon: 'home' }],
14
+ })
15
+ expect(result.items).toHaveLength(1)
16
+ })
17
+
18
+ it('requires a label and href on each item', () => {
19
+ expect(() => mobileBottomNavItemSchema.parse({ label: 'Home' })).toThrow()
20
+ })
21
+
22
+ it('rejects an unknown icon name at the boundary', () => {
23
+ expect(() =>
24
+ mobileBottomNavItemSchema.parse({
25
+ label: 'Home',
26
+ href: '/',
27
+ icon: 'not-an-icon',
28
+ }),
29
+ ).toThrow()
30
+ })
31
+
32
+ it('renders nothing when there are no destinations', () => {
33
+ expect(renderToStaticMarkup(<MobileBottomNav items={[]} />)).toBe('')
34
+ })
35
+
36
+ it('renders a labelled, mobile-only landmark offset by the safe area', () => {
37
+ const html = renderToStaticMarkup(
38
+ <MobileBottomNav
39
+ items={[
40
+ { label: 'Home', href: '/', icon: 'home' },
41
+ { label: 'Store', href: '/categories/all', icon: 'store' },
42
+ ]}
43
+ />,
44
+ )
45
+ expect(html).toContain('aria-label="Primary"')
46
+ expect(html).toContain('md:hidden')
47
+ expect(html).toContain('env(safe-area-inset-bottom)')
48
+ expect(html).toContain('h-14')
49
+ })
50
+
51
+ it('marks the current destination accessibly and with a non-colour cue', () => {
52
+ const html = renderToStaticMarkup(
53
+ <MobileBottomNav
54
+ currentHref="/categories/all"
55
+ items={[
56
+ { label: 'Home', href: '/', icon: 'home' },
57
+ { label: 'Store', href: '/categories/all', icon: 'store' },
58
+ ]}
59
+ />,
60
+ )
61
+ expect(html).toContain('aria-current="page"')
62
+ expect(html).toContain('font-semibold')
63
+ })
64
+
65
+ it('marks no destination when the current href matches none', () => {
66
+ const html = renderToStaticMarkup(
67
+ <MobileBottomNav
68
+ currentHref="/unknown"
69
+ items={[{ label: 'Home', href: '/', icon: 'home' }]}
70
+ />,
71
+ )
72
+ expect(html).not.toContain('aria-current')
73
+ })
74
+ })
@@ -0,0 +1,78 @@
1
+ import { z } from 'zod'
2
+
3
+ import { cn } from '../../../lib/utils'
4
+ import { type CategoryIconName, CategoryIcon, categoryIconNameSchema } from './category-icon'
5
+
6
+ export const mobileBottomNavItemSchema = z.object({
7
+ id: z.string().optional(),
8
+ label: z.string(),
9
+ href: z.string(),
10
+ icon: categoryIconNameSchema.optional(),
11
+ })
12
+
13
+ export type MobileBottomNavItem = z.infer<typeof mobileBottomNavItemSchema>
14
+
15
+ export const mobileBottomNavPropsSchema = z.object({
16
+ items: z.array(mobileBottomNavItemSchema),
17
+ currentHref: z.string().optional(),
18
+ ariaLabel: z.string().optional(),
19
+ className: z.string().optional(),
20
+ })
21
+
22
+ export type MobileBottomNavProps = z.infer<typeof mobileBottomNavPropsSchema>
23
+
24
+ /**
25
+ * Mobile-only bottom navigation offering the primary destinations.
26
+ *
27
+ * Framework-agnostic and prop-driven: plain anchors (no router). Hidden at
28
+ * `md` and up; offsets the iOS home indicator via `env(safe-area-inset-bottom)`;
29
+ * each destination meets the 44px minimum touch target. The current
30
+ * destination is marked with `aria-current="page"` and a non-colour cue (weight
31
+ * + indicator bar), never colour alone.
32
+ */
33
+ export function MobileBottomNav({
34
+ items,
35
+ currentHref,
36
+ ariaLabel = 'Primary',
37
+ className,
38
+ }: MobileBottomNavProps) {
39
+ if (items.length === 0) return null
40
+
41
+ return (
42
+ <nav
43
+ aria-label={ariaLabel}
44
+ className={cn(
45
+ 'fixed inset-x-0 bottom-0 z-40 border-t bg-card text-foreground md:hidden',
46
+ className,
47
+ )}
48
+ style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
49
+ >
50
+ <ul className="flex">
51
+ {items.map((item) => {
52
+ const active = currentHref !== undefined && item.href === currentHref
53
+ return (
54
+ <li key={item.id ?? item.href} className="relative flex-1">
55
+ {active ? (
56
+ <span
57
+ aria-hidden="true"
58
+ className="absolute inset-x-0 top-0 mx-auto h-0.5 w-8 rounded-full bg-primary"
59
+ />
60
+ ) : null}
61
+ <a
62
+ href={item.href}
63
+ aria-current={active ? 'page' : undefined}
64
+ className={cn(
65
+ 'flex h-14 flex-col items-center justify-center gap-0.5 text-xs text-muted-foreground',
66
+ active && 'font-semibold text-primary',
67
+ )}
68
+ >
69
+ <CategoryIcon name={item.icon} className="size-5" />
70
+ <span className="truncate">{item.label}</span>
71
+ </a>
72
+ </li>
73
+ )
74
+ })}
75
+ </ul>
76
+ </nav>
77
+ )
78
+ }
@@ -0,0 +1,35 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { SkipToContent, skipToContentPropsSchema } from './skip-to-content'
5
+
6
+ describe('SkipToContent', () => {
7
+ it('parses a valid props object', () => {
8
+ const result = skipToContentPropsSchema.parse({ targetId: 'main' })
9
+ expect(result.targetId).toBe('main')
10
+ })
11
+
12
+ it('requires a targetId', () => {
13
+ expect(() => skipToContentPropsSchema.parse({})).toThrow()
14
+ })
15
+
16
+ it('renders a hidden anchor targeting the content id', () => {
17
+ const html = renderToStaticMarkup(<SkipToContent targetId="main" />)
18
+ expect(html).toContain('href="#main"')
19
+ expect(html).toContain('sr-only')
20
+ expect(html).toContain('Skip to content')
21
+ })
22
+
23
+ it('honours a custom label', () => {
24
+ const html = renderToStaticMarkup(
25
+ <SkipToContent targetId="content" label="Saltar al contenido" />,
26
+ )
27
+ expect(html).toContain('Saltar al contenido')
28
+ expect(html).toContain('href="#content"')
29
+ })
30
+
31
+ it('becomes visible on focus', () => {
32
+ const html = renderToStaticMarkup(<SkipToContent targetId="main" />)
33
+ expect(html).toContain('focus:not-sr-only')
34
+ })
35
+ })
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod'
2
+
3
+ import { cn } from '../../../lib/utils'
4
+
5
+ export const skipToContentPropsSchema = z.object({
6
+ targetId: z.string(),
7
+ label: z.string().optional(),
8
+ className: z.string().optional(),
9
+ })
10
+
11
+ export type SkipToContentProps = z.infer<typeof skipToContentPropsSchema>
12
+
13
+ /**
14
+ * Skip-to-content link (WCAG 2.4.1 Bypass Blocks).
15
+ *
16
+ * Framework-agnostic and prop-driven: a plain anchor to the main content id,
17
+ * visually hidden until it receives focus, then pinned at the top-left with
18
+ * visible contrast. The target element is responsible for carrying the id (and
19
+ * being focusable); this block only renders the bypass affordance.
20
+ */
21
+ export function SkipToContent({
22
+ targetId,
23
+ label = 'Skip to content',
24
+ className,
25
+ }: SkipToContentProps) {
26
+ return (
27
+ <a
28
+ href={`#${targetId}`}
29
+ className={cn(
30
+ 'sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-sm focus:font-semibold focus:text-primary-foreground focus:shadow-lg',
31
+ className,
32
+ )}
33
+ >
34
+ {label}
35
+ </a>
36
+ )
37
+ }
@@ -0,0 +1,122 @@
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 { CategoryCard } from './category-card'
7
+ import { CategoryGrid } from './product-category-grid'
8
+ import type { ProductCategoryItem } from '../../../lib/contracts'
9
+
10
+ beforeAll(() => {
11
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
12
+ })
13
+
14
+ const category: ProductCategoryItem = {
15
+ id: 'pcat_herramientas',
16
+ handle: 'herramientas',
17
+ name: 'Herramientas',
18
+ description: 'Taladros y accesorios',
19
+ image: '/products/drill.png',
20
+ itemCount: 12,
21
+ trending: true,
22
+ featured: true,
23
+ subcategories: ['Taladros', 'Accesorios'],
24
+ }
25
+
26
+ const categories: ProductCategoryItem[] = [
27
+ category,
28
+ { ...category, id: 'pcat_pintura', handle: 'pintura', name: 'Pintura', image: undefined },
29
+ { ...category, id: 'pcat_jardin', handle: 'jardin', name: 'Jardín' },
30
+ ]
31
+
32
+ const card = (extra: Record<string, unknown> = {}) =>
33
+ renderToStaticMarkup(<CategoryCard category={category} {...extra} />)
34
+
35
+ const grid = (extra: Record<string, unknown> = {}) =>
36
+ renderToStaticMarkup(<CategoryGrid categories={categories} {...extra} />)
37
+
38
+ describe('CategoryCard — item cell', () => {
39
+ it('renders a single category without section chrome', () => {
40
+ const html = card()
41
+ expect(html).toContain('Herramientas')
42
+ expect(html).not.toContain('<section')
43
+ expect(html).not.toContain('<h2')
44
+ })
45
+
46
+ it('renders the image for the grid presentation and omits it for icons', () => {
47
+ expect(card()).toContain('/products/drill.png')
48
+ expect(card({ presentation: 'icons' })).not.toContain('<img')
49
+ })
50
+
51
+ it('keeps counts honest (zero and unknown are not invented)', () => {
52
+ expect(card()).toContain('12')
53
+ expect(renderToStaticMarkup(<CategoryCard category={{ ...category, itemCount: undefined }} />)).not.toContain('items</span>')
54
+ expect(renderToStaticMarkup(<CategoryCard category={{ ...category, itemCount: 0 }} />)).not.toContain('items</span>')
55
+ })
56
+
57
+ it('only exposes browse when a handler exists', () => {
58
+ expect(card({ browseLabel: 'Ver' })).not.toContain('Ver Herramientas')
59
+ const html = card({ browseLabel: 'Ver', onBrowse: vi.fn() })
60
+ expect(html).toContain('aria-label="Ver Herramientas"')
61
+ })
62
+
63
+ it('renders the list cell with description, chips and featured badge', () => {
64
+ const html = card({ presentation: 'list', featuredLabel: 'Destacada', itemsLabel: 'productos' })
65
+ expect(html).toContain('Taladros y accesorios')
66
+ expect(html).toContain('Taladros')
67
+ expect(html).toContain('Destacada')
68
+ expect(html).toContain('productos')
69
+ })
70
+ })
71
+
72
+ describe('CategoryGrid — listing', () => {
73
+ it('renders one cell per category', () => {
74
+ const html = grid()
75
+ expect(html).toContain('Herramientas')
76
+ expect(html).toContain('Pintura')
77
+ expect(html).toContain('Jardín')
78
+ expect(html.match(/data-slot="card"/g) ?? []).toHaveLength(3)
79
+ })
80
+
81
+ it('caps the rendered collection with maxItems', () => {
82
+ const html = grid({ maxItems: 2 })
83
+ expect(html).toContain('Herramientas')
84
+ expect(html).toContain('Pintura')
85
+ expect(html).not.toContain('Jardín')
86
+ })
87
+
88
+ it('owns its loading state with skeletons and no cells', () => {
89
+ const html = grid({ loading: true, skeletonCount: 3 })
90
+ expect(html).toContain('aria-busy="true"')
91
+ expect(html).not.toContain('data-slot="card"')
92
+ })
93
+
94
+ it('owns its empty state', () => {
95
+ expect(renderToStaticMarkup(<CategoryGrid categories={[]} emptyLabel="Sin categorías" />)).toContain('Sin categorías')
96
+ })
97
+
98
+ it('renders the search control only in the list presentation', () => {
99
+ expect(grid({ presentation: 'grid', onQueryChange: vi.fn() })).not.toContain('type="search"')
100
+ expect(grid({ presentation: 'list', onQueryChange: vi.fn(), searchPlaceholder: 'Buscar' })).toContain('type="search"')
101
+ })
102
+
103
+ it('renders view-all only outside the grid and the section cta only outside the list', () => {
104
+ const withBoth = { cta: { label: 'Comprar', href: '/tienda' }, onViewAll: vi.fn(), viewAllLabel: 'Ver todo' }
105
+ const gridHtml = grid({ presentation: 'grid', ...withBoth })
106
+ expect(gridHtml).toContain('Comprar')
107
+ expect(gridHtml).not.toContain('Ver todo')
108
+ const listHtml = grid({ presentation: 'list', ...withBoth })
109
+ expect(listHtml).toContain('Ver todo')
110
+ expect(listHtml).not.toContain('Comprar')
111
+ })
112
+
113
+ it('forwards the controlled query to the search input', async () => {
114
+ const user = userEvent.setup()
115
+ const onQueryChange = vi.fn()
116
+ render(<CategoryGrid categories={categories} presentation="list" query="tal" onQueryChange={onQueryChange} searchLabel="Buscar categorías" />)
117
+ const input = screen.getByLabelText('Buscar categorías') as HTMLInputElement
118
+ expect(input.value).toBe('tal')
119
+ await user.type(input, 'a')
120
+ expect(onQueryChange).toHaveBeenCalled()
121
+ })
122
+ })