@thorprovider/blocks 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +32 -5
  3. package/AGENTS.md +1 -1
  4. package/README.md +52 -0
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/contracts.d.mts +1111 -90
  7. package/dist/contracts.d.ts +1111 -90
  8. package/dist/contracts.js +189 -6
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/contracts.mjs +173 -7
  11. package/dist/contracts.mjs.map +1 -1
  12. package/dist/index.d.mts +467 -89
  13. package/dist/index.d.ts +467 -89
  14. package/dist/index.js +2967 -2250
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +2989 -2282
  17. package/dist/index.mjs.map +1 -1
  18. package/docs/standards/theming.md +32 -5
  19. package/package.json +4 -3
  20. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
  21. package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
  22. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
  23. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
  24. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
  25. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
  26. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
  27. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
  28. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
  29. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
  30. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
  31. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
  32. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
  33. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
  34. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
  35. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
  36. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
  37. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +137 -0
  38. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +231 -89
  39. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +65 -0
  40. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +19 -0
  41. package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
  42. package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +54 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +43 -13
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +112 -0
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +101 -28
  47. package/src/index.ts +38 -0
  48. package/src/lib/cart.test.ts +15 -0
  49. package/src/lib/cart.ts +11 -0
  50. package/src/lib/contracts-curated.test.ts +207 -0
  51. package/src/lib/contracts-server-boundary.test.ts +52 -0
  52. package/src/lib/contracts.ts +182 -4
  53. package/src/lib/money.test.ts +64 -0
  54. package/src/lib/money.ts +65 -0
  55. package/styles.css +1 -0
  56. package/tsup.config.ts +0 -1
  57. package/vitest.config.ts +9 -1
@@ -0,0 +1,117 @@
1
+ import type { ReactElement, ReactNode } from 'react'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { FooterSection1 } from './footer-section-1'
5
+
6
+ const base = {
7
+ brandName: 'ElectroCuba',
8
+ linkColumns: [],
9
+ }
10
+
11
+ type SubmitHandler = (event: { preventDefault: () => void }) => void
12
+
13
+ function findForm(node: ReactNode): ReactElement<{ onSubmit?: SubmitHandler }> | undefined {
14
+ if (node === null || node === undefined || typeof node !== 'object') return undefined
15
+ if (Array.isArray(node)) {
16
+ for (const child of node) {
17
+ const found = findForm(child)
18
+ if (found) return found
19
+ }
20
+ return undefined
21
+ }
22
+ const element = node as ReactElement<{ children?: ReactNode }>
23
+ if (element.type === 'form') return element as ReactElement<{ onSubmit?: SubmitHandler }>
24
+ return findForm(element.props?.children)
25
+ }
26
+
27
+ describe('FooterSection1 — optional brand accent', () => {
28
+ it('renders the brand name unchanged when no accent is supplied', () => {
29
+ const html = renderToStaticMarkup(<FooterSection1 {...base} />)
30
+ expect(html).toContain('>ElectroCuba</span>')
31
+ expect(html).not.toContain('text-brand-promo')
32
+ })
33
+
34
+ it('accents a matched trailing segment without duplicating the brand name', () => {
35
+ const html = renderToStaticMarkup(
36
+ <FooterSection1 {...base} brandAccent={{ text: 'Cuba', className: 'text-brand-promo' }} />,
37
+ )
38
+ expect(html).toContain('Electro<span class="text-brand-promo">Cuba</span>')
39
+ expect(html.match(/Electro/g)).toHaveLength(1)
40
+ expect(html.match(/Cuba/g)).toHaveLength(1)
41
+ })
42
+
43
+ it('accents the full brand name when the accent covers all of it', () => {
44
+ const html = renderToStaticMarkup(
45
+ <FooterSection1 {...base} brandAccent={{ text: 'ElectroCuba', className: 'text-brand-promo' }} />,
46
+ )
47
+ expect(html).toContain('<span class="text-brand-promo">ElectroCuba</span>')
48
+ })
49
+
50
+ it('does not accent when the segment is not a trailing match', () => {
51
+ const html = renderToStaticMarkup(<FooterSection1 {...base} brandAccent={{ text: 'Electro' }} />)
52
+ expect(html).toContain('>ElectroCuba</span>')
53
+ })
54
+ })
55
+
56
+ describe('FooterSection1 — newsletter requires a submit callback', () => {
57
+ it('renders no form, input, or button when the newsletter has no onSubmit', () => {
58
+ const html = renderToStaticMarkup(
59
+ <FooterSection1 {...base} newsletter={{ buttonLabel: 'Suscribirse', placeholder: 'tu@correo.com' }} />,
60
+ )
61
+ expect(html).not.toContain('<form')
62
+ expect(html).not.toContain('<input')
63
+ expect(html).not.toContain('<button')
64
+ })
65
+
66
+ it('renders the interactive form when a submit handler is supplied', () => {
67
+ const html = renderToStaticMarkup(
68
+ <FooterSection1 {...base} newsletter={{ buttonLabel: 'Suscribirse', onSubmit: () => {} }} />,
69
+ )
70
+ expect(html).toContain('<form')
71
+ expect(html).toContain('<input')
72
+ expect(html).toContain('<button')
73
+ expect(html).toContain('Suscribirse')
74
+ })
75
+
76
+ it('retains the supplied submit handler and forwards the email', () => {
77
+ const onSubmit = vi.fn()
78
+ const tree = FooterSection1({
79
+ ...base,
80
+ newsletter: { buttonLabel: 'Suscribirse', onSubmit },
81
+ })
82
+ const form = findForm(tree)
83
+ expect(form).toBeDefined()
84
+
85
+ vi.stubGlobal(
86
+ 'FormData',
87
+ class {
88
+ get() {
89
+ return 'shopper@example.com'
90
+ }
91
+ },
92
+ )
93
+ try {
94
+ form?.props.onSubmit?.({ preventDefault: () => {} })
95
+ } finally {
96
+ vi.unstubAllGlobals()
97
+ }
98
+
99
+ expect(onSubmit).toHaveBeenCalledWith('shopper@example.com')
100
+ })
101
+ })
102
+
103
+ describe('FooterSection1 — optional outer surface className', () => {
104
+ it('keeps the default surface when className is omitted', () => {
105
+ const html = renderToStaticMarkup(<FooterSection1 {...base} />)
106
+ expect(html).toContain('bg-secondary')
107
+ })
108
+
109
+ it('merges a branded surface onto the footer and drops the default background', () => {
110
+ const html = renderToStaticMarkup(
111
+ <FooterSection1 {...base} className='bg-brand-nav text-brand-nav-foreground' />,
112
+ )
113
+ expect(html).toContain('bg-brand-nav')
114
+ expect(html).toContain('text-brand-nav-foreground')
115
+ expect(html).not.toContain('bg-secondary')
116
+ })
117
+ })
@@ -3,6 +3,7 @@
3
3
  import { Input } from '../../../ui/input'
4
4
  import { Button } from '../../../ui/button'
5
5
  import { ArrowRight } from 'lucide-react'
6
+ import { cn } from '../../../lib/utils'
6
7
 
7
8
  export interface FooterLink {
8
9
  label: string
@@ -17,24 +18,59 @@ export interface FooterLinkColumn {
17
18
  export interface FooterNewsletter {
18
19
  placeholder?: string
19
20
  buttonLabel: string
21
+ /**
22
+ * Submit handler. The interactive form (input + submit button) renders only
23
+ * when this is supplied. A newsletter without `onSubmit` renders no form,
24
+ * input, or button — never a fake form.
25
+ */
20
26
  onSubmit?: (email: string) => void
21
27
  }
22
28
 
29
+ export interface BrandAccent {
30
+ /** Trailing segment of `brandName` to render with the accent styling. */
31
+ text: string
32
+ /** Consumer-supplied classes for the accent span (e.g. a brand color token). */
33
+ className?: string
34
+ }
35
+
23
36
  export interface FooterSection1Props {
24
37
  brandName: string
38
+ /**
39
+ * Optional accent for a trailing segment of `brandName` (e.g. the "Cuba" in
40
+ * "ElectroCuba"). When omitted, or when `text` is not a trailing segment of
41
+ * `brandName`, the brand name renders unchanged.
42
+ */
43
+ brandAccent?: BrandAccent
25
44
  brandDescription?: string
26
45
  linkColumns: FooterLinkColumn[]
27
46
  newsletter?: FooterNewsletter
47
+ /**
48
+ * Consumer-supplied classes merged onto the outer `<footer>` as the final
49
+ * argument, so a semantic surface such as `bg-brand-nav
50
+ * text-brand-nav-foreground` overrides the default `bg-secondary`. Purely
51
+ * presentational: no layout, structure, or behavior changes.
52
+ */
53
+ className?: string
28
54
  }
29
55
 
30
56
  export function FooterSection1({
31
57
  brandName,
58
+ brandAccent,
32
59
  brandDescription,
33
60
  linkColumns,
34
61
  newsletter,
62
+ className,
35
63
  }: FooterSection1Props) {
64
+ const accentText = brandAccent?.text ?? ''
65
+ const hasAccent = accentText.length > 0 && brandName.endsWith(accentText)
66
+ const baseName = hasAccent ? brandName.slice(0, brandName.length - accentText.length) : brandName
67
+ const newsletterSubmit = newsletter?.onSubmit
68
+
36
69
  return (
37
- <footer className='bg-secondary w-full py-16' aria-label='Pie de página'>
70
+ <footer
71
+ className={cn('bg-secondary w-full py-16', className)}
72
+ aria-label='Pie de página'
73
+ >
38
74
  <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
39
75
  <div className='grid grid-cols-1 gap-8 lg:grid-cols-12'>
40
76
  {/* Left Section */}
@@ -66,23 +102,24 @@ export function FooterSection1({
66
102
  <circle cx='13.1' cy='26.1' r='1.7' stroke='currentColor' />
67
103
  <circle cx='22.1' cy='26.1' r='1.7' stroke='currentColor' />
68
104
  </svg>
69
- <span className='text-xl font-semibold'>{brandName}</span>
105
+ <span className='text-xl font-semibold'>
106
+ {baseName}
107
+ {hasAccent ? <span className={brandAccent?.className}>{accentText}</span> : null}
108
+ </span>
70
109
  </div>
71
110
  {brandDescription && (
72
111
  <p className='text-muted-foreground mb-8 max-w-sm text-sm leading-relaxed text-balance'>
73
112
  {brandDescription}
74
113
  </p>
75
114
  )}
76
- {newsletter && (
115
+ {newsletter && newsletterSubmit ? (
77
116
  <form
78
117
  className='relative'
79
118
  onSubmit={e => {
80
119
  e.preventDefault()
81
- if (newsletter.onSubmit) {
82
- const email = new FormData(e.currentTarget).get('email')
83
- if (typeof email === 'string') {
84
- newsletter.onSubmit(email)
85
- }
120
+ const email = new FormData(e.currentTarget).get('email')
121
+ if (typeof email === 'string') {
122
+ newsletterSubmit(email)
86
123
  }
87
124
  }}
88
125
  >
@@ -103,7 +140,7 @@ export function FooterSection1({
103
140
  <ArrowRight />
104
141
  </Button>
105
142
  </form>
106
- )}
143
+ ) : null}
107
144
  </div>
108
145
 
109
146
  {/* Right Section */}
@@ -0,0 +1,54 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Navbar4CartSheet, type CartItem } from './navbar-4-cart-sheet'
4
+
5
+ const items: CartItem[] = [
6
+ { id: 'line_1', name: 'Taladro', variant: 'Rojo', price: 45, qty: 2, image: 'https://cdn.example/t.jpg' },
7
+ ]
8
+
9
+ const formatMoney = (money: { amount: string; currencyCode: string }) =>
10
+ `${money.currencyCode} ${money.amount}`
11
+
12
+ const noop = () => {}
13
+
14
+ describe('Navbar4CartSheet — authoritative subtotal', () => {
15
+ it('never renders a fake zero for a supplied but absent/malformed subtotal', () => {
16
+ const html = renderToStaticMarkup(
17
+ <Navbar4CartSheet
18
+ items={items}
19
+ onQtyChange={noop}
20
+ onRemove={noop}
21
+ subtotal={{ amount: '', currencyCode: 'USD' }}
22
+ formatMoney={formatMoney}
23
+ />,
24
+ )
25
+ expect(html).not.toContain('USD 0')
26
+ expect(html).toContain('—')
27
+ })
28
+
29
+ it('preserves a legitimate zero subtotal', () => {
30
+ const html = renderToStaticMarkup(
31
+ <Navbar4CartSheet
32
+ items={items}
33
+ onQtyChange={noop}
34
+ onRemove={noop}
35
+ subtotal={{ amount: '0', currencyCode: 'USD' }}
36
+ formatMoney={formatMoney}
37
+ />,
38
+ )
39
+ expect(html).toContain('USD 0')
40
+ expect(html).not.toContain('—')
41
+ })
42
+
43
+ it('falls back to the legacy local subtotal only when none is supplied', () => {
44
+ const html = renderToStaticMarkup(
45
+ <Navbar4CartSheet
46
+ items={items}
47
+ onQtyChange={noop}
48
+ onRemove={noop}
49
+ formatPrice={value => `$${value.toFixed(2)}`}
50
+ />,
51
+ )
52
+ expect(html).toContain('$90.00')
53
+ })
54
+ })
@@ -15,21 +15,37 @@ 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: string
25
+ variant?: string
23
26
  price: number
24
27
  qty: number
25
- image: string
28
+ /** Optional; an absent image is omitted rather than serialized as `""`. */
29
+ image?: string
26
30
  }
27
31
 
28
32
  export interface Navbar4CartSheetProps {
29
33
  items: CartItem[]
34
+ /**
35
+ * Quantity DELTA for a backend cart line-item id. The boundary converts the
36
+ * delta into an absolute quantity against the authoritative current line
37
+ * quantity before mutating the cart.
38
+ */
30
39
  onQtyChange: (id: string, delta: number) => void
40
+ /** Remove the backend cart line-item id. */
31
41
  onRemove: (id: string) => void
32
- /** Override price formatting (locale, currency). Defaults to USD. */
42
+ /** Authoritative backend subtotal. Preferred over any local line math. */
43
+ subtotal?: Money
44
+ /** Shown when an authoritative subtotal is supplied but absent/malformed. */
45
+ subtotalUnavailableLabel?: string
46
+ /** Override money formatting (locale, currency). Defaults to `Money.currencyCode`. */
47
+ formatMoney?: (money: Money) => string
48
+ /** @deprecated legacy numeric formatter; ignored when `subtotal` is supplied. */
33
49
  formatPrice?: (value: number) => string
34
50
  onCheckout?: () => void
35
51
  onViewCart?: () => void
@@ -59,13 +75,17 @@ export function Navbar4CartSheet({
59
75
  items,
60
76
  onQtyChange,
61
77
  onRemove,
78
+ subtotal: subtotalProp,
79
+ subtotalUnavailableLabel = '—',
80
+ formatMoney: formatMoneyProp,
62
81
  formatPrice,
63
82
  onCheckout,
64
83
  onViewCart,
65
84
  }: Navbar4CartSheetProps) {
66
85
  const reduceMotion = useReducedMotion()
67
86
  const format = formatPrice ?? (value => usdFormatter.format(value))
68
- const subtotal = items.reduce((total, item) => total + item.price * item.qty, 0)
87
+ const localSubtotal = items.reduce((total, item) => total + item.price * item.qty, 0)
88
+ const authoritativeSubtotal = subtotalProp !== undefined ? formatMoney(subtotalProp, formatMoneyProp) : null
69
89
  const isEmpty = items.length === 0
70
90
  /** Wait for the last cart row's exit before revealing empty copy. */
71
91
  const [showEmpty, setShowEmpty] = useState(isEmpty)
@@ -100,18 +120,24 @@ export function Navbar4CartSheet({
100
120
  className='border-border flex gap-4 border-b py-4 last:border-b-0'
101
121
  style={{ willChange: 'transform, opacity, filter' }}
102
122
  >
103
- <img
104
- src={item.image || undefined}
105
- alt=''
106
- width={80}
107
- height={96}
108
- className='bg-muted size-20 shrink-0 rounded-md object-cover'
109
- />
123
+ {item.image ? (
124
+ <img
125
+ src={item.image || undefined}
126
+ alt=''
127
+ width={80}
128
+ height={96}
129
+ className='bg-muted size-20 shrink-0 rounded-md object-cover'
130
+ />
131
+ ) : (
132
+ <div aria-hidden='true' className='bg-muted size-20 shrink-0 rounded-md' />
133
+ )}
110
134
  <div className='flex min-w-0 flex-1 flex-col'>
111
135
  <div className='flex items-start justify-between gap-2'>
112
136
  <div className='min-w-0'>
113
137
  <p className='truncate text-sm font-medium'>{item.name}</p>
114
- <p className='text-muted-foreground text-sm'>{item.variant}</p>
138
+ {item.variant ? (
139
+ <p className='text-muted-foreground text-sm'>{item.variant}</p>
140
+ ) : null}
115
141
  </div>
116
142
  <p className='text-sm font-medium'>{format(item.price * item.qty)}</p>
117
143
  </div>
@@ -230,7 +256,11 @@ export function Navbar4CartSheet({
230
256
  <Separator />
231
257
  <div className='flex items-center justify-between text-sm'>
232
258
  <span className='text-muted-foreground'>Subtotal</span>
233
- <span className='font-medium tabular-nums'>{format(subtotal)}</span>
259
+ <span className='font-medium tabular-nums'>
260
+ {subtotalProp !== undefined
261
+ ? authoritativeSubtotal ?? subtotalUnavailableLabel
262
+ : format(localSubtotal)}
263
+ </span>
234
264
  </div>
235
265
  <p className='text-muted-foreground text-xs'>Shipping and taxes are calculated at checkout.</p>
236
266
  <div className='flex flex-col gap-2'>
@@ -0,0 +1,112 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Navbar4 } from './navbar-4'
4
+
5
+ const base = {
6
+ storeCategories: [],
7
+ shippingNote: 'Entrega segura',
8
+ utilityLinks: [],
9
+ logoText: 'ElectroCuba',
10
+ }
11
+
12
+ describe('Navbar4 — optional logo accent', () => {
13
+ it('renders the wordmark unchanged when no accent is supplied', () => {
14
+ const html = renderToStaticMarkup(<Navbar4 {...base} />)
15
+ expect(html).toContain('>ElectroCuba</span>')
16
+ expect(html).not.toContain('text-brand-promo')
17
+ })
18
+
19
+ it('accents a matched trailing segment without duplicating the wordmark', () => {
20
+ const html = renderToStaticMarkup(
21
+ <Navbar4 {...base} logoAccent={{ text: 'Cuba', className: 'text-brand-promo' }} />,
22
+ )
23
+ expect(html).toContain('Electro<span class="text-brand-promo">Cuba</span>')
24
+ expect(html.match(/Electro/g)).toHaveLength(1)
25
+ expect(html.match(/Cuba/g)).toHaveLength(1)
26
+ })
27
+
28
+ it('accents the full wordmark when the accent covers all of it', () => {
29
+ const html = renderToStaticMarkup(
30
+ <Navbar4 {...base} logoAccent={{ text: 'ElectroCuba', className: 'text-brand-promo' }} />,
31
+ )
32
+ expect(html).toContain('<span class="text-brand-promo">ElectroCuba</span>')
33
+ })
34
+
35
+ it('does not accent when the segment is not a trailing match', () => {
36
+ const html = renderToStaticMarkup(<Navbar4 {...base} logoAccent={{ text: 'Electro' }} />)
37
+ expect(html).toContain('>ElectroCuba</span>')
38
+ expect(html).not.toContain('<span class="">')
39
+ })
40
+
41
+ it('keeps the wordmark as the accessible text of the logo link', () => {
42
+ const html = renderToStaticMarkup(
43
+ <Navbar4 {...base} logoHref='/' logoAccent={{ text: 'Cuba', className: 'text-brand-promo' }} />,
44
+ )
45
+ expect(html).toContain('href="/"')
46
+ expect(html).toContain('Electro<span class="text-brand-promo">Cuba</span>')
47
+ })
48
+ })
49
+
50
+ const category = { href: '/herramientas', label: 'Herramientas' }
51
+
52
+ // The desktop nav link (the mobile drawer also emits an anchor for the same
53
+ // href, but only the desktop nav link carries `hover:bg-muted`).
54
+ const navLinkClass = (html: string) =>
55
+ html.match(/<a href="\/herramientas" class="([^"]*hover:bg-muted[^"]*)"/)?.[1] ?? ''
56
+
57
+ describe('Navbar4 — optional outer surface className', () => {
58
+ it('keeps the default surface (incl. backdrop-filter variant) and nav link color when className is omitted', () => {
59
+ const html = renderToStaticMarkup(<Navbar4 {...base} storeCategories={[category]} />)
60
+ expect(html).toContain('bg-background/95')
61
+ expect(html).toContain('supports-backdrop-filter:bg-background/80')
62
+ expect(navLinkClass(html)).toContain('text-foreground')
63
+ expect(navLinkClass(html)).not.toContain('hover:text-foreground')
64
+ })
65
+
66
+ it('gives the consumer full surface ownership when className is supplied (no default background or variant)', () => {
67
+ const html = renderToStaticMarkup(
68
+ <Navbar4 {...base} storeCategories={[category]} className='bg-brand-nav text-brand-nav-foreground' />,
69
+ )
70
+ expect(html).toContain('bg-brand-nav')
71
+ expect(html).toContain('text-brand-nav-foreground')
72
+ expect(html).not.toContain('bg-background/95')
73
+ expect(html).not.toContain('supports-backdrop-filter:bg-background/80')
74
+ // Layout/border/sticky/backdrop classes stay internal and present.
75
+ expect(html).toContain('border-border')
76
+ expect(html).toContain('sticky')
77
+ expect(html).toContain('backdrop-blur-md')
78
+ })
79
+
80
+ it('lets nav links inherit the branded surface text color with a correct hover foreground', () => {
81
+ const html = renderToStaticMarkup(
82
+ <Navbar4 {...base} storeCategories={[category]} className='bg-brand-nav text-brand-nav-foreground' />,
83
+ )
84
+ expect(navLinkClass(html)).toContain('text-inherit')
85
+ expect(navLinkClass(html)).toContain('hover:text-foreground')
86
+ })
87
+ })
88
+
89
+ describe('Navbar4 — optional showSearch (E2 task 5.4)', () => {
90
+ it('renders the search control by default (legacy consumers unaffected)', () => {
91
+ const html = renderToStaticMarkup(<Navbar4 {...base} />)
92
+ expect(html).toContain('role="search"')
93
+ expect(html).toContain('type="search"')
94
+ expect(html).toContain('aria-label="Search products"')
95
+ })
96
+
97
+ it('renders no search control when showSearch is false, keeping the rest of the chrome', () => {
98
+ const html = renderToStaticMarkup(<Navbar4 {...base} showSearch={false} />)
99
+ expect(html).not.toContain('role="search"')
100
+ expect(html).not.toContain('type="search"')
101
+ expect(html).not.toContain('aria-label="Search products"')
102
+ // Non-search chrome is unaffected: the wordmark and cart trigger remain.
103
+ expect(html).toContain('ElectroCuba')
104
+ expect(html).toContain('aria-label="Cart, 0 items"')
105
+ })
106
+
107
+ it('renders the search control when showSearch is explicitly true', () => {
108
+ const html = renderToStaticMarkup(<Navbar4 {...base} showSearch={true} />)
109
+ expect(html).toContain('role="search"')
110
+ expect(html).toContain('type="search"')
111
+ })
112
+ })