@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.
- package/CHANGELOG.md +107 -0
- package/README.md +108 -23
- package/docs/standards/data-contracts.md +13 -2
- package/package.json +26 -10
- package/scripts/fix-esm-extensions.mjs +40 -0
- package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
- package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
- package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
- package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
- package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
- package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
- package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
- package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
- package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
- package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
- package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
- package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
- package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
- package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
- package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
- package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
- package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
- package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
- package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
- package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
- package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
- package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
- package/src/blocks/marketing/features/feature.test.tsx +153 -0
- package/src/blocks/marketing/features/feature.tsx +195 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
- package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
- package/src/index.ts +93 -46
- package/src/lib/contracts-curated.test.ts +42 -24
- package/src/lib/contracts.test.ts +143 -2
- package/src/lib/contracts.ts +395 -13
- package/src/ui/button-group.tsx +1 -1
- package/src/ui/carousel.tsx +1 -1
- package/src/ui/dialog.tsx +1 -1
- package/src/ui/field.tsx +2 -2
- package/src/ui/input-group.tsx +3 -3
- package/src/ui/pagination.tsx +1 -1
- package/src/ui/sidebar.tsx +7 -7
- package/src/ui/toggle-group.tsx +1 -1
- package/tsconfig.build.json +12 -0
- package/tsup.config.ts +11 -7
- package/.turbo/turbo-build.log +0 -24
- package/.turbo/turbo-test.log +0 -37
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/dist/contracts.d.mts +0 -1584
- package/dist/contracts.d.ts +0 -1584
- package/dist/contracts.js +0 -390
- package/dist/contracts.js.map +0 -1
- package/dist/contracts.mjs +0 -334
- package/dist/contracts.mjs.map +0 -1
- package/dist/index.d.mts +0 -2786
- package/dist/index.d.ts +0 -2786
- package/dist/index.js +0 -17139
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -17059
- package/dist/index.mjs.map +0 -1
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
- package/src/blocks/marketing/features/feature-1.tsx +0 -97
- 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
|
+
})
|