@thorprovider/blocks 0.1.1 → 0.1.3
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/.turbo/turbo-build.log +15 -15
- package/.turbo/turbo-test.log +42 -5
- package/AGENTS.md +1 -1
- package/README.md +94 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/contracts.d.mts +1266 -97
- package/dist/contracts.d.ts +1266 -97
- package/dist/contracts.js +225 -9
- package/dist/contracts.js.map +1 -1
- package/dist/contracts.mjs +207 -10
- package/dist/contracts.mjs.map +1 -1
- package/dist/index.d.mts +756 -96
- package/dist/index.d.ts +756 -96
- package/dist/index.js +5114 -3702
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4939 -3551
- package/dist/index.mjs.map +1 -1
- package/docs/standards/theming.md +32 -5
- package/package.json +2 -2
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
- package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
- 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/mobile-category-drawer.test.tsx +42 -2
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
- 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/product-category-2.test.tsx +51 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
- package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +88 -0
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
- 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-1.test.tsx +182 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +229 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +313 -110
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +72 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +20 -1
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
- package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +38 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +204 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +144 -21
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +233 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +235 -60
- package/src/index.ts +56 -0
- package/src/lib/cart.test.ts +15 -0
- package/src/lib/cart.ts +11 -0
- package/src/lib/contracts-curated.test.ts +207 -0
- package/src/lib/contracts-server-boundary.test.ts +52 -0
- package/src/lib/contracts.ts +234 -6
- package/src/lib/money.test.ts +64 -0
- package/src/lib/money.ts +65 -0
- package/tsup.config.ts +0 -1
- package/vitest.config.ts +9 -1
|
@@ -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
|
+
}
|
|
@@ -102,8 +102,6 @@ describe('MobileCategoryDrawer', () => {
|
|
|
102
102
|
expect(html).toContain('Ofertas')
|
|
103
103
|
expect(html).toContain('Novedades')
|
|
104
104
|
expect(html).toContain('href="/colecciones/ofertas"')
|
|
105
|
-
expect(html).toContain('href="/colecciones"')
|
|
106
|
-
expect(html).toContain('Ver todas')
|
|
107
105
|
expect(html).toContain('grid grid-cols-2')
|
|
108
106
|
})
|
|
109
107
|
|
|
@@ -149,4 +147,46 @@ describe('MobileCategoryDrawer', () => {
|
|
|
149
147
|
}),
|
|
150
148
|
).toThrow()
|
|
151
149
|
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('MobileCategoryDrawer — view-all link requires a served href', () => {
|
|
153
|
+
const base = { categories, collections, open: true, onClose: () => {} }
|
|
154
|
+
|
|
155
|
+
it('omits the link and never emits an empty href when viewAllHref is omitted', () => {
|
|
156
|
+
const html = renderToStaticMarkup(<MobileCategoryDrawer {...base} />)
|
|
157
|
+
expect(html).not.toContain('Ver todas')
|
|
158
|
+
expect(html).not.toContain('href=""')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('omits the link when viewAllHref is an empty string', () => {
|
|
162
|
+
const html = renderToStaticMarkup(<MobileCategoryDrawer {...base} viewAllHref="" />)
|
|
163
|
+
expect(html).not.toContain('Ver todas')
|
|
164
|
+
expect(html).not.toContain('href=""')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('omits the link when viewAllHref is whitespace only', () => {
|
|
168
|
+
const html = renderToStaticMarkup(<MobileCategoryDrawer {...base} viewAllHref=" " />)
|
|
169
|
+
expect(html).not.toContain('Ver todas')
|
|
170
|
+
expect(html).not.toContain('href=""')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('renders the link when a served path is supplied', () => {
|
|
174
|
+
const html = renderToStaticMarkup(
|
|
175
|
+
<MobileCategoryDrawer {...base} viewAllHref="/tienda/todas" />,
|
|
176
|
+
)
|
|
177
|
+
expect(html).toContain('href="/tienda/todas"')
|
|
178
|
+
expect(html).toContain('Ver todas')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('renders a custom label with the served path', () => {
|
|
182
|
+
const html = renderToStaticMarkup(
|
|
183
|
+
<MobileCategoryDrawer
|
|
184
|
+
{...base}
|
|
185
|
+
viewAllHref="/tienda/todas"
|
|
186
|
+
viewAllLabel="Explorar todo"
|
|
187
|
+
/>,
|
|
188
|
+
)
|
|
189
|
+
expect(html).toContain('href="/tienda/todas"')
|
|
190
|
+
expect(html).toContain('Explorar todo')
|
|
191
|
+
})
|
|
152
192
|
})
|
|
@@ -59,7 +59,7 @@ export function MobileCategoryDrawer({
|
|
|
59
59
|
rootTitle = 'Categorías',
|
|
60
60
|
collectionsTitle = 'Colecciones',
|
|
61
61
|
viewAllLabel = 'Ver todas',
|
|
62
|
-
viewAllHref
|
|
62
|
+
viewAllHref,
|
|
63
63
|
closeAriaLabel = 'Cerrar menú',
|
|
64
64
|
backAriaLabel = 'Volver',
|
|
65
65
|
menuAriaLabel = 'Menú de categorías',
|
|
@@ -235,13 +235,15 @@ export function MobileCategoryDrawer({
|
|
|
235
235
|
>
|
|
236
236
|
{collectionsTitle}
|
|
237
237
|
</h2>
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
238
|
+
{viewAllHref && viewAllHref.trim() ? (
|
|
239
|
+
<a
|
|
240
|
+
href={viewAllHref}
|
|
241
|
+
onClick={handleClose}
|
|
242
|
+
className="text-xs font-bold text-primary"
|
|
243
|
+
>
|
|
244
|
+
{viewAllLabel}
|
|
245
|
+
</a>
|
|
246
|
+
) : null}
|
|
245
247
|
</div>
|
|
246
248
|
<div className="grid grid-cols-2 gap-3">
|
|
247
249
|
{collections.map((collection) => (
|
|
@@ -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,51 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
ProductCategory2,
|
|
5
|
+
type ProductCategory2Category,
|
|
6
|
+
} from './product-category-2'
|
|
7
|
+
|
|
8
|
+
const base: ProductCategory2Category = {
|
|
9
|
+
id: 'cat-1',
|
|
10
|
+
name: 'Herramientas',
|
|
11
|
+
description: 'Taladros y accesorios',
|
|
12
|
+
image: '/products/drill.png',
|
|
13
|
+
trending: false,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const render = (category: ProductCategory2Category) =>
|
|
17
|
+
renderToStaticMarkup(<ProductCategory2 categories={[category]} />)
|
|
18
|
+
|
|
19
|
+
// The count is the only text node that ends in "items" inside a span (the
|
|
20
|
+
// `items-center` utility lives in a class attribute and is never followed by
|
|
21
|
+
// `</span>`).
|
|
22
|
+
const hasItemCount = (html: string) => html.includes('items</span>')
|
|
23
|
+
|
|
24
|
+
describe('ProductCategory2 — optional itemCount', () => {
|
|
25
|
+
it('renders no count when itemCount is omitted (unknown projection)', () => {
|
|
26
|
+
const html = render(base)
|
|
27
|
+
expect(hasItemCount(html)).toBe(false)
|
|
28
|
+
// The tile itself still renders.
|
|
29
|
+
expect(html).toContain('Herramientas')
|
|
30
|
+
expect(html).toContain('Browse')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('preserves a real zero count', () => {
|
|
34
|
+
const html = render({ ...base, itemCount: 0 })
|
|
35
|
+
expect(html).toContain('0 items</span>')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('renders a positive count', () => {
|
|
39
|
+
const html = render({ ...base, itemCount: 7 })
|
|
40
|
+
expect(html).toContain('7 items</span>')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('omits a negative count', () => {
|
|
44
|
+
expect(hasItemCount(render({ ...base, itemCount: -1 }))).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('omits a non-finite count', () => {
|
|
48
|
+
expect(hasItemCount(render({ ...base, itemCount: Number.NaN }))).toBe(false)
|
|
49
|
+
expect(hasItemCount(render({ ...base, itemCount: Number.POSITIVE_INFINITY }))).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
})
|