@thorprovider/blocks 0.1.2 → 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 +37 -27
- package/README.md +42 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/contracts.d.mts +162 -14
- package/dist/contracts.d.ts +162 -14
- package/dist/contracts.js +36 -3
- package/dist/contracts.js.map +1 -1
- package/dist/contracts.mjs +34 -3
- package/dist/contracts.mjs.map +1 -1
- package/dist/index.d.mts +294 -12
- package/dist/index.d.ts +294 -12
- package/dist/index.js +3577 -2882
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3431 -2750
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- 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-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/shopping-carts/shopping-cart-4.test.tsx +92 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +86 -25
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +7 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +1 -1
- 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 +150 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +121 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +134 -32
- package/src/index.ts +18 -0
- package/src/lib/contracts.ts +53 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thorprovider/blocks",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Reusable, framework-agnostic React component blocks for Thor Commerce storefronts.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"license": "MIT",
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@base-ui/react": "^1.8.0",
|
|
43
|
-
"@thorprovider/types": "^5.
|
|
43
|
+
"@thorprovider/types": "^5.8.0",
|
|
44
44
|
"class-variance-authority": "^0.7.1",
|
|
45
45
|
"clsx": "^2.1.1",
|
|
46
46
|
"cn": "^0.4.0",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { BackToTop, backToTopPropsSchema, resolveScrollBehavior } from './back-to-top'
|
|
5
|
+
|
|
6
|
+
describe('BackToTop', () => {
|
|
7
|
+
it('parses a valid props object', () => {
|
|
8
|
+
const result = backToTopPropsSchema.parse({ threshold: 300 })
|
|
9
|
+
expect(result.threshold).toBe(300)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('accepts an empty props object', () => {
|
|
13
|
+
expect(() => backToTopPropsSchema.parse({})).not.toThrow()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('uses an instant jump under reduced motion', () => {
|
|
17
|
+
expect(resolveScrollBehavior(true)).toBe('auto')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('uses a smooth scroll when motion is allowed', () => {
|
|
21
|
+
expect(resolveScrollBehavior(false)).toBe('smooth')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders nothing before it becomes visible', () => {
|
|
25
|
+
expect(renderToStaticMarkup(<BackToTop />)).toBe('')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ArrowUp } from 'lucide-react'
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../../../lib/utils'
|
|
8
|
+
|
|
9
|
+
export const backToTopPropsSchema = z.object({
|
|
10
|
+
threshold: z.number().optional(),
|
|
11
|
+
label: z.string().optional(),
|
|
12
|
+
className: z.string().optional(),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export type BackToTopProps = z.infer<typeof backToTopPropsSchema>
|
|
16
|
+
|
|
17
|
+
const DEFAULT_THRESHOLD = 400
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the scroll behavior for a back-to-top activation. Pure so it can be
|
|
21
|
+
* unit-tested without a DOM: reduced motion yields an instant jump.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveScrollBehavior(
|
|
24
|
+
prefersReducedMotion: boolean,
|
|
25
|
+
): 'auto' | 'smooth' {
|
|
26
|
+
return prefersReducedMotion ? 'auto' : 'smooth'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Back-to-top control that appears once the page is scrolled past `threshold`
|
|
31
|
+
* and returns the viewport to the top. Honors `prefers-reduced-motion` (instant
|
|
32
|
+
* jump instead of a smooth scroll). Framework-agnostic; icon-only with an
|
|
33
|
+
* accessible label.
|
|
34
|
+
*/
|
|
35
|
+
export function BackToTop({
|
|
36
|
+
threshold = DEFAULT_THRESHOLD,
|
|
37
|
+
label = 'Back to top',
|
|
38
|
+
className,
|
|
39
|
+
}: BackToTopProps) {
|
|
40
|
+
const [visible, setVisible] = useState(false)
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const onScroll = () => setVisible(window.scrollY > threshold)
|
|
44
|
+
onScroll()
|
|
45
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
46
|
+
return () => window.removeEventListener('scroll', onScroll)
|
|
47
|
+
}, [threshold])
|
|
48
|
+
|
|
49
|
+
if (!visible) return null
|
|
50
|
+
|
|
51
|
+
const handleClick = () => {
|
|
52
|
+
const reduce =
|
|
53
|
+
typeof window !== 'undefined' &&
|
|
54
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
55
|
+
window.scrollTo({ top: 0, behavior: resolveScrollBehavior(reduce) })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
onClick={handleClick}
|
|
62
|
+
aria-label={label}
|
|
63
|
+
className={cn(
|
|
64
|
+
'fixed bottom-4 right-4 z-40 inline-flex size-11 items-center justify-center rounded-full border bg-card text-foreground shadow-lg transition-colors hover:bg-secondary',
|
|
65
|
+
className,
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
<ArrowUp className="size-5" aria-hidden="true" />
|
|
69
|
+
</button>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -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
|
+
}
|