@akinon/pz-theme 2.0.38-beta.0 → 2.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -4
- package/package.json +4 -4
- package/src/__tests__/chrome-takeover-gate.test.tsx +252 -0
- package/src/__tests__/registry-parity.test.ts +14 -1
- package/src/__tests__/route-slug.test.ts +116 -0
- package/src/blocks/__tests__/overlay-sweep.test.tsx +592 -0
- package/src/blocks/account-button-block.tsx +133 -0
- package/src/blocks/announcement-bar-block.tsx +407 -0
- package/src/blocks/block-renderer-registry.tsx +26 -0
- package/src/blocks/currency-selector-block.tsx +177 -0
- package/src/blocks/hamburger-menu-block.tsx +461 -0
- package/src/blocks/language-selector-block.tsx +172 -0
- package/src/blocks/mega-menu-block.tsx +712 -0
- package/src/blocks/mini-basket-block.tsx +173 -0
- package/src/blocks/nav-menu-block.tsx +309 -0
- package/src/blocks/newsletter-form-block.tsx +184 -0
- package/src/blocks/payment-badges-block.tsx +393 -0
- package/src/blocks/search-box-block.tsx +277 -0
- package/src/blocks/social-links-block.tsx +391 -0
- package/src/blocks/wishlist-button-block.tsx +115 -0
- package/src/chrome/chrome-fallback.tsx +147 -0
- package/src/chrome/chrome-slot.tsx +68 -0
- package/src/chrome/composition-state.ts +92 -0
- package/src/chrome/page-context.ts +387 -0
- package/src/chrome/placeholder-slugs.ts +86 -0
- package/src/chrome/route-slug.ts +159 -0
- package/src/index.ts +20 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState
|
|
9
|
+
} from 'react';
|
|
10
|
+
import NextLink from 'next/link';
|
|
11
|
+
import { usePathname } from 'next/navigation';
|
|
12
|
+
import { useGetMenuQuery } from '@akinon/next/data/client/misc';
|
|
13
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
14
|
+
import { useOverlay } from '../hooks/use-overlay';
|
|
15
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
16
|
+
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
17
|
+
import { DesignerPlaceholder, readToggle } from './product-block-utils';
|
|
18
|
+
|
|
19
|
+
/** Matches the dwell the reference brand header uses before a panel opens. */
|
|
20
|
+
const HOVER_OPEN_DELAY = 200;
|
|
21
|
+
const HOVER_CLOSE_DELAY = 120;
|
|
22
|
+
|
|
23
|
+
/** Query strings and trailing slashes are noise when matching the open page. */
|
|
24
|
+
const normalizePath = (value: unknown): string => {
|
|
25
|
+
const path = String(value || '')
|
|
26
|
+
.split('?')[0]
|
|
27
|
+
.split('#')[0];
|
|
28
|
+
|
|
29
|
+
return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
interface MenuNodeLike {
|
|
33
|
+
pk?: number | string;
|
|
34
|
+
label?: string;
|
|
35
|
+
url?: string;
|
|
36
|
+
parent_pk?: number | string | null;
|
|
37
|
+
extra_context?: { attributes?: Record<string, unknown> } | null;
|
|
38
|
+
children?: MenuNodeLike[] | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface MegaMenuNode {
|
|
42
|
+
key: string;
|
|
43
|
+
label: string;
|
|
44
|
+
url: string;
|
|
45
|
+
attributes: Record<string, unknown>;
|
|
46
|
+
children: MegaMenuNode[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const clampNumber = (
|
|
50
|
+
value: unknown,
|
|
51
|
+
fallback: number,
|
|
52
|
+
min: number,
|
|
53
|
+
max: number
|
|
54
|
+
) => {
|
|
55
|
+
const numeric = Math.round(Number(value));
|
|
56
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
57
|
+
return Math.min(max, Math.max(min, numeric));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const toNode = (raw: MenuNodeLike, index: number): MegaMenuNode => ({
|
|
61
|
+
key: String(raw.pk ?? `menu-${index}`),
|
|
62
|
+
label: String(raw.label ?? ''),
|
|
63
|
+
url: String(raw.url ?? ''),
|
|
64
|
+
attributes: (raw.extra_context?.attributes as Record<string, unknown>) || {},
|
|
65
|
+
children: []
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The menu endpoint answers a depth>1 request with a FLAT list linked by
|
|
70
|
+
* `parent_pk`, so the columns have to be assembled here. Core's `menuGenerator`
|
|
71
|
+
* does `data[item.parent_pk].children.push(...)` unguarded and throws the whole
|
|
72
|
+
* header away when a parent falls outside the requested depth window; this
|
|
73
|
+
* version keeps those entries instead of crashing.
|
|
74
|
+
*/
|
|
75
|
+
const buildTree = (raw: MenuNodeLike[]): MegaMenuNode[] => {
|
|
76
|
+
if (raw.length === 0) return [];
|
|
77
|
+
|
|
78
|
+
const alreadyNested = raw.some(
|
|
79
|
+
(entry) => Array.isArray(entry.children) && entry.children.length > 0
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (alreadyNested) {
|
|
83
|
+
const walk = (entries: MenuNodeLike[]): MegaMenuNode[] =>
|
|
84
|
+
entries.map((entry, index) => ({
|
|
85
|
+
...toNode(entry, index),
|
|
86
|
+
children: walk(Array.isArray(entry.children) ? entry.children : [])
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
return walk(raw);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const nodes = new Map<string, MegaMenuNode>();
|
|
93
|
+
raw.forEach((entry, index) => {
|
|
94
|
+
const node = toNode(entry, index);
|
|
95
|
+
nodes.set(node.key, node);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const roots: MegaMenuNode[] = [];
|
|
99
|
+
const orphans: MegaMenuNode[] = [];
|
|
100
|
+
|
|
101
|
+
raw.forEach((entry, index) => {
|
|
102
|
+
const node = nodes.get(String(entry.pk ?? `menu-${index}`));
|
|
103
|
+
if (!node) return;
|
|
104
|
+
|
|
105
|
+
const parentKey =
|
|
106
|
+
entry.parent_pk === null || entry.parent_pk === undefined
|
|
107
|
+
? ''
|
|
108
|
+
: String(entry.parent_pk);
|
|
109
|
+
|
|
110
|
+
if (!parentKey) {
|
|
111
|
+
roots.push(node);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const parent = nodes.get(parentKey);
|
|
116
|
+
if (parent) {
|
|
117
|
+
parent.children.push(node);
|
|
118
|
+
} else {
|
|
119
|
+
orphans.push(node);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// A `parent` window that starts below the tree root has no parentless entry
|
|
124
|
+
// at all; falling back to the orphans keeps the menu visible.
|
|
125
|
+
return roots.length > 0 ? roots : orphans;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Brands hang the panel artwork off the menu item under a handful of attribute
|
|
130
|
+
* names, each with its own nesting (`kwargs.value.top_image.url`,
|
|
131
|
+
* `kwargs.url`, ...). Probing a short key list and then walking for a `url`
|
|
132
|
+
* keeps one renderer working across those payload shapes.
|
|
133
|
+
*/
|
|
134
|
+
const IMAGE_ATTRIBUTE_KEYS = [
|
|
135
|
+
'navigation_image',
|
|
136
|
+
'nav_sub_category_image',
|
|
137
|
+
'menu_image',
|
|
138
|
+
'image'
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const looksLikeUrl = (value: string) =>
|
|
142
|
+
/^(https?:)?\/\//i.test(value) || value.startsWith('/');
|
|
143
|
+
|
|
144
|
+
const findUrl = (value: unknown, depth = 0): string => {
|
|
145
|
+
if (depth > 5) return '';
|
|
146
|
+
|
|
147
|
+
if (typeof value === 'string') return looksLikeUrl(value) ? value : '';
|
|
148
|
+
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
for (const entry of value) {
|
|
151
|
+
const found = findUrl(entry, depth + 1);
|
|
152
|
+
if (found) return found;
|
|
153
|
+
}
|
|
154
|
+
return '';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (value && typeof value === 'object') {
|
|
158
|
+
const record = value as Record<string, unknown>;
|
|
159
|
+
|
|
160
|
+
// `url` is the key commerce renders the asset under; preferring it stops a
|
|
161
|
+
// sibling link field from being picked up as the image source.
|
|
162
|
+
if (typeof record.url === 'string' && looksLikeUrl(record.url)) {
|
|
163
|
+
return record.url;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const entry of Object.values(record)) {
|
|
167
|
+
const found = findUrl(entry, depth + 1);
|
|
168
|
+
if (found) return found;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return '';
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const readPromoImage = (attributes: Record<string, unknown>): string => {
|
|
176
|
+
for (const key of IMAGE_ATTRIBUTE_KEYS) {
|
|
177
|
+
const found = findUrl(attributes?.[key]);
|
|
178
|
+
if (found) return found;
|
|
179
|
+
}
|
|
180
|
+
return '';
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Functional chrome block: top level menu entries that open a full width panel
|
|
185
|
+
* with the child columns and an optional promo image. Menu CONTENT stays
|
|
186
|
+
* commerce-driven (menu service, depth 2-3); the editor owns the trigger,
|
|
187
|
+
* column count, highlights and styling.
|
|
188
|
+
* Properties: depth, parent, trigger, columns, showImages, highlightLabels,
|
|
189
|
+
* highlightColor, columnGap, openDelay, closeDelay, showParentLink,
|
|
190
|
+
* parentLinkLabel, promoPosition, promoMaxWidth, activeMatch, maxItems,
|
|
191
|
+
* emptyText, loadingText.
|
|
192
|
+
* Parts: menu, item, panel, parent-link, column, column-title, column-image,
|
|
193
|
+
* submenu, submenu-item, promo.
|
|
194
|
+
*/
|
|
195
|
+
const MegaMenuBlock = ({
|
|
196
|
+
block,
|
|
197
|
+
placeholderId,
|
|
198
|
+
sectionId,
|
|
199
|
+
selectedBlockId = null,
|
|
200
|
+
currentBreakpoint = 'desktop',
|
|
201
|
+
isDesigner = false
|
|
202
|
+
}: BlockRendererProps) => {
|
|
203
|
+
const pathname = usePathname();
|
|
204
|
+
const [openKey, setOpenKey] = useState<string | null>(null);
|
|
205
|
+
const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
206
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
207
|
+
|
|
208
|
+
const depth = clampNumber(
|
|
209
|
+
getResponsiveValue(block.properties?.depth, currentBreakpoint, 3),
|
|
210
|
+
3,
|
|
211
|
+
2,
|
|
212
|
+
3
|
|
213
|
+
);
|
|
214
|
+
const parent = String(
|
|
215
|
+
getResponsiveValue(block.properties?.parent, currentBreakpoint, '')
|
|
216
|
+
).trim();
|
|
217
|
+
const trigger =
|
|
218
|
+
String(
|
|
219
|
+
getResponsiveValue(block.properties?.trigger, currentBreakpoint, 'hover')
|
|
220
|
+
) === 'click'
|
|
221
|
+
? 'click'
|
|
222
|
+
: 'hover';
|
|
223
|
+
const columns = clampNumber(
|
|
224
|
+
getResponsiveValue(block.properties?.columns, currentBreakpoint, 4),
|
|
225
|
+
4,
|
|
226
|
+
1,
|
|
227
|
+
6
|
|
228
|
+
);
|
|
229
|
+
const showImages = readToggle(
|
|
230
|
+
block.properties?.showImages,
|
|
231
|
+
currentBreakpoint,
|
|
232
|
+
true
|
|
233
|
+
);
|
|
234
|
+
const highlightColor = String(
|
|
235
|
+
getResponsiveValue(block.properties?.highlightColor, currentBreakpoint, '')
|
|
236
|
+
);
|
|
237
|
+
const highlightLabels = String(
|
|
238
|
+
getResponsiveValue(block.properties?.highlightLabels, currentBreakpoint, '')
|
|
239
|
+
)
|
|
240
|
+
.split(',')
|
|
241
|
+
.map((entry) => entry.trim().toLocaleLowerCase())
|
|
242
|
+
.filter(Boolean);
|
|
243
|
+
const columnGap = clampNumber(
|
|
244
|
+
getResponsiveValue(block.properties?.columnGap, currentBreakpoint, 24),
|
|
245
|
+
24,
|
|
246
|
+
0,
|
|
247
|
+
96
|
|
248
|
+
);
|
|
249
|
+
const openDelay = clampNumber(
|
|
250
|
+
getResponsiveValue(
|
|
251
|
+
block.properties?.openDelay,
|
|
252
|
+
currentBreakpoint,
|
|
253
|
+
HOVER_OPEN_DELAY
|
|
254
|
+
),
|
|
255
|
+
HOVER_OPEN_DELAY,
|
|
256
|
+
0,
|
|
257
|
+
1000
|
|
258
|
+
);
|
|
259
|
+
const closeDelay = clampNumber(
|
|
260
|
+
getResponsiveValue(
|
|
261
|
+
block.properties?.closeDelay,
|
|
262
|
+
currentBreakpoint,
|
|
263
|
+
HOVER_CLOSE_DELAY
|
|
264
|
+
),
|
|
265
|
+
HOVER_CLOSE_DELAY,
|
|
266
|
+
0,
|
|
267
|
+
1000
|
|
268
|
+
);
|
|
269
|
+
const showParentLink = readToggle(
|
|
270
|
+
block.properties?.showParentLink,
|
|
271
|
+
currentBreakpoint,
|
|
272
|
+
true
|
|
273
|
+
);
|
|
274
|
+
const parentLinkLabel = String(
|
|
275
|
+
getResponsiveValue(block.properties?.parentLinkLabel, currentBreakpoint, '')
|
|
276
|
+
);
|
|
277
|
+
const promoPosition = String(
|
|
278
|
+
getResponsiveValue(
|
|
279
|
+
block.properties?.promoPosition,
|
|
280
|
+
currentBreakpoint,
|
|
281
|
+
'end'
|
|
282
|
+
)
|
|
283
|
+
);
|
|
284
|
+
const promoMaxWidth = clampNumber(
|
|
285
|
+
getResponsiveValue(block.properties?.promoMaxWidth, currentBreakpoint, 256),
|
|
286
|
+
256,
|
|
287
|
+
80,
|
|
288
|
+
720
|
|
289
|
+
);
|
|
290
|
+
const activeMatch = String(
|
|
291
|
+
getResponsiveValue(
|
|
292
|
+
block.properties?.activeMatch,
|
|
293
|
+
currentBreakpoint,
|
|
294
|
+
'exact'
|
|
295
|
+
)
|
|
296
|
+
);
|
|
297
|
+
const maxItems = clampNumber(
|
|
298
|
+
getResponsiveValue(block.properties?.maxItems, currentBreakpoint, 0),
|
|
299
|
+
0,
|
|
300
|
+
0,
|
|
301
|
+
40
|
|
302
|
+
);
|
|
303
|
+
const emptyText = String(
|
|
304
|
+
getResponsiveValue(
|
|
305
|
+
block.properties?.emptyText,
|
|
306
|
+
currentBreakpoint,
|
|
307
|
+
'Mega menu (no items)'
|
|
308
|
+
)
|
|
309
|
+
);
|
|
310
|
+
const loadingText = String(
|
|
311
|
+
getResponsiveValue(
|
|
312
|
+
block.properties?.loadingText,
|
|
313
|
+
currentBreakpoint,
|
|
314
|
+
'Mega menu (loading)'
|
|
315
|
+
)
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const { data: menu, isLoading } = useGetMenuQuery({
|
|
319
|
+
depth,
|
|
320
|
+
...(parent ? { parent } : {})
|
|
321
|
+
} as Parameters<typeof useGetMenuQuery>[0]);
|
|
322
|
+
|
|
323
|
+
const allItems = useMemo(
|
|
324
|
+
() => buildTree(Array.isArray(menu) ? (menu as MenuNodeLike[]) : []),
|
|
325
|
+
[menu]
|
|
326
|
+
);
|
|
327
|
+
const items = maxItems > 0 ? allItems.slice(0, maxItems) : allItems;
|
|
328
|
+
|
|
329
|
+
const clearTimers = useCallback(() => {
|
|
330
|
+
if (openTimer.current) clearTimeout(openTimer.current);
|
|
331
|
+
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
332
|
+
openTimer.current = null;
|
|
333
|
+
closeTimer.current = null;
|
|
334
|
+
}, []);
|
|
335
|
+
|
|
336
|
+
const closeNow = useCallback(() => {
|
|
337
|
+
clearTimers();
|
|
338
|
+
setOpenKey(null);
|
|
339
|
+
}, [clearTimers]);
|
|
340
|
+
|
|
341
|
+
useEffect(() => clearTimers, [clearTimers]);
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Escape is page-wide state, so the shared overlay stack decides whose it
|
|
345
|
+
* is. This panel used to answer it from its own document listener, which
|
|
346
|
+
* meant an Escape aimed at a modal opened over the header collapsed the menu
|
|
347
|
+
* underneath at the same time.
|
|
348
|
+
*
|
|
349
|
+
* Nothing else moves to the primitive: the panel hangs off the header in
|
|
350
|
+
* flow behind no backdrop, so `modal: false` (no `aria-modal`, no focus
|
|
351
|
+
* trap) and `manageFocus: false` — the item's own focus and blur handlers
|
|
352
|
+
* already drive this menu open and closed.
|
|
353
|
+
*/
|
|
354
|
+
useOverlay({
|
|
355
|
+
open: Boolean(openKey),
|
|
356
|
+
onClose: closeNow,
|
|
357
|
+
isDesigner,
|
|
358
|
+
modal: false,
|
|
359
|
+
manageFocus: false
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const scheduleOpen = (key: string) => {
|
|
363
|
+
clearTimers();
|
|
364
|
+
openTimer.current = setTimeout(() => setOpenKey(key), openDelay);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const scheduleClose = () => {
|
|
368
|
+
clearTimers();
|
|
369
|
+
closeTimer.current = setTimeout(() => setOpenKey(null), closeDelay);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const isHighlighted = (label: string) =>
|
|
373
|
+
Boolean(
|
|
374
|
+
label &&
|
|
375
|
+
highlightLabels.some((entry) =>
|
|
376
|
+
label.toLocaleLowerCase().includes(entry)
|
|
377
|
+
)
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// The entry for the page the shopper is on carries `selected` so a part
|
|
381
|
+
// style can address it without the brand hand-listing every label.
|
|
382
|
+
const isCurrent = (url: string) => {
|
|
383
|
+
const target = normalizePath(url);
|
|
384
|
+
const current = normalizePath(pathname);
|
|
385
|
+
|
|
386
|
+
if (!target || target === '#' || !current) return false;
|
|
387
|
+
if (activeMatch === 'section' && target !== '/') {
|
|
388
|
+
return current === target || current.startsWith(`${target}/`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return current === target;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
// Navigating inside the designer iframe would drop the author out of the page
|
|
395
|
+
// they are editing, so every link in the menu is inert there.
|
|
396
|
+
const guard = (event: React.MouseEvent) => {
|
|
397
|
+
if (isDesigner) event.preventDefault();
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const linkHref = (url: string) => (isDesigner ? '#' : url || '#');
|
|
401
|
+
|
|
402
|
+
if (items.length === 0) {
|
|
403
|
+
return isDesigner ? (
|
|
404
|
+
<DesignerPlaceholder
|
|
405
|
+
label={isLoading ? loadingText : emptyText}
|
|
406
|
+
attribute="data-mega-menu"
|
|
407
|
+
/>
|
|
408
|
+
) : null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const renderPanel = (item: MegaMenuNode) => {
|
|
412
|
+
const promoImage = showImages ? readPromoImage(item.attributes) : '';
|
|
413
|
+
if (item.children.length === 0 && !promoImage) return null;
|
|
414
|
+
|
|
415
|
+
// A designer who marked a child as the column layout gets that layout
|
|
416
|
+
// repeated per column; otherwise the built-in columns render unchanged.
|
|
417
|
+
const customColumns = hasItemTemplate(block, currentBreakpoint) ? (
|
|
418
|
+
<ItemTemplateList
|
|
419
|
+
block={block}
|
|
420
|
+
items={item.children.map((column) => ({
|
|
421
|
+
key: column.key,
|
|
422
|
+
name: column.label,
|
|
423
|
+
label: column.label,
|
|
424
|
+
url: column.url,
|
|
425
|
+
image: showImages ? readPromoImage(column.attributes) : ''
|
|
426
|
+
}))}
|
|
427
|
+
placeholderId={placeholderId}
|
|
428
|
+
sectionId={sectionId}
|
|
429
|
+
isDesigner={isDesigner}
|
|
430
|
+
selectedBlockId={selectedBlockId}
|
|
431
|
+
currentBreakpoint={currentBreakpoint}
|
|
432
|
+
designerPlaceholderCount={4}
|
|
433
|
+
/>
|
|
434
|
+
) : null;
|
|
435
|
+
|
|
436
|
+
const promo = promoImage ? (
|
|
437
|
+
<NextLink
|
|
438
|
+
href={linkHref(item.url)}
|
|
439
|
+
data-mega-menu-promo=""
|
|
440
|
+
{...partAttrs('promo')}
|
|
441
|
+
onClick={guard}
|
|
442
|
+
style={{
|
|
443
|
+
flex: '0 0 auto',
|
|
444
|
+
display: 'block',
|
|
445
|
+
maxWidth: `${promoMaxWidth}px`
|
|
446
|
+
}}
|
|
447
|
+
>
|
|
448
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
449
|
+
<img
|
|
450
|
+
src={promoImage}
|
|
451
|
+
alt={item.label}
|
|
452
|
+
loading="lazy"
|
|
453
|
+
data-mega-menu-promo-image=""
|
|
454
|
+
{...partAttrs('promo-image')}
|
|
455
|
+
style={{ display: 'block', width: '100%', height: 'auto' }}
|
|
456
|
+
/>
|
|
457
|
+
</NextLink>
|
|
458
|
+
) : null;
|
|
459
|
+
|
|
460
|
+
return (
|
|
461
|
+
<div
|
|
462
|
+
data-mega-menu-panel=""
|
|
463
|
+
{...partAttrs('panel', { open: true })}
|
|
464
|
+
onMouseEnter={() => {
|
|
465
|
+
if (trigger === 'hover') clearTimers();
|
|
466
|
+
}}
|
|
467
|
+
onMouseLeave={() => {
|
|
468
|
+
if (trigger === 'hover') scheduleClose();
|
|
469
|
+
}}
|
|
470
|
+
style={{
|
|
471
|
+
position: 'absolute',
|
|
472
|
+
top: '100%',
|
|
473
|
+
left: 0,
|
|
474
|
+
minWidth: '100%',
|
|
475
|
+
maxWidth: '100vw',
|
|
476
|
+
zIndex: 60,
|
|
477
|
+
display: 'flex',
|
|
478
|
+
alignItems: 'flex-start',
|
|
479
|
+
gap: '32px',
|
|
480
|
+
padding: 'var(--mega-menu-panel-padding, 24px)',
|
|
481
|
+
background: 'var(--mega-menu-panel-bg, #ffffff)',
|
|
482
|
+
color: 'var(--mega-menu-panel-color, #111827)',
|
|
483
|
+
boxShadow: '0 12px 24px rgba(0,0,0,0.12)',
|
|
484
|
+
textAlign: 'left'
|
|
485
|
+
}}
|
|
486
|
+
>
|
|
487
|
+
{promoPosition === 'start' && promo}
|
|
488
|
+
|
|
489
|
+
{item.children.length > 0 && (
|
|
490
|
+
<div
|
|
491
|
+
data-mega-menu-columns=""
|
|
492
|
+
{...partAttrs('column-list')}
|
|
493
|
+
style={{
|
|
494
|
+
flex: '1 1 auto',
|
|
495
|
+
display: 'grid',
|
|
496
|
+
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
497
|
+
// The CSS variable stays the outer fallback: brands that already
|
|
498
|
+
// set it keep the value they shipped with.
|
|
499
|
+
gap: `var(--mega-menu-column-gap, ${columnGap}px)`
|
|
500
|
+
}}
|
|
501
|
+
>
|
|
502
|
+
{trigger === 'click' && showParentLink && (
|
|
503
|
+
// In click mode the top level entry is a disclosure button, so the
|
|
504
|
+
// parent page would otherwise be unreachable from the panel.
|
|
505
|
+
<NextLink
|
|
506
|
+
href={linkHref(item.url)}
|
|
507
|
+
data-mega-menu-parent-link=""
|
|
508
|
+
{...partAttrs('parent-link')}
|
|
509
|
+
onClick={guard}
|
|
510
|
+
style={{
|
|
511
|
+
gridColumn: '1 / -1',
|
|
512
|
+
color: 'inherit',
|
|
513
|
+
textDecoration: 'underline',
|
|
514
|
+
fontSize: '0.9em'
|
|
515
|
+
}}
|
|
516
|
+
>
|
|
517
|
+
{parentLinkLabel || item.label}
|
|
518
|
+
</NextLink>
|
|
519
|
+
)}
|
|
520
|
+
|
|
521
|
+
{customColumns ??
|
|
522
|
+
item.children.map((column) => {
|
|
523
|
+
const columnImage = showImages
|
|
524
|
+
? readPromoImage(column.attributes)
|
|
525
|
+
: '';
|
|
526
|
+
|
|
527
|
+
return (
|
|
528
|
+
<div
|
|
529
|
+
key={column.key}
|
|
530
|
+
data-mega-menu-column=""
|
|
531
|
+
{...partAttrs('column')}
|
|
532
|
+
>
|
|
533
|
+
<NextLink
|
|
534
|
+
href={linkHref(column.url)}
|
|
535
|
+
data-mega-menu-column-title=""
|
|
536
|
+
{...partAttrs('column-title', {
|
|
537
|
+
selected: isCurrent(column.url)
|
|
538
|
+
})}
|
|
539
|
+
onClick={guard}
|
|
540
|
+
style={{
|
|
541
|
+
display: 'block',
|
|
542
|
+
marginBottom: '10px',
|
|
543
|
+
color: 'inherit',
|
|
544
|
+
textDecoration: 'none',
|
|
545
|
+
fontWeight: 600
|
|
546
|
+
}}
|
|
547
|
+
>
|
|
548
|
+
{column.label}
|
|
549
|
+
</NextLink>
|
|
550
|
+
|
|
551
|
+
{columnImage && (
|
|
552
|
+
// Plain img: menu artwork comes from arbitrary commerce CDNs
|
|
553
|
+
// that the brand next.config may not allow through next/image.
|
|
554
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
555
|
+
<img
|
|
556
|
+
src={columnImage}
|
|
557
|
+
alt={column.label}
|
|
558
|
+
loading="lazy"
|
|
559
|
+
data-mega-menu-column-image=""
|
|
560
|
+
{...partAttrs('column-image')}
|
|
561
|
+
style={{
|
|
562
|
+
display: 'block',
|
|
563
|
+
width: '100%',
|
|
564
|
+
height: 'auto',
|
|
565
|
+
marginBottom: '10px'
|
|
566
|
+
}}
|
|
567
|
+
/>
|
|
568
|
+
)}
|
|
569
|
+
|
|
570
|
+
{column.children.length > 0 && (
|
|
571
|
+
<ul
|
|
572
|
+
data-mega-menu-submenu=""
|
|
573
|
+
{...partAttrs('submenu')}
|
|
574
|
+
style={{
|
|
575
|
+
listStyle: 'none',
|
|
576
|
+
margin: 0,
|
|
577
|
+
padding: 0,
|
|
578
|
+
display: 'flex',
|
|
579
|
+
flexDirection: 'column',
|
|
580
|
+
gap: '6px'
|
|
581
|
+
}}
|
|
582
|
+
>
|
|
583
|
+
{column.children.map((leaf) => (
|
|
584
|
+
<li key={leaf.key}>
|
|
585
|
+
<NextLink
|
|
586
|
+
href={linkHref(leaf.url)}
|
|
587
|
+
data-mega-menu-link=""
|
|
588
|
+
{...partAttrs('submenu-item', {
|
|
589
|
+
selected: isCurrent(leaf.url)
|
|
590
|
+
})}
|
|
591
|
+
onClick={guard}
|
|
592
|
+
style={{
|
|
593
|
+
color: 'inherit',
|
|
594
|
+
textDecoration: 'none',
|
|
595
|
+
opacity: 0.85,
|
|
596
|
+
fontSize: '0.92em'
|
|
597
|
+
}}
|
|
598
|
+
>
|
|
599
|
+
{leaf.label}
|
|
600
|
+
</NextLink>
|
|
601
|
+
</li>
|
|
602
|
+
))}
|
|
603
|
+
</ul>
|
|
604
|
+
)}
|
|
605
|
+
</div>
|
|
606
|
+
);
|
|
607
|
+
})}
|
|
608
|
+
</div>
|
|
609
|
+
)}
|
|
610
|
+
|
|
611
|
+
{promoPosition !== 'start' && promo}
|
|
612
|
+
</div>
|
|
613
|
+
);
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
return (
|
|
617
|
+
<nav
|
|
618
|
+
data-mega-menu=""
|
|
619
|
+
data-mega-menu-trigger={trigger}
|
|
620
|
+
{...partAttrs('menu')}
|
|
621
|
+
onMouseLeave={() => {
|
|
622
|
+
if (trigger === 'hover') scheduleClose();
|
|
623
|
+
}}
|
|
624
|
+
style={{
|
|
625
|
+
position: 'relative',
|
|
626
|
+
display: 'flex',
|
|
627
|
+
alignItems: 'center',
|
|
628
|
+
gap: 'var(--mega-menu-gap, 24px)'
|
|
629
|
+
}}
|
|
630
|
+
>
|
|
631
|
+
<ul
|
|
632
|
+
data-mega-menu-list=""
|
|
633
|
+
{...partAttrs('item-list')}
|
|
634
|
+
style={{
|
|
635
|
+
display: 'flex',
|
|
636
|
+
alignItems: 'center',
|
|
637
|
+
gap: 'inherit',
|
|
638
|
+
listStyle: 'none',
|
|
639
|
+
margin: 0,
|
|
640
|
+
padding: 0
|
|
641
|
+
}}
|
|
642
|
+
>
|
|
643
|
+
{items.map((item) => {
|
|
644
|
+
const open = openKey === item.key;
|
|
645
|
+
const highlighted = isHighlighted(item.label);
|
|
646
|
+
const current = isCurrent(item.url);
|
|
647
|
+
const entryStyle: React.CSSProperties = {
|
|
648
|
+
color: highlighted && highlightColor ? highlightColor : 'inherit',
|
|
649
|
+
fontWeight: highlighted ? 600 : 'inherit',
|
|
650
|
+
textDecoration: 'none',
|
|
651
|
+
background: 'none',
|
|
652
|
+
border: 'none',
|
|
653
|
+
padding: 0,
|
|
654
|
+
font: trigger === 'click' ? 'inherit' : undefined,
|
|
655
|
+
cursor: 'pointer'
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
return (
|
|
659
|
+
<li
|
|
660
|
+
key={item.key}
|
|
661
|
+
data-mega-menu-item={highlighted ? 'highlighted' : 'default'}
|
|
662
|
+
data-mega-menu-open={open ? 'true' : 'false'}
|
|
663
|
+
onMouseEnter={() => {
|
|
664
|
+
if (trigger === 'hover') scheduleOpen(item.key);
|
|
665
|
+
}}
|
|
666
|
+
onFocus={() => {
|
|
667
|
+
// Keyboard users never fire mouseenter, so focus opens the panel
|
|
668
|
+
// in both trigger modes.
|
|
669
|
+
clearTimers();
|
|
670
|
+
setOpenKey(item.key);
|
|
671
|
+
}}
|
|
672
|
+
onBlur={(event) => {
|
|
673
|
+
if (
|
|
674
|
+
!event.currentTarget.contains(event.relatedTarget as Node)
|
|
675
|
+
) {
|
|
676
|
+
closeNow();
|
|
677
|
+
}
|
|
678
|
+
}}
|
|
679
|
+
style={{ display: 'flex', alignItems: 'center' }}
|
|
680
|
+
>
|
|
681
|
+
{trigger === 'click' ? (
|
|
682
|
+
<button
|
|
683
|
+
type="button"
|
|
684
|
+
aria-expanded={open}
|
|
685
|
+
{...partAttrs('item', { open, selected: current })}
|
|
686
|
+
onClick={() => setOpenKey(open ? null : item.key)}
|
|
687
|
+
style={entryStyle}
|
|
688
|
+
>
|
|
689
|
+
{item.label}
|
|
690
|
+
</button>
|
|
691
|
+
) : (
|
|
692
|
+
<NextLink
|
|
693
|
+
href={linkHref(item.url)}
|
|
694
|
+
aria-expanded={open}
|
|
695
|
+
{...partAttrs('item', { open, selected: current })}
|
|
696
|
+
onClick={guard}
|
|
697
|
+
style={entryStyle}
|
|
698
|
+
>
|
|
699
|
+
{item.label}
|
|
700
|
+
</NextLink>
|
|
701
|
+
)}
|
|
702
|
+
|
|
703
|
+
{open && renderPanel(item)}
|
|
704
|
+
</li>
|
|
705
|
+
);
|
|
706
|
+
})}
|
|
707
|
+
</ul>
|
|
708
|
+
</nav>
|
|
709
|
+
);
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
export default MegaMenuBlock;
|