@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
|
@@ -15,24 +15,73 @@ import {
|
|
|
15
15
|
} from '../../../ui/empty'
|
|
16
16
|
import { Separator } from '../../../ui/separator'
|
|
17
17
|
import { SheetClose } from '../../../ui/sheet'
|
|
18
|
+
import type { Money } from '@thorprovider/types'
|
|
19
|
+
import { formatMoney } from '../../../lib/money'
|
|
18
20
|
|
|
19
21
|
export interface CartItem {
|
|
22
|
+
/** Backend cart line-item id — the stable identity for update/removal. */
|
|
20
23
|
id: string
|
|
21
24
|
name: string
|
|
22
|
-
variant
|
|
25
|
+
variant?: string
|
|
23
26
|
price: number
|
|
24
27
|
qty: number
|
|
25
|
-
image
|
|
28
|
+
/** Optional; an absent image is omitted rather than serialized as `""`. */
|
|
29
|
+
image?: string
|
|
30
|
+
/**
|
|
31
|
+
* Canonical backend line amount. Required for truthful rendering in
|
|
32
|
+
* `canonical` mode; when absent/invalid the line shows the unavailable
|
|
33
|
+
* label rather than a numeric fallback.
|
|
34
|
+
*/
|
|
35
|
+
lineTotal?: Money
|
|
26
36
|
}
|
|
27
37
|
|
|
38
|
+
export type CartSheetState = 'idle' | 'pending' | 'error'
|
|
39
|
+
|
|
28
40
|
export interface Navbar4CartSheetProps {
|
|
29
41
|
items: CartItem[]
|
|
42
|
+
/**
|
|
43
|
+
* Quantity DELTA for a backend cart line-item id. The boundary converts the
|
|
44
|
+
* delta into an absolute quantity against the authoritative current line
|
|
45
|
+
* quantity before mutating the cart.
|
|
46
|
+
*/
|
|
30
47
|
onQtyChange: (id: string, delta: number) => void
|
|
48
|
+
/** Remove the backend cart line-item id. */
|
|
31
49
|
onRemove: (id: string) => void
|
|
32
|
-
/**
|
|
50
|
+
/** Authoritative backend subtotal. Preferred over any local line math. */
|
|
51
|
+
subtotal?: Money
|
|
52
|
+
/** Shown when an authoritative subtotal is supplied but absent/malformed. */
|
|
53
|
+
subtotalUnavailableLabel?: string
|
|
54
|
+
/** Override money formatting (locale, currency). Defaults to `Money.currencyCode`. */
|
|
55
|
+
formatMoney?: (money: Money) => string
|
|
56
|
+
/** @deprecated legacy numeric formatter; ignored when `subtotal` is supplied. */
|
|
33
57
|
formatPrice?: (value: number) => string
|
|
34
58
|
onCheckout?: () => void
|
|
35
59
|
onViewCart?: () => void
|
|
60
|
+
/**
|
|
61
|
+
* Backend-authoritative rendering. When `true`, each line amount comes from
|
|
62
|
+
* the item's canonical `lineTotal` Money and the sheet never recomputes
|
|
63
|
+
* `price * qty`. When omitted/false, legacy numeric rendering is preserved
|
|
64
|
+
* for standalone consumers.
|
|
65
|
+
*/
|
|
66
|
+
canonical?: boolean
|
|
67
|
+
/** Shown in `canonical` mode when a line amount is missing or malformed. */
|
|
68
|
+
lineUnavailableLabel?: string
|
|
69
|
+
/** Cart read/mutation lifecycle. Pending retains confirmed lines; error is recoverable. */
|
|
70
|
+
state?: CartSheetState
|
|
71
|
+
/** Message shown when `state` is `error`. */
|
|
72
|
+
errorLabel?: string
|
|
73
|
+
/** Label for the recovery action in the error state. */
|
|
74
|
+
retryLabel?: string
|
|
75
|
+
/** Recovery action invoked from the error state. */
|
|
76
|
+
onRetry?: () => void
|
|
77
|
+
/** Label shown while `state` is `pending`. */
|
|
78
|
+
pendingLabel?: string
|
|
79
|
+
/** When `true`, the checkout action is suppressed (checkout not installed). */
|
|
80
|
+
checkoutUnavailable?: boolean
|
|
81
|
+
/** When `true`, the checkout action is disabled (cart not ready / invalid amounts). */
|
|
82
|
+
checkoutDisabled?: boolean
|
|
83
|
+
/** When `true`, the review action is suppressed (cart view not installed). */
|
|
84
|
+
viewCartUnavailable?: boolean
|
|
36
85
|
}
|
|
37
86
|
|
|
38
87
|
const usdFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
|
|
@@ -59,14 +108,30 @@ export function Navbar4CartSheet({
|
|
|
59
108
|
items,
|
|
60
109
|
onQtyChange,
|
|
61
110
|
onRemove,
|
|
111
|
+
subtotal: subtotalProp,
|
|
112
|
+
subtotalUnavailableLabel = '—',
|
|
113
|
+
formatMoney: formatMoneyProp,
|
|
62
114
|
formatPrice,
|
|
63
115
|
onCheckout,
|
|
64
116
|
onViewCart,
|
|
117
|
+
canonical = false,
|
|
118
|
+
lineUnavailableLabel = '—',
|
|
119
|
+
state = 'idle',
|
|
120
|
+
errorLabel = 'No se pudo actualizar el carrito.',
|
|
121
|
+
retryLabel = 'Reintentar',
|
|
122
|
+
onRetry,
|
|
123
|
+
pendingLabel = 'Actualizando el carrito…',
|
|
124
|
+
checkoutUnavailable = false,
|
|
125
|
+
checkoutDisabled = false,
|
|
126
|
+
viewCartUnavailable = false,
|
|
65
127
|
}: Navbar4CartSheetProps) {
|
|
66
128
|
const reduceMotion = useReducedMotion()
|
|
67
129
|
const format = formatPrice ?? (value => usdFormatter.format(value))
|
|
68
|
-
const
|
|
130
|
+
const localSubtotal = items.reduce((total, item) => total + item.price * item.qty, 0)
|
|
131
|
+
const authoritativeSubtotal = subtotalProp !== undefined ? formatMoney(subtotalProp, formatMoneyProp) : null
|
|
69
132
|
const isEmpty = items.length === 0
|
|
133
|
+
const isPending = state === 'pending'
|
|
134
|
+
const isError = state === 'error'
|
|
70
135
|
/** Wait for the last cart row's exit before revealing empty copy. */
|
|
71
136
|
const [showEmpty, setShowEmpty] = useState(isEmpty)
|
|
72
137
|
|
|
@@ -76,9 +141,46 @@ export function Navbar4CartSheet({
|
|
|
76
141
|
}
|
|
77
142
|
}, [isEmpty])
|
|
78
143
|
|
|
144
|
+
const lineAmount = (item: CartItem): string => {
|
|
145
|
+
if (canonical) {
|
|
146
|
+
if (item.lineTotal === undefined) {
|
|
147
|
+
return lineUnavailableLabel
|
|
148
|
+
}
|
|
149
|
+
return formatMoney(item.lineTotal, formatMoneyProp) ?? lineUnavailableLabel
|
|
150
|
+
}
|
|
151
|
+
return format(item.price * item.qty)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A confirmed empty cart is only claimed when no read/mutation is in flight
|
|
155
|
+
// and no read error is outstanding; otherwise the sheet shows loading/error.
|
|
156
|
+
const showConfirmedEmpty = showEmpty && !isPending && !isError
|
|
157
|
+
const mutationsDisabled = isPending
|
|
158
|
+
|
|
79
159
|
return (
|
|
80
160
|
<>
|
|
81
161
|
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
|
|
162
|
+
{isPending ? (
|
|
163
|
+
<p
|
|
164
|
+
role='status'
|
|
165
|
+
aria-live='polite'
|
|
166
|
+
className='border-border text-muted-foreground border-b px-4 py-2 text-sm'
|
|
167
|
+
>
|
|
168
|
+
{pendingLabel}
|
|
169
|
+
</p>
|
|
170
|
+
) : null}
|
|
171
|
+
{isError ? (
|
|
172
|
+
<div
|
|
173
|
+
role='alert'
|
|
174
|
+
className='border-border flex items-center justify-between gap-2 border-b px-4 py-2 text-sm'
|
|
175
|
+
>
|
|
176
|
+
<span className='text-destructive'>{errorLabel}</span>
|
|
177
|
+
{onRetry ? (
|
|
178
|
+
<Button variant='ghost' size='sm' onClick={onRetry}>
|
|
179
|
+
{retryLabel}
|
|
180
|
+
</Button>
|
|
181
|
+
) : null}
|
|
182
|
+
</div>
|
|
183
|
+
) : null}
|
|
82
184
|
<ul className='flex-1 overflow-y-auto px-4'>
|
|
83
185
|
<AnimatePresence
|
|
84
186
|
initial={false}
|
|
@@ -100,20 +202,26 @@ export function Navbar4CartSheet({
|
|
|
100
202
|
className='border-border flex gap-4 border-b py-4 last:border-b-0'
|
|
101
203
|
style={{ willChange: 'transform, opacity, filter' }}
|
|
102
204
|
>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
205
|
+
{item.image ? (
|
|
206
|
+
<img
|
|
207
|
+
src={item.image || undefined}
|
|
208
|
+
alt=''
|
|
209
|
+
width={80}
|
|
210
|
+
height={96}
|
|
211
|
+
className='bg-muted size-20 shrink-0 rounded-md object-cover'
|
|
212
|
+
/>
|
|
213
|
+
) : (
|
|
214
|
+
<div aria-hidden='true' className='bg-muted size-20 shrink-0 rounded-md' />
|
|
215
|
+
)}
|
|
110
216
|
<div className='flex min-w-0 flex-1 flex-col'>
|
|
111
217
|
<div className='flex items-start justify-between gap-2'>
|
|
112
218
|
<div className='min-w-0'>
|
|
113
219
|
<p className='truncate text-sm font-medium'>{item.name}</p>
|
|
114
|
-
|
|
220
|
+
{item.variant ? (
|
|
221
|
+
<p className='text-muted-foreground text-sm'>{item.variant}</p>
|
|
222
|
+
) : null}
|
|
115
223
|
</div>
|
|
116
|
-
<p className='text-sm font-medium'>{
|
|
224
|
+
<p className='text-sm font-medium'>{lineAmount(item)}</p>
|
|
117
225
|
</div>
|
|
118
226
|
|
|
119
227
|
<div className='mt-auto flex items-center justify-between pt-2'>
|
|
@@ -123,6 +231,7 @@ export function Navbar4CartSheet({
|
|
|
123
231
|
size='icon'
|
|
124
232
|
className='size-8 rounded-r-none'
|
|
125
233
|
aria-label={`Decrease quantity, ${item.name}`}
|
|
234
|
+
disabled={mutationsDisabled}
|
|
126
235
|
onClick={() => onQtyChange(item.id, -1)}
|
|
127
236
|
>
|
|
128
237
|
<Minus />
|
|
@@ -133,6 +242,7 @@ export function Navbar4CartSheet({
|
|
|
133
242
|
size='icon'
|
|
134
243
|
className='size-8 rounded-l-none'
|
|
135
244
|
aria-label={`Increase quantity, ${item.name}`}
|
|
245
|
+
disabled={mutationsDisabled}
|
|
136
246
|
onClick={() => onQtyChange(item.id, 1)}
|
|
137
247
|
>
|
|
138
248
|
<Plus />
|
|
@@ -143,6 +253,7 @@ export function Navbar4CartSheet({
|
|
|
143
253
|
size='sm'
|
|
144
254
|
className='text-muted-foreground hover:text-foreground'
|
|
145
255
|
aria-label={`Remove ${item.name} from cart`}
|
|
256
|
+
disabled={mutationsDisabled}
|
|
146
257
|
onClick={() => onRemove(item.id)}
|
|
147
258
|
>
|
|
148
259
|
Remove
|
|
@@ -155,7 +266,7 @@ export function Navbar4CartSheet({
|
|
|
155
266
|
</ul>
|
|
156
267
|
|
|
157
268
|
<AnimatePresence>
|
|
158
|
-
{
|
|
269
|
+
{showConfirmedEmpty ? (
|
|
159
270
|
<motion.div
|
|
160
271
|
key='empty'
|
|
161
272
|
initial={false}
|
|
@@ -230,16 +341,28 @@ export function Navbar4CartSheet({
|
|
|
230
341
|
<Separator />
|
|
231
342
|
<div className='flex items-center justify-between text-sm'>
|
|
232
343
|
<span className='text-muted-foreground'>Subtotal</span>
|
|
233
|
-
<span className='font-medium tabular-nums'>
|
|
344
|
+
<span className='font-medium tabular-nums'>
|
|
345
|
+
{canonical || subtotalProp !== undefined
|
|
346
|
+
? authoritativeSubtotal ?? subtotalUnavailableLabel
|
|
347
|
+
: format(localSubtotal)}
|
|
348
|
+
</span>
|
|
234
349
|
</div>
|
|
235
350
|
<p className='text-muted-foreground text-xs'>Shipping and taxes are calculated at checkout.</p>
|
|
236
351
|
<div className='flex flex-col gap-2'>
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
352
|
+
{checkoutUnavailable ? null : (
|
|
353
|
+
<Button
|
|
354
|
+
className='w-full'
|
|
355
|
+
disabled={checkoutDisabled || isPending}
|
|
356
|
+
onClick={onCheckout}
|
|
357
|
+
>
|
|
358
|
+
Checkout
|
|
359
|
+
</Button>
|
|
360
|
+
)}
|
|
361
|
+
{viewCartUnavailable ? null : (
|
|
362
|
+
<Button variant='ghost' className='w-full' onClick={onViewCart}>
|
|
363
|
+
View cart
|
|
364
|
+
</Button>
|
|
365
|
+
)}
|
|
243
366
|
</div>
|
|
244
367
|
</div>
|
|
245
368
|
) : null}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { Navbar4 } from './navbar-4'
|
|
4
|
+
import { Navbar4AccountMenu } from './navbar-4-account-menu'
|
|
5
|
+
import { navbar4PropsSchema } from '../../../lib/contracts'
|
|
6
|
+
|
|
7
|
+
const base = {
|
|
8
|
+
storeCategories: [],
|
|
9
|
+
shippingNote: 'Entrega segura',
|
|
10
|
+
utilityLinks: [],
|
|
11
|
+
logoText: 'ElectroCuba',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('Navbar4 — account affordance', () => {
|
|
15
|
+
const user = { name: 'Ada Lovelace', email: 'ada@example.test', initials: 'AL' }
|
|
16
|
+
|
|
17
|
+
it('offers a visible account destination to anonymous shoppers', () => {
|
|
18
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} accountHref="/account" />)
|
|
19
|
+
expect(html).toMatch(/<a[^>]*href="\/account"[^>]*aria-label="Account"/)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders the identity trigger without a desktop-only wrapper or empty image', () => {
|
|
23
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} user={user} />)
|
|
24
|
+
expect(html).toContain('Account menu for Ada Lovelace')
|
|
25
|
+
expect(html).not.toMatch(/<div class="hidden lg:block"><button[^>]*aria-label="Account/)
|
|
26
|
+
expect(html).not.toContain('src=""')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('renders no account control without identity or destination', () => {
|
|
30
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} />)
|
|
31
|
+
expect(html).not.toContain('aria-label="Account')
|
|
32
|
+
expect(renderToStaticMarkup(<Navbar4AccountMenu />)).toBe('')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('disables the identity trigger while a session operation is pending', () => {
|
|
36
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} user={user} accountPending />)
|
|
37
|
+
expect(html).toMatch(/<button[^>]*disabled=""[^>]*aria-label="Account menu for Ada Lovelace"/)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('distinguishes unavailable identity from anonymous and offers retry', () => {
|
|
41
|
+
const html = renderToStaticMarkup(
|
|
42
|
+
<Navbar4 {...base} accountHref="/account" accountUnavailable onAccountRetry={() => {}} />,
|
|
43
|
+
)
|
|
44
|
+
expect(html).toContain('aria-label="Retry account session"')
|
|
45
|
+
expect(html).not.toContain('aria-label="Account"')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('keeps serializable identity and feedback while stripping callbacks', () => {
|
|
49
|
+
const parsed = navbar4PropsSchema.parse({
|
|
50
|
+
...base, user, accountHref: '/account', accountMenuItems: [{ label: 'Account', href: '/account' }],
|
|
51
|
+
accountPending: true, accountUnavailable: true, onSignOut: () => {},
|
|
52
|
+
})
|
|
53
|
+
expect(parsed.user).toEqual(user)
|
|
54
|
+
expect(parsed.accountPending).toBe(true)
|
|
55
|
+
expect(parsed.accountUnavailable).toBe(true)
|
|
56
|
+
expect(parsed).not.toHaveProperty('onSignOut')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('Navbar4 — optional logo accent', () => {
|
|
61
|
+
it('renders the wordmark unchanged when no accent is supplied', () => {
|
|
62
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} />)
|
|
63
|
+
expect(html).toContain('>ElectroCuba</span>')
|
|
64
|
+
expect(html).not.toContain('text-brand-promo')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('accents a matched trailing segment without duplicating the wordmark', () => {
|
|
68
|
+
const html = renderToStaticMarkup(
|
|
69
|
+
<Navbar4 {...base} logoAccent={{ text: 'Cuba', className: 'text-brand-promo' }} />,
|
|
70
|
+
)
|
|
71
|
+
expect(html).toContain('Electro<span class="text-brand-promo">Cuba</span>')
|
|
72
|
+
expect(html.match(/Electro/g)).toHaveLength(1)
|
|
73
|
+
expect(html.match(/Cuba/g)).toHaveLength(1)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('accents the full wordmark when the accent covers all of it', () => {
|
|
77
|
+
const html = renderToStaticMarkup(
|
|
78
|
+
<Navbar4 {...base} logoAccent={{ text: 'ElectroCuba', className: 'text-brand-promo' }} />,
|
|
79
|
+
)
|
|
80
|
+
expect(html).toContain('<span class="text-brand-promo">ElectroCuba</span>')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('does not accent when the segment is not a trailing match', () => {
|
|
84
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} logoAccent={{ text: 'Electro' }} />)
|
|
85
|
+
expect(html).toContain('>ElectroCuba</span>')
|
|
86
|
+
expect(html).not.toContain('<span class="">')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('keeps the wordmark as the accessible text of the logo link', () => {
|
|
90
|
+
const html = renderToStaticMarkup(
|
|
91
|
+
<Navbar4 {...base} logoHref='/' logoAccent={{ text: 'Cuba', className: 'text-brand-promo' }} />,
|
|
92
|
+
)
|
|
93
|
+
expect(html).toContain('href="/"')
|
|
94
|
+
expect(html).toContain('Electro<span class="text-brand-promo">Cuba</span>')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('Navbar4 — optional utility strip', () => {
|
|
99
|
+
// The desktop utility strip is the only element with this inner track class.
|
|
100
|
+
const strip = (html: string) => html.includes('h-9 w-full max-w-7xl')
|
|
101
|
+
|
|
102
|
+
it('renders no utility strip when the note is empty and there are no utility links', () => {
|
|
103
|
+
const html = renderToStaticMarkup(
|
|
104
|
+
<Navbar4 storeCategories={[]} shippingNote='' utilityLinks={[]} logoText='ElectroCuba' />,
|
|
105
|
+
)
|
|
106
|
+
expect(strip(html)).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('renders no utility strip when the note is omitted', () => {
|
|
110
|
+
const html = renderToStaticMarkup(
|
|
111
|
+
<Navbar4 storeCategories={[]} utilityLinks={[]} logoText='ElectroCuba' />,
|
|
112
|
+
)
|
|
113
|
+
expect(strip(html)).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('treats a whitespace-only note as empty', () => {
|
|
117
|
+
const html = renderToStaticMarkup(
|
|
118
|
+
<Navbar4 storeCategories={[]} shippingNote=' ' utilityLinks={[]} logoText='ElectroCuba' />,
|
|
119
|
+
)
|
|
120
|
+
expect(strip(html)).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('renders the utility strip with a non-empty note', () => {
|
|
124
|
+
const html = renderToStaticMarkup(
|
|
125
|
+
<Navbar4 storeCategories={[]} shippingNote='Entrega segura' utilityLinks={[]} logoText='ElectroCuba' />,
|
|
126
|
+
)
|
|
127
|
+
expect(strip(html)).toBe(true)
|
|
128
|
+
expect(html).toContain('Entrega segura')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('renders the utility strip when there is a utility link even with an empty note', () => {
|
|
132
|
+
const html = renderToStaticMarkup(
|
|
133
|
+
<Navbar4
|
|
134
|
+
storeCategories={[]}
|
|
135
|
+
shippingNote=''
|
|
136
|
+
utilityLinks={[{ label: 'Ayuda', href: '/help' }]}
|
|
137
|
+
logoText='ElectroCuba'
|
|
138
|
+
/>,
|
|
139
|
+
)
|
|
140
|
+
expect(strip(html)).toBe(true)
|
|
141
|
+
expect(html).toContain('Ayuda')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const category = { href: '/herramientas', label: 'Herramientas' }
|
|
146
|
+
|
|
147
|
+
// The desktop nav link (the mobile drawer also emits an anchor for the same
|
|
148
|
+
// href, but only the desktop nav link carries `hover:bg-muted`).
|
|
149
|
+
const navLinkClass = (html: string) =>
|
|
150
|
+
html.match(/<a href="\/herramientas" class="([^"]*hover:bg-muted[^"]*)"/)?.[1] ?? ''
|
|
151
|
+
|
|
152
|
+
describe('Navbar4 — optional outer surface className', () => {
|
|
153
|
+
it('keeps the default surface (incl. backdrop-filter variant) and nav link color when className is omitted', () => {
|
|
154
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} storeCategories={[category]} />)
|
|
155
|
+
expect(html).toContain('bg-background/95')
|
|
156
|
+
expect(html).toContain('supports-backdrop-filter:bg-background/80')
|
|
157
|
+
expect(navLinkClass(html)).toContain('text-foreground')
|
|
158
|
+
expect(navLinkClass(html)).not.toContain('hover:text-foreground')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('gives the consumer full surface ownership when className is supplied (no default background or variant)', () => {
|
|
162
|
+
const html = renderToStaticMarkup(
|
|
163
|
+
<Navbar4 {...base} storeCategories={[category]} className='bg-brand-nav text-brand-nav-foreground' />,
|
|
164
|
+
)
|
|
165
|
+
expect(html).toContain('bg-brand-nav')
|
|
166
|
+
expect(html).toContain('text-brand-nav-foreground')
|
|
167
|
+
expect(html).not.toContain('bg-background/95')
|
|
168
|
+
expect(html).not.toContain('supports-backdrop-filter:bg-background/80')
|
|
169
|
+
// Layout/border/sticky/backdrop classes stay internal and present.
|
|
170
|
+
expect(html).toContain('border-border')
|
|
171
|
+
expect(html).toContain('sticky')
|
|
172
|
+
expect(html).toContain('backdrop-blur-md')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('lets nav links inherit the branded surface text color with a correct hover foreground', () => {
|
|
176
|
+
const html = renderToStaticMarkup(
|
|
177
|
+
<Navbar4 {...base} storeCategories={[category]} className='bg-brand-nav text-brand-nav-foreground' />,
|
|
178
|
+
)
|
|
179
|
+
expect(navLinkClass(html)).toContain('text-inherit')
|
|
180
|
+
expect(navLinkClass(html)).toContain('hover:text-foreground')
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const cartTrigger = (html: string) =>
|
|
185
|
+
html.match(/<button[^>]*aria-label="Cart,[^"]*"[^>]*>/)?.[0] ?? ''
|
|
186
|
+
|
|
187
|
+
describe('Navbar4 — controlled cart sheet visibility', () => {
|
|
188
|
+
it('keeps the uncontrolled sheet closed by default (legacy consumers unaffected)', () => {
|
|
189
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} />)
|
|
190
|
+
expect(cartTrigger(html)).toContain('data-state="closed"')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('reflects a controlled open state on the trigger', () => {
|
|
194
|
+
const html = renderToStaticMarkup(
|
|
195
|
+
<Navbar4 {...base} cartOpen={true} onCartOpenChange={() => {}} />,
|
|
196
|
+
)
|
|
197
|
+
expect(cartTrigger(html)).toContain('data-state="open"')
|
|
198
|
+
expect(cartTrigger(html)).toContain('aria-expanded="true"')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('reflects a controlled closed state on the trigger', () => {
|
|
202
|
+
const html = renderToStaticMarkup(
|
|
203
|
+
<Navbar4 {...base} cartOpen={false} onCartOpenChange={() => {}} />,
|
|
204
|
+
)
|
|
205
|
+
expect(cartTrigger(html)).toContain('data-state="closed"')
|
|
206
|
+
expect(cartTrigger(html)).toContain('aria-expanded="false"')
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('Navbar4 — optional showSearch (E2 task 5.4)', () => {
|
|
211
|
+
it('renders the search control by default (legacy consumers unaffected)', () => {
|
|
212
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} />)
|
|
213
|
+
expect(html).toContain('role="search"')
|
|
214
|
+
expect(html).toContain('type="search"')
|
|
215
|
+
expect(html).toContain('aria-label="Search products"')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('renders no search control when showSearch is false, keeping the rest of the chrome', () => {
|
|
219
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} showSearch={false} />)
|
|
220
|
+
expect(html).not.toContain('role="search"')
|
|
221
|
+
expect(html).not.toContain('type="search"')
|
|
222
|
+
expect(html).not.toContain('aria-label="Search products"')
|
|
223
|
+
// Non-search chrome is unaffected: the wordmark and cart trigger remain.
|
|
224
|
+
expect(html).toContain('ElectroCuba')
|
|
225
|
+
expect(html).toContain('aria-label="Cart, 0 items"')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('renders the search control when showSearch is explicitly true', () => {
|
|
229
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} showSearch={true} />)
|
|
230
|
+
expect(html).toContain('role="search"')
|
|
231
|
+
expect(html).toContain('type="search"')
|
|
232
|
+
})
|
|
233
|
+
})
|