@akinon/pz-theme 2.0.55-beta.0 → 2.0.55

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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.55-beta.0
3
+ ## 2.0.55
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd75]
8
- - @akinon/next@2.0.55-beta.0
9
- - @akinon/theme-contract@2.0.55-beta.0
7
+ - 1225194: ZERO-4753: Render a separator glyph between nav menu and mega menu entries, plus an optional trailing fill band
8
+ - @akinon/next@2.0.55
9
+ - @akinon/theme-contract@2.0.55
10
10
 
11
11
  ## 2.0.54
12
12
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.55-beta.0",
4
+ "version": "2.0.55",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.55-beta.0",
11
+ "@akinon/next": "2.0.55",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.55-beta.0",
16
+ "@akinon/theme-contract": "2.0.55",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.55-beta.0",
23
+ "@akinon/next": "2.0.55",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,335 @@
1
+ import React from 'react';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+
4
+ jest.mock('next/navigation', () => ({
5
+ useRouter: () => ({ push: jest.fn() }),
6
+ usePathname: () => '/men',
7
+ useSearchParams: () => new URLSearchParams('')
8
+ }));
9
+
10
+ // Three entries, so "between" is measurable: two separators, never a third.
11
+ // "Women" carries children so the submenu keeps rendering beside the glyph.
12
+ const mockMenu = [
13
+ {
14
+ pk: 1,
15
+ label: 'Women',
16
+ url: '/women',
17
+ children: [{ pk: 11, label: 'Shoes', url: '/women/shoes', children: [] }]
18
+ },
19
+ { pk: 2, label: 'Men', url: '/men', children: [] },
20
+ { pk: 3, label: 'Sale', url: '/sale', children: [] }
21
+ ];
22
+
23
+ jest.mock('@akinon/next/data/client/misc', () => ({
24
+ useGetMenuQuery: () => ({
25
+ data: mockMenu,
26
+ isLoading: false,
27
+ isFetching: false,
28
+ isSuccess: true,
29
+ isError: false
30
+ })
31
+ }));
32
+
33
+ import MegaMenuBlock from '../mega-menu-block';
34
+ import NavMenuBlock from '../nav-menu-block';
35
+ import { generateThemeCSS } from '../../utils';
36
+
37
+ const blockProps = {
38
+ placeholderId: 'p',
39
+ sectionId: 's',
40
+ currentBreakpoint: 'desktop',
41
+ isDesigner: false
42
+ };
43
+
44
+ const mkBlock = (type: string, properties: Record<string, unknown> = {}) =>
45
+ ({
46
+ id: `${type}-1`,
47
+ type,
48
+ label: type,
49
+ order: 0,
50
+ hidden: false,
51
+ value: '',
52
+ properties,
53
+ styles: {},
54
+ blocks: []
55
+ } as never);
56
+
57
+ const renderNav = (properties: Record<string, unknown> = {}) =>
58
+ render(
59
+ <NavMenuBlock block={mkBlock('nav-menu', properties)} {...blockProps} />
60
+ );
61
+
62
+ const renderMega = (properties: Record<string, unknown> = {}) =>
63
+ render(
64
+ <MegaMenuBlock block={mkBlock('mega-menu', properties)} {...blockProps} />
65
+ );
66
+
67
+ const separators = () =>
68
+ Array.from(document.querySelectorAll<HTMLElement>('[data-part="separator"]'));
69
+
70
+ const trailingFill = () =>
71
+ document.querySelector('[data-part="trailing-fill"]') as HTMLElement | null;
72
+
73
+ describe('NavMenuBlock — separator', () => {
74
+ it('draws one glyph between entries and none after the last', () => {
75
+ const { container } = renderNav({
76
+ separatorType: 'character',
77
+ separatorCharacter: '/'
78
+ });
79
+
80
+ expect(separators()).toHaveLength(mockMenu.length - 1);
81
+
82
+ const nav = container.querySelector('[data-nav-menu]') as HTMLElement;
83
+ // The row reads entry, glyph, entry, glyph, entry — the first entry is not
84
+ // preceded by punctuation and the last is not followed by it.
85
+ expect(
86
+ Array.from(nav.children).map((child) => child.getAttribute('data-part'))
87
+ ).toEqual(['item', 'separator', 'item', 'separator', 'item']);
88
+ });
89
+
90
+ it('emits nothing at all when the type is none', () => {
91
+ renderNav({ separatorCharacter: '/', trailingFill: true });
92
+
93
+ expect(separators()).toHaveLength(0);
94
+ expect(trailingFill()).toBeNull();
95
+ });
96
+
97
+ it('emits nothing when the chosen source has no glyph', () => {
98
+ // Type picked, icon never chosen: an empty span between every entry would
99
+ // still take the gap on both sides. The icon control's descriptor default
100
+ // is '' too, so nothing is what the panel advertises here.
101
+ renderNav({ separatorType: 'icon' });
102
+
103
+ expect(separators()).toHaveLength(0);
104
+ });
105
+
106
+ it('hides the glyph from assistive technology', () => {
107
+ renderNav({ separatorType: 'character', separatorCharacter: '·' });
108
+
109
+ separators().forEach((glyph) =>
110
+ expect(glyph).toHaveAttribute('aria-hidden', 'true')
111
+ );
112
+ });
113
+
114
+ it('keeps the submenu and the item states with a separator present', () => {
115
+ const { container } = renderNav({
116
+ showSubmenu: true,
117
+ submenuTrigger: 'hover',
118
+ separatorType: 'character',
119
+ separatorCharacter: '/',
120
+ highlightLabels: 'sale'
121
+ });
122
+
123
+ // The submenu is what the item-template workaround used to cost.
124
+ fireEvent.mouseEnter(
125
+ container.querySelector('[data-nav-item-wrapper]') as Element
126
+ );
127
+ expect(
128
+ document.querySelector('[data-nav-submenu][data-state="open"]')
129
+ ).toBeInTheDocument();
130
+ expect(screen.getByText('Shoes')).toBeInTheDocument();
131
+
132
+ // …and so were the open/selected states and the label highlights.
133
+ const items = Array.from(document.querySelectorAll('[data-part="item"]'));
134
+ expect(items[0]).toHaveAttribute('data-state', 'open');
135
+ expect(items[1]).toHaveAttribute('data-state', 'selected');
136
+ expect(items[2]).toHaveAttribute('data-nav-item', 'highlighted');
137
+ expect(separators()).toHaveLength(2);
138
+ });
139
+
140
+ it('tiles the glyph across the trailing band after the last entry', () => {
141
+ const { container } = renderNav({
142
+ separatorType: 'character',
143
+ separatorCharacter: '/',
144
+ separatorSize: 20,
145
+ separatorGap: 4,
146
+ trailingFill: true
147
+ });
148
+
149
+ const fill = trailingFill() as HTMLElement;
150
+ const nav = container.querySelector('[data-nav-menu]') as HTMLElement;
151
+
152
+ expect(nav.lastElementChild).toBe(fill);
153
+ expect(fill).toHaveAttribute('aria-hidden', 'true');
154
+ expect(fill.style.backgroundRepeat).toBe('repeat-x');
155
+ // One glyph plus the gap, so a repeated band breathes.
156
+ expect(fill.style.backgroundSize).toBe('24px 20px');
157
+ expect(fill.getAttribute('style')).toContain('flex: 1 1 auto');
158
+ expect(fill.style.backgroundImage).toContain('data:image/svg+xml');
159
+ });
160
+
161
+ it('tiles an uploaded image as the band', () => {
162
+ renderNav({
163
+ separatorType: 'image',
164
+ separatorImage: { url: 'https://cdn.example.com/dot.png' },
165
+ trailingFill: true
166
+ });
167
+
168
+ expect(separators()[0].style.backgroundImage).toContain(
169
+ 'https://cdn.example.com/dot.png'
170
+ );
171
+ expect((trailingFill() as HTMLElement).style.backgroundImage).toContain(
172
+ 'https://cdn.example.com/dot.png'
173
+ );
174
+ });
175
+
176
+ it('wraps inline icon markup as a data URI instead of dropping it', () => {
177
+ renderNav({
178
+ separatorType: 'icon',
179
+ separatorIcon: '<svg xmlns="http://www.w3.org/2000/svg"></svg>'
180
+ });
181
+
182
+ expect(separators()[0].style.backgroundImage).toContain(
183
+ 'data:image/svg+xml'
184
+ );
185
+ });
186
+
187
+ it('refuses a source that would break out of the url() value', () => {
188
+ renderNav({
189
+ separatorType: 'image',
190
+ separatorImage: 'https://cdn.example.com/a.png") ; background: red; ("',
191
+ trailingFill: true
192
+ });
193
+
194
+ expect(separators()).toHaveLength(0);
195
+ expect(trailingFill()).toBeNull();
196
+ });
197
+ });
198
+
199
+ describe('MegaMenuBlock — separator', () => {
200
+ it('draws one list item between entries and none after the last', () => {
201
+ renderMega({ separatorType: 'character', separatorCharacter: '|' });
202
+
203
+ const list = document.querySelector('[data-mega-menu-list]') as HTMLElement;
204
+
205
+ expect(separators()).toHaveLength(mockMenu.length - 1);
206
+ expect(Array.from(list.children).map((child) => child.tagName)).toEqual([
207
+ 'LI',
208
+ 'LI',
209
+ 'LI',
210
+ 'LI',
211
+ 'LI'
212
+ ]);
213
+ expect(list.lastElementChild).not.toHaveAttribute('data-part', 'separator');
214
+ });
215
+
216
+ it('keeps the panel opening with a separator present', () => {
217
+ renderMega({
218
+ trigger: 'click',
219
+ separatorType: 'character',
220
+ separatorCharacter: '|'
221
+ });
222
+
223
+ fireEvent.click(screen.getByRole('button', { name: 'Women' }));
224
+
225
+ expect(
226
+ document.querySelector('[data-mega-menu-panel]')
227
+ ).toBeInTheDocument();
228
+ expect(screen.getByText('Shoes')).toBeInTheDocument();
229
+ expect(separators()).toHaveLength(2);
230
+ });
231
+
232
+ it('closes the row with a trailing band', () => {
233
+ renderMega({
234
+ separatorType: 'character',
235
+ separatorCharacter: '|',
236
+ trailingFill: true
237
+ });
238
+
239
+ const list = document.querySelector('[data-mega-menu-list]') as HTMLElement;
240
+ const fill = trailingFill() as HTMLElement;
241
+
242
+ expect(list.lastElementChild).toBe(fill);
243
+ expect(fill.tagName).toBe('LI');
244
+ expect(fill.style.backgroundRepeat).toBe('repeat-x');
245
+ });
246
+
247
+ it('emits nothing at all when the type is none', () => {
248
+ renderMega({ trailingFill: true });
249
+
250
+ expect(separators()).toHaveLength(0);
251
+ expect(trailingFill()).toBeNull();
252
+ });
253
+ });
254
+
255
+ /**
256
+ * A descriptor default is advertised in the panel but never seeded into the
257
+ * stored composition, so a control the designer never opened arrives at the
258
+ * renderer absent. These pin the renderer's fallbacks to the values the panel
259
+ * shows, from the editor's `config/blocks/menu-separator-fields.ts` — if the
260
+ * two drift, the panel promises one thing and the canvas draws another.
261
+ */
262
+ describe('separator — absent properties render what the panel advertises', () => {
263
+ // Straight from the editor descriptor's defaultValue fields.
264
+ const ADVERTISED = { character: '|', size: 12, gap: 8 };
265
+
266
+ it('draws the advertised character when the field was never opened', () => {
267
+ // The whole bug: picking "Character" and moving on used to render an empty
268
+ // span, so the menu looked untouched while the panel showed a pipe.
269
+ renderNav({ separatorType: 'character' });
270
+
271
+ const glyphs = separators();
272
+ expect(glyphs).toHaveLength(mockMenu.length - 1);
273
+ glyphs.forEach((glyph) =>
274
+ expect(glyph).toHaveTextContent(ADVERTISED.character)
275
+ );
276
+ });
277
+
278
+ it('sizes and spaces the glyph as the panel advertises', () => {
279
+ renderNav({ separatorType: 'character' });
280
+ const glyph = separators()[0];
281
+
282
+ expect(glyph.style.fontSize).toBe(`${ADVERTISED.size}px`);
283
+ expect(glyph.style.marginLeft).toBe(`${ADVERTISED.gap}px`);
284
+ expect(glyph.style.marginRight).toBe(`${ADVERTISED.gap}px`);
285
+ });
286
+
287
+ it('sizes an image glyph as the panel advertises', () => {
288
+ renderNav({
289
+ separatorType: 'image',
290
+ separatorImage: { url: 'https://cdn.example.com/dot.png' }
291
+ });
292
+
293
+ const glyph = separators()[0];
294
+ expect(glyph.style.width).toBe(`${ADVERTISED.size}px`);
295
+ expect(glyph.style.height).toBe(`${ADVERTISED.size}px`);
296
+ });
297
+
298
+ it('tiles the trailing band on the advertised size and gap', () => {
299
+ renderNav({ separatorType: 'character', trailingFill: true });
300
+
301
+ // One glyph plus the gap, both defaulted: 12 + 8.
302
+ expect((trailingFill() as HTMLElement).style.backgroundSize).toBe(
303
+ `${ADVERTISED.size + ADVERTISED.gap}px ${ADVERTISED.size}px`
304
+ );
305
+ });
306
+ });
307
+
308
+ /**
309
+ * The band is clipped by whatever holds the menu, so the group around it has to
310
+ * be able to say `overflow: hidden`. The CSS generator has no property
311
+ * whitelist, so this needs no renderer plumbing — but a whitelist added later
312
+ * would silently remove the only way to end the band on a partial glyph.
313
+ */
314
+ describe('group box chrome — overflow', () => {
315
+ it('passes overflow through to the group wrapper rule', () => {
316
+ const css = generateThemeCSS(
317
+ [
318
+ {
319
+ id: 'sec-1',
320
+ blocks: [
321
+ {
322
+ id: 'grp-1',
323
+ type: 'group',
324
+ styles: { overflow: { desktop: 'hidden' } }
325
+ }
326
+ ]
327
+ }
328
+ ] as never,
329
+ 'desktop'
330
+ );
331
+
332
+ expect(css).toContain('[data-block-id="grp-1"]');
333
+ expect(css).toContain('overflow: hidden');
334
+ });
335
+ });
@@ -14,6 +14,11 @@ import { getResponsiveValue, partAttrs, resolveForceOpen } from '../utils';
14
14
  import { useOverlay } from '../hooks/use-overlay';
15
15
  import { BlockRendererProps } from './block-renderer-registry';
16
16
  import { hasItemTemplate, ItemTemplateList } from './item-template';
17
+ import {
18
+ MenuSeparator,
19
+ MenuTrailingFill,
20
+ readMenuSeparator
21
+ } from './menu-separator';
17
22
  import { DesignerPlaceholder, readToggle } from './product-block-utils';
18
23
 
19
24
  /** Matches the dwell the reference brand header uses before a panel opens. */
@@ -188,9 +193,9 @@ const readPromoImage = (attributes: Record<string, unknown>): string => {
188
193
  * Properties: depth, parent, trigger, columns, showImages, highlightLabels,
189
194
  * highlightColor, columnGap, openDelay, closeDelay, showParentLink,
190
195
  * parentLinkLabel, promoPosition, promoMaxWidth, activeMatch, maxItems,
191
- * emptyText, loadingText.
196
+ * emptyText, loadingText, separator*, trailingFill.
192
197
  * Parts: menu, item, panel, parent-link, column, column-title, column-image,
193
- * submenu, submenu-item, promo.
198
+ * submenu, submenu-item, promo, separator, trailing-fill.
194
199
  */
195
200
  const MegaMenuBlock = ({
196
201
  block,
@@ -315,6 +320,8 @@ const MegaMenuBlock = ({
315
320
  )
316
321
  );
317
322
 
323
+ const separator = readMenuSeparator(block.properties, currentBreakpoint);
324
+
318
325
  const { data: menu, isLoading } = useGetMenuQuery({
319
326
  depth,
320
327
  ...(parent ? { parent } : {})
@@ -656,7 +663,7 @@ const MegaMenuBlock = ({
656
663
  padding: 0
657
664
  }}
658
665
  >
659
- {items.map((item) => {
666
+ {items.map((item, index) => {
660
667
  const open = effectiveOpenKey === item.key;
661
668
  const highlighted = isHighlighted(item.label);
662
669
  const current = isCurrent(item.url);
@@ -671,9 +678,8 @@ const MegaMenuBlock = ({
671
678
  cursor: 'pointer'
672
679
  };
673
680
 
674
- return (
681
+ const entry = (
675
682
  <li
676
- key={item.key}
677
683
  data-mega-menu-item={highlighted ? 'highlighted' : 'default'}
678
684
  data-mega-menu-open={open ? 'true' : 'false'}
679
685
  onMouseEnter={() => {
@@ -724,7 +730,19 @@ const MegaMenuBlock = ({
724
730
  {open && renderPanel(item)}
725
731
  </li>
726
732
  );
733
+
734
+ // Punctuation goes BETWEEN entries, so the first one is not preceded
735
+ // by one. It is a list item like the entries it separates: anything
736
+ // else here would be markup a `ul` may not contain.
737
+ return (
738
+ <React.Fragment key={item.key}>
739
+ {index > 0 && <MenuSeparator settings={separator} as="li" />}
740
+ {entry}
741
+ </React.Fragment>
742
+ );
727
743
  })}
744
+
745
+ <MenuTrailingFill settings={separator} as="li" />
728
746
  </ul>
729
747
  </nav>
730
748
  );
@@ -0,0 +1,284 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
5
+ import { readToggle } from './product-block-utils';
6
+
7
+ /**
8
+ * Menu punctuation: the glyph a brand draws BETWEEN top level menu entries, and
9
+ * the repeating band it draws after the last one.
10
+ *
11
+ * This is renderer-owned decoration rather than a block, because every way a
12
+ * designer had of producing it cost them the menu. Injecting a glyph through
13
+ * the item template REPLACES the built-in row, so the submenu, the `item` part
14
+ * with its hover/focus/selected states and the label highlights all disappear
15
+ * in the same swap; and a pseudo-element is unreachable from a composition
16
+ * (`content` is not in the generated property set and a part key has no
17
+ * pseudo-element slot). Rendering it here keeps the glyph alongside those
18
+ * features rather than in place of them.
19
+ *
20
+ * It lives next to both menu blocks rather than inside either: the two share
21
+ * one contract, so moving a header from `nav-menu` to `mega-menu` must not
22
+ * lose the decoration.
23
+ */
24
+
25
+ export type MenuSeparatorType = 'none' | 'character' | 'icon' | 'image';
26
+
27
+ const SEPARATOR_TYPES: MenuSeparatorType[] = [
28
+ 'none',
29
+ 'character',
30
+ 'icon',
31
+ 'image'
32
+ ];
33
+
34
+ /**
35
+ * These mirror the descriptor defaults in the editor's
36
+ * `config/blocks/menu-separator-fields.ts`, and have to keep mirroring them.
37
+ *
38
+ * A descriptor default is only ever shown in the panel — it is not seeded into
39
+ * the stored composition — so a property the designer never touched arrives
40
+ * here absent. If the two sides disagree, the panel advertises one value and
41
+ * the canvas draws another, with nothing in either place saying why.
42
+ */
43
+ const DEFAULT_SIZE = 12;
44
+ const DEFAULT_GAP = 8;
45
+ const DEFAULT_CHARACTER = '|';
46
+
47
+ export interface MenuSeparatorSettings {
48
+ type: MenuSeparatorType;
49
+ character: string;
50
+ /** Resolved source for the `icon` and `image` types; '' when unusable. */
51
+ imageUrl: string;
52
+ size: number;
53
+ color: string;
54
+ gap: number;
55
+ trailingFill: boolean;
56
+ }
57
+
58
+ const readSize = (
59
+ value: unknown,
60
+ currentBreakpoint: string,
61
+ fallback: number
62
+ ): number => {
63
+ const numeric = Number(
64
+ getResponsiveValue(value, currentBreakpoint, fallback)
65
+ );
66
+ return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback;
67
+ };
68
+
69
+ /**
70
+ * The icon and file controls hand back a bare string, a `{ url }` record or a
71
+ * per breakpoint entry of either, so all three shapes are probed. Inline SVG
72
+ * markup (what the icon picker stores for a built-in glyph) is wrapped as a
73
+ * data URI here, so a single code path downstream can treat every source as an
74
+ * image address.
75
+ */
76
+ const readMediaSource = (value: unknown, currentBreakpoint: string): string => {
77
+ if (value === undefined || value === null) return '';
78
+
79
+ let raw: unknown = value;
80
+
81
+ if (typeof raw === 'object') {
82
+ const record = raw as Record<string, unknown>;
83
+ raw =
84
+ typeof record.url === 'string'
85
+ ? record.url
86
+ : getResponsiveValue(record, currentBreakpoint, '');
87
+
88
+ if (typeof raw === 'object' && raw !== null) {
89
+ const nested = raw as Record<string, unknown>;
90
+ raw = typeof nested.url === 'string' ? nested.url : '';
91
+ }
92
+ }
93
+
94
+ const source = String(raw || '').trim();
95
+ if (!source) return '';
96
+
97
+ if (source.includes('<svg')) {
98
+ return `data:image/svg+xml,${encodeURIComponent(source)}`;
99
+ }
100
+
101
+ // The address is interpolated into a `url("…")` value, so a quote, a
102
+ // backslash, a closing paren or a newline would end the value early; and a
103
+ // scheme that executes has no business being a menu glyph.
104
+ if (/^\s*javascript:/i.test(source)) return '';
105
+ if (/["\\)\r\n]/.test(source)) return '';
106
+
107
+ return source;
108
+ };
109
+
110
+ export const readMenuSeparator = (
111
+ properties: Record<string, unknown> | undefined,
112
+ currentBreakpoint: string
113
+ ): MenuSeparatorSettings => {
114
+ const type = resolveOption<MenuSeparatorType>(
115
+ properties?.separatorType,
116
+ currentBreakpoint,
117
+ 'none',
118
+ SEPARATOR_TYPES
119
+ );
120
+
121
+ const imageUrl =
122
+ type === 'icon'
123
+ ? readMediaSource(properties?.separatorIcon, currentBreakpoint)
124
+ : type === 'image'
125
+ ? readMediaSource(properties?.separatorImage, currentBreakpoint)
126
+ : '';
127
+
128
+ return {
129
+ type,
130
+ character: String(
131
+ getResponsiveValue(
132
+ properties?.separatorCharacter,
133
+ currentBreakpoint,
134
+ DEFAULT_CHARACTER
135
+ )
136
+ ),
137
+ imageUrl,
138
+ size: readSize(properties?.separatorSize, currentBreakpoint, DEFAULT_SIZE),
139
+ color: String(
140
+ getResponsiveValue(properties?.separatorColor, currentBreakpoint, '')
141
+ ),
142
+ gap: readSize(properties?.separatorGap, currentBreakpoint, DEFAULT_GAP),
143
+ trailingFill: readToggle(properties?.trailingFill, currentBreakpoint, false)
144
+ };
145
+ };
146
+
147
+ /** Whether the settings can actually draw a glyph. */
148
+ export const hasMenuSeparator = (settings: MenuSeparatorSettings): boolean => {
149
+ if (settings.type === 'none') return false;
150
+ if (settings.type === 'character') return settings.character !== '';
151
+ return settings.imageUrl !== '';
152
+ };
153
+
154
+ const escapeXml = (value: string): string =>
155
+ value
156
+ .replace(/&/g, '&amp;')
157
+ .replace(/</g, '&lt;')
158
+ .replace(/>/g, '&gt;')
159
+ .replace(/"/g, '&quot;')
160
+ .replace(/'/g, '&apos;');
161
+
162
+ /** Tile width: one glyph plus the gap, so a repeated band breathes. */
163
+ const tileWidth = (settings: MenuSeparatorSettings): number =>
164
+ settings.size + settings.gap;
165
+
166
+ /**
167
+ * The glyph as a CSS `url(…)` value, which is what lets the same source paint a
168
+ * repeating band. A character has no address, so it becomes a one-glyph SVG
169
+ * tile. `currentColor` inside an SVG used as an image resolves against the
170
+ * image's own initial colour (black) rather than the menu's, which is why the
171
+ * descriptor ships a colour control.
172
+ */
173
+ const buildTileImage = (settings: MenuSeparatorSettings): string => {
174
+ if (settings.type === 'character') {
175
+ if (!settings.character) return '';
176
+
177
+ const width = tileWidth(settings);
178
+ const svg =
179
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${settings.size}" ` +
180
+ `viewBox="0 0 ${width} ${settings.size}">` +
181
+ `<text x="50%" y="50%" text-anchor="middle" dominant-baseline="central" ` +
182
+ `font-size="${settings.size}" fill="${escapeXml(
183
+ settings.color || 'currentColor'
184
+ )}">${escapeXml(settings.character)}</text></svg>`;
185
+
186
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
187
+ }
188
+
189
+ return settings.imageUrl ? `url("${settings.imageUrl}")` : '';
190
+ };
191
+
192
+ type MenuDecorationTag = 'span' | 'li';
193
+
194
+ interface MenuDecorationProps {
195
+ settings: MenuSeparatorSettings;
196
+ /** `li` inside the mega menu's list, `span` inside the nav menu's flex row. */
197
+ as?: MenuDecorationTag;
198
+ }
199
+
200
+ /**
201
+ * Punctuation between two entries. `aria-hidden` because it is exactly that:
202
+ * a screen reader announcing "slash" between every link would turn a six item
203
+ * menu into twelve announcements.
204
+ */
205
+ export const MenuSeparator = ({
206
+ settings,
207
+ as: Tag = 'span'
208
+ }: MenuDecorationProps): React.ReactElement | null => {
209
+ if (!hasMenuSeparator(settings)) return null;
210
+
211
+ const spacing = { marginLeft: settings.gap, marginRight: settings.gap };
212
+ const style: React.CSSProperties =
213
+ settings.type === 'character'
214
+ ? {
215
+ ...spacing,
216
+ flex: '0 0 auto',
217
+ display: 'inline-block',
218
+ alignSelf: 'center',
219
+ fontSize: `${settings.size}px`,
220
+ lineHeight: 1,
221
+ color: settings.color || 'inherit',
222
+ userSelect: 'none'
223
+ }
224
+ : {
225
+ ...spacing,
226
+ flex: '0 0 auto',
227
+ display: 'inline-block',
228
+ alignSelf: 'center',
229
+ width: `${settings.size}px`,
230
+ height: `${settings.size}px`,
231
+ backgroundImage: buildTileImage(settings),
232
+ backgroundRepeat: 'no-repeat',
233
+ backgroundPosition: 'center',
234
+ backgroundSize: 'contain'
235
+ };
236
+
237
+ return (
238
+ <Tag
239
+ aria-hidden="true"
240
+ data-menu-separator=""
241
+ {...partAttrs('separator')}
242
+ style={style}
243
+ >
244
+ {settings.type === 'character' ? settings.character : null}
245
+ </Tag>
246
+ );
247
+ };
248
+
249
+ /**
250
+ * The band after the last entry: the same glyph tiled across whatever width is
251
+ * left in the row. It takes the remaining space with `flex: 1 1 auto`, so a
252
+ * menu that does not fill its container is finished off rather than leaving a
253
+ * ragged edge — the effect a brand would otherwise fake with a stretched image.
254
+ * Clipping the last partial glyph is the container's job (`overflow: hidden` on
255
+ * the group that holds the menu).
256
+ */
257
+ export const MenuTrailingFill = ({
258
+ settings,
259
+ as: Tag = 'span'
260
+ }: MenuDecorationProps): React.ReactElement | null => {
261
+ if (!settings.trailingFill) return null;
262
+
263
+ const tile = buildTileImage(settings);
264
+ if (!tile) return null;
265
+
266
+ return (
267
+ <Tag
268
+ aria-hidden="true"
269
+ data-menu-trailing-fill=""
270
+ {...partAttrs('trailing-fill')}
271
+ style={{
272
+ flex: '1 1 auto',
273
+ minWidth: 0,
274
+ alignSelf: 'center',
275
+ marginLeft: settings.gap,
276
+ height: `${settings.size}px`,
277
+ backgroundImage: tile,
278
+ backgroundRepeat: 'repeat-x',
279
+ backgroundPosition: 'left center',
280
+ backgroundSize: `${tileWidth(settings)}px ${settings.size}px`
281
+ }}
282
+ />
283
+ );
284
+ };
@@ -7,6 +7,11 @@ import { useGetMenuQuery } from '@akinon/next/data/client/misc';
7
7
  import { getResponsiveValue, partAttrs, resolveForceOpen } from '../utils';
8
8
  import { BlockRendererProps } from './block-renderer-registry';
9
9
  import { hasItemTemplate, ItemTemplateList } from './item-template';
10
+ import {
11
+ MenuSeparator,
12
+ MenuTrailingFill,
13
+ readMenuSeparator
14
+ } from './menu-separator';
10
15
  import { readToggle } from './product-block-utils';
11
16
 
12
17
  interface MenuItemLike {
@@ -32,8 +37,10 @@ const normalizePath = (value: unknown): string => {
32
37
  * placement, wording of the editor-only states, the optional submenu and
33
38
  * styling.
34
39
  * Properties: depth, parent, orientation, highlight*, showSubmenu,
35
- * submenuTrigger, activeMatch, maxItems, emptyText, loadingText.
36
- * Parts: menu, item (hover/focus/selected), submenu, submenu-item.
40
+ * submenuTrigger, activeMatch, maxItems, emptyText, loadingText, separator*,
41
+ * trailingFill.
42
+ * Parts: menu, item (hover/focus/selected), submenu, submenu-item, separator,
43
+ * trailing-fill.
37
44
  */
38
45
  const NavMenuBlock = ({
39
46
  block,
@@ -112,6 +119,8 @@ const NavMenuBlock = ({
112
119
  getResponsiveValue(block.properties?.loadingText, currentBreakpoint, '…')
113
120
  );
114
121
 
122
+ const separator = readMenuSeparator(block.properties, currentBreakpoint);
123
+
115
124
  const isHighlighted = (label?: string) =>
116
125
  Boolean(
117
126
  label &&
@@ -296,29 +305,47 @@ const NavMenuBlock = ({
296
305
  // Without a submenu the entry stays a bare link, exactly as it has
297
306
  // always rendered — the positioning wrapper only appears when a panel
298
307
  // actually needs somewhere to anchor.
299
- if (!showSubmenu || !hasChildren) return link;
308
+ const entry =
309
+ !showSubmenu || !hasChildren ? (
310
+ link
311
+ ) : (
312
+ <span
313
+ data-nav-item-wrapper=""
314
+ onMouseEnter={() => {
315
+ if (submenuTrigger === 'hover') setOpenKey(key);
316
+ }}
317
+ onMouseLeave={() => {
318
+ if (submenuTrigger === 'hover' && !forcedOpen)
319
+ setOpenKey(null);
320
+ }}
321
+ style={{
322
+ position: 'relative',
323
+ display: 'inline-flex',
324
+ alignItems: 'center'
325
+ }}
326
+ >
327
+ {link}
328
+ {submenuPanel(item, key)}
329
+ </span>
330
+ );
300
331
 
332
+ // Punctuation goes BETWEEN entries, so the first one is not preceded
333
+ // by one. The fragment has no box of its own, so a composition that
334
+ // asks for no separator renders the row it always rendered.
301
335
  return (
302
- <span
303
- key={key}
304
- data-nav-item-wrapper=""
305
- onMouseEnter={() => {
306
- if (submenuTrigger === 'hover') setOpenKey(key);
307
- }}
308
- onMouseLeave={() => {
309
- if (submenuTrigger === 'hover' && !forcedOpen) setOpenKey(null);
310
- }}
311
- style={{
312
- position: 'relative',
313
- display: 'inline-flex',
314
- alignItems: 'center'
315
- }}
316
- >
317
- {link}
318
- {submenuPanel(item, key)}
319
- </span>
336
+ <React.Fragment key={key}>
337
+ {index > 0 && <MenuSeparator settings={separator} />}
338
+ {entry}
339
+ </React.Fragment>
320
340
  );
321
341
  })}
342
+
343
+ {/*
344
+ Outside the arm split on purpose: the band decorates the ROW, not the
345
+ entries, so a designer who replaced the rows with an item template
346
+ still gets to finish the row off.
347
+ */}
348
+ <MenuTrailingFill settings={separator} />
322
349
  </nav>
323
350
  );
324
351
  };