@akinon/pz-theme 2.0.45-rc.0 → 2.0.45

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,36 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.45-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea23]
8
- - Updated dependencies [324f97d5]
9
- - Updated dependencies [51ea0688]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08f]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f4]
18
- - Updated dependencies [57d7eb30]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a71]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d0]
25
- - Updated dependencies [4ac7b2a1]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a963]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b2]
32
- - @akinon/next@2.0.45-rc.0
33
- - @akinon/theme-contract@2.0.45-rc.0
3
+ ## 2.0.45
4
+
5
+ ### Patch Changes
6
+
7
+ - cd66ce0: ZERO-4736: Hold chrome overlays open in the designer canvas so their open states can be styled
8
+ - @akinon/next@2.0.45
9
+ - @akinon/theme-contract@2.0.45
34
10
 
35
11
  ## 2.0.44
36
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.45-rc.0",
4
+ "version": "2.0.45",
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.45-rc.0",
11
+ "@akinon/next": "2.0.45",
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.45-rc.0",
16
+ "@akinon/theme-contract": "2.0.45",
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.45-rc.0",
23
+ "@akinon/next": "2.0.45",
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,378 @@
1
+ import React from 'react';
2
+ import { act, fireEvent, render, screen } from '@testing-library/react';
3
+
4
+ jest.mock('next/navigation', () => ({
5
+ useRouter: () => ({ push: jest.fn() }),
6
+ usePathname: () => '/',
7
+ useSearchParams: () => new URLSearchParams('')
8
+ }));
9
+
10
+ // Nested fixture: buildTree consumes it as-is, the flat consumers read
11
+ // `children` directly. "Sale" first so the forced pin has to SKIP an entry
12
+ // that yields no panel and land on the first one that does.
13
+ const mockMenu = [
14
+ { pk: 3, label: 'Sale', url: '/sale', children: [] },
15
+ {
16
+ pk: 1,
17
+ label: 'Women',
18
+ url: '/women',
19
+ children: [{ pk: 2, label: 'Shoes', url: '/women/shoes', children: [] }]
20
+ }
21
+ ];
22
+
23
+ // The hamburger drawer gates its fetch on being open, so the mock honours
24
+ // `skip` — a forced drawer that forgot to unskip would render zero rows here.
25
+ jest.mock('@akinon/next/data/client/misc', () => ({
26
+ useGetMenuQuery: (_args: unknown, options?: { skip?: boolean }) =>
27
+ options?.skip
28
+ ? {
29
+ data: undefined,
30
+ isLoading: false,
31
+ isFetching: false,
32
+ isSuccess: false,
33
+ isError: false
34
+ }
35
+ : {
36
+ data: mockMenu,
37
+ isLoading: false,
38
+ isFetching: false,
39
+ isSuccess: true,
40
+ isError: false
41
+ }
42
+ }));
43
+
44
+ // Stub the recursive ThemeBlock (default export) with a countable marker.
45
+ jest.mock('../../theme-block', () => ({
46
+ __esModule: true,
47
+ default: ({ block }: { block: { id: string } }) => (
48
+ <div data-testid="tb" data-id={block.id} />
49
+ )
50
+ }));
51
+
52
+ import SearchBoxBlock from '../search-box-block';
53
+ import HamburgerMenuBlock from '../hamburger-menu-block';
54
+ import MegaMenuBlock from '../mega-menu-block';
55
+ import NavMenuBlock from '../nav-menu-block';
56
+ import HotspotBlock from '../hotspot-block';
57
+ import { PartStatePreview } from '../../components/part-state-preview';
58
+
59
+ const mkBlock = (
60
+ type: string,
61
+ properties: Record<string, unknown> = {},
62
+ styles: Record<string, unknown> = {}
63
+ ) =>
64
+ ({
65
+ id: `${type}-1`,
66
+ type,
67
+ label: type,
68
+ order: 0,
69
+ hidden: false,
70
+ value: '',
71
+ properties,
72
+ styles,
73
+ blocks: []
74
+ } as never);
75
+
76
+ const blockProps = {
77
+ placeholderId: 'p',
78
+ sectionId: 's',
79
+ currentBreakpoint: 'desktop'
80
+ };
81
+
82
+ const pin = (blockId: string, partName: string, state: string | null) =>
83
+ act(() => {
84
+ window.dispatchEvent(
85
+ new MessageEvent('message', {
86
+ data: {
87
+ type: 'PREVIEW_PART_STATE',
88
+ data: { blockId, partName, state }
89
+ }
90
+ })
91
+ );
92
+ });
93
+
94
+ describe('SearchBoxBlock under force-open', () => {
95
+ const renderBox = (isDesigner: boolean, styles: Record<string, unknown>) =>
96
+ render(
97
+ <SearchBoxBlock
98
+ block={mkBlock('search-box', {}, styles)}
99
+ isDesigner={isDesigner}
100
+ {...blockProps}
101
+ />
102
+ );
103
+
104
+ it('mounts the overlay panel with no interaction in the designer', () => {
105
+ renderBox(true, { forceOpen: true });
106
+
107
+ const panel = document.querySelector('[data-search-overlay]');
108
+ expect(panel).toBeInTheDocument();
109
+ expect(panel).toHaveAttribute('data-part', 'panel');
110
+ expect(panel).toHaveAttribute('data-state', 'open');
111
+ // The trigger reports the effective state, exactly as a real open would.
112
+ expect(document.querySelector('[data-part="trigger"]')).toHaveAttribute(
113
+ 'data-state',
114
+ 'open'
115
+ );
116
+ });
117
+
118
+ it('leaves the panel mounted when the close button is clicked', () => {
119
+ renderBox(true, { forceOpen: true });
120
+
121
+ fireEvent.click(document.querySelector('[data-search-close]') as Element);
122
+
123
+ expect(document.querySelector('[data-search-overlay]')).toBeInTheDocument();
124
+ expect(document.querySelector('[data-part="trigger"]')).toHaveAttribute(
125
+ 'data-state',
126
+ 'open'
127
+ );
128
+ });
129
+
130
+ it('leaves the panel mounted when the trigger is clicked again', () => {
131
+ renderBox(true, { forceOpen: true });
132
+
133
+ fireEvent.click(document.querySelector('[data-part="trigger"]') as Element);
134
+
135
+ expect(document.querySelector('[data-search-overlay]')).toBeInTheDocument();
136
+ });
137
+
138
+ it('lets a pin match the force-mounted panel with no click', () => {
139
+ render(
140
+ <div data-block-id="search-box-1">
141
+ <PartStatePreview isDesigner />
142
+ <SearchBoxBlock
143
+ block={mkBlock('search-box', {}, { forceOpen: true })}
144
+ isDesigner
145
+ {...blockProps}
146
+ />
147
+ </div>
148
+ );
149
+
150
+ pin('search-box-1', 'panel', 'open');
151
+
152
+ const panel = document.querySelector('[data-search-overlay]');
153
+ expect(panel).toHaveAttribute('data-part-state-preview', 'open');
154
+ expect(panel?.getAttribute('data-state')).toContain('open');
155
+ });
156
+
157
+ it('ignores forceOpen on the storefront, byte for byte', () => {
158
+ const forced = renderBox(false, { forceOpen: true });
159
+ const forcedHtml = forced.container.innerHTML;
160
+ forced.unmount();
161
+
162
+ const plain = renderBox(false, {});
163
+
164
+ expect(document.querySelector('[data-search-overlay]')).toBeNull();
165
+ expect(forcedHtml).toBe(plain.container.innerHTML);
166
+ });
167
+ });
168
+
169
+ describe('HamburgerMenuBlock under force-open', () => {
170
+ const renderMenu = (isDesigner: boolean, styles: Record<string, unknown>) =>
171
+ render(
172
+ <HamburgerMenuBlock
173
+ block={mkBlock('hamburger-menu', {}, styles)}
174
+ isDesigner={isDesigner}
175
+ {...blockProps}
176
+ />
177
+ );
178
+
179
+ it('mounts the drawer with its item rows and no interaction', () => {
180
+ renderMenu(true, { forceOpen: true });
181
+
182
+ const drawer = document.querySelector('[data-hamburger-panel]');
183
+ expect(drawer).toBeInTheDocument();
184
+ expect(drawer).toHaveAttribute('data-state', 'open');
185
+ // The menu fetch is unskipped under force, so the rows exist to style.
186
+ expect(screen.getByText('Women')).toBeInTheDocument();
187
+ });
188
+
189
+ it('leaves the drawer mounted when the backdrop is clicked', () => {
190
+ renderMenu(true, { forceOpen: true });
191
+
192
+ fireEvent.click(
193
+ document.querySelector('[data-hamburger-overlay]') as Element
194
+ );
195
+
196
+ expect(
197
+ document.querySelector('[data-hamburger-panel]')
198
+ ).toBeInTheDocument();
199
+ });
200
+
201
+ it('leaves the drawer mounted when the close button is clicked', () => {
202
+ renderMenu(true, { forceOpen: true });
203
+
204
+ fireEvent.click(
205
+ document.querySelector('[data-hamburger-close]') as Element
206
+ );
207
+
208
+ expect(
209
+ document.querySelector('[data-hamburger-panel]')
210
+ ).toBeInTheDocument();
211
+ });
212
+
213
+ it('ignores forceOpen on the storefront, byte for byte', () => {
214
+ const forced = renderMenu(false, { forceOpen: true });
215
+ const forcedHtml = forced.container.innerHTML;
216
+ forced.unmount();
217
+
218
+ const plain = renderMenu(false, {});
219
+
220
+ expect(document.querySelector('[data-hamburger-panel]')).toBeNull();
221
+ expect(forcedHtml).toBe(plain.container.innerHTML);
222
+ });
223
+ });
224
+
225
+ describe('MegaMenuBlock under force-open', () => {
226
+ const renderMenu = (
227
+ isDesigner: boolean,
228
+ styles: Record<string, unknown>,
229
+ properties: Record<string, unknown> = {}
230
+ ) =>
231
+ render(
232
+ <MegaMenuBlock
233
+ block={mkBlock('mega-menu', properties, styles)}
234
+ isDesigner={isDesigner}
235
+ {...blockProps}
236
+ />
237
+ );
238
+
239
+ it('pins open the first entry that yields a panel, with no interaction', () => {
240
+ renderMenu(true, { forceOpen: true });
241
+
242
+ const panel = document.querySelector('[data-mega-menu-panel]');
243
+ expect(panel).toBeInTheDocument();
244
+ // "Sale" has no children and no promo, so the pin lands on "Women".
245
+ expect(screen.getByText('Shoes')).toBeInTheDocument();
246
+ });
247
+
248
+ it('does not close when the pointer leaves for the editor UI', () => {
249
+ jest.useFakeTimers();
250
+ try {
251
+ renderMenu(true, { forceOpen: true });
252
+
253
+ fireEvent.mouseLeave(document.querySelector('[data-mega-menu]') as Element);
254
+ act(() => {
255
+ jest.advanceTimersByTime(2000);
256
+ });
257
+
258
+ expect(
259
+ document.querySelector('[data-mega-menu-panel]')
260
+ ).toBeInTheDocument();
261
+ } finally {
262
+ jest.useRealTimers();
263
+ }
264
+ });
265
+
266
+ it('keeps the panel open when the open entry is clicked in click mode', () => {
267
+ renderMenu(true, { forceOpen: true }, { trigger: 'click' });
268
+
269
+ fireEvent.click(screen.getByRole('button', { name: 'Women' }));
270
+
271
+ expect(
272
+ document.querySelector('[data-mega-menu-panel]')
273
+ ).toBeInTheDocument();
274
+ });
275
+
276
+ it('ignores forceOpen on the storefront, byte for byte', () => {
277
+ const forced = renderMenu(false, { forceOpen: true });
278
+ const forcedHtml = forced.container.innerHTML;
279
+ forced.unmount();
280
+
281
+ const plain = renderMenu(false, {});
282
+
283
+ expect(document.querySelector('[data-mega-menu-panel]')).toBeNull();
284
+ expect(forcedHtml).toBe(plain.container.innerHTML);
285
+ });
286
+ });
287
+
288
+ describe('NavMenuBlock under force-open', () => {
289
+ const renderMenu = (
290
+ isDesigner: boolean,
291
+ styles: Record<string, unknown>,
292
+ properties: Record<string, unknown> = { showSubmenu: true }
293
+ ) =>
294
+ render(
295
+ <NavMenuBlock
296
+ block={mkBlock('nav-menu', properties, styles)}
297
+ isDesigner={isDesigner}
298
+ {...blockProps}
299
+ />
300
+ );
301
+
302
+ it('pins open the first submenu-bearing entry with no interaction', () => {
303
+ renderMenu(true, { forceOpen: true });
304
+
305
+ const submenu = document.querySelector('[data-nav-submenu]');
306
+ expect(submenu).toBeInTheDocument();
307
+ expect(submenu).toHaveAttribute('data-state', 'open');
308
+ expect(screen.getByText('Shoes')).toBeInTheDocument();
309
+ });
310
+
311
+ it('does not close when the pointer leaves the pinned entry', () => {
312
+ renderMenu(true, { forceOpen: true });
313
+
314
+ fireEvent.mouseLeave(
315
+ document.querySelector('[data-nav-item-wrapper]') as Element
316
+ );
317
+
318
+ expect(document.querySelector('[data-nav-submenu]')).toBeInTheDocument();
319
+ });
320
+
321
+ it('cannot conjure a submenu the composition turned off', () => {
322
+ renderMenu(true, { forceOpen: true }, {});
323
+
324
+ expect(document.querySelector('[data-nav-submenu]')).toBeNull();
325
+ });
326
+
327
+ it('ignores forceOpen on the storefront, byte for byte', () => {
328
+ const forced = renderMenu(false, { forceOpen: true });
329
+ const forcedHtml = forced.container.innerHTML;
330
+ forced.unmount();
331
+
332
+ const plain = renderMenu(false, {});
333
+
334
+ expect(document.querySelector('[data-nav-submenu]')).toBeNull();
335
+ expect(forcedHtml).toBe(plain.container.innerHTML);
336
+ });
337
+ });
338
+
339
+ describe('HotspotBlock under force-open', () => {
340
+ const renderHotspot = (isDesigner: boolean, styles: Record<string, unknown>) =>
341
+ render(
342
+ <HotspotBlock
343
+ block={mkBlock('hotspot', { title: 'Hot Title' }, styles)}
344
+ isDesigner={isDesigner}
345
+ {...blockProps}
346
+ />
347
+ );
348
+
349
+ it('mounts the popup with no interaction in the designer', () => {
350
+ renderHotspot(true, { forceOpen: true });
351
+
352
+ expect(screen.getByText('Hot Title')).toBeInTheDocument();
353
+ });
354
+
355
+ it('leaves the popup mounted when the close button is clicked', () => {
356
+ const { container } = renderHotspot(true, { forceOpen: true });
357
+
358
+ fireEvent.click(
359
+ container.querySelector('.text-gray-400') as Element
360
+ );
361
+
362
+ expect(screen.getByText('Hot Title')).toBeInTheDocument();
363
+ });
364
+
365
+ it('resolves a responsive value instead of a raw truthy read', () => {
366
+ // `{ desktop: false }` is a truthy OBJECT: the old raw check would have
367
+ // mounted the popup for a value the designer explicitly turned off.
368
+ renderHotspot(true, { forceOpen: { desktop: false } });
369
+
370
+ expect(screen.queryByText('Hot Title')).not.toBeInTheDocument();
371
+ });
372
+
373
+ it('ignores forceOpen on the storefront', () => {
374
+ renderHotspot(false, { forceOpen: true });
375
+
376
+ expect(screen.queryByText('Hot Title')).not.toBeInTheDocument();
377
+ });
378
+ });
@@ -3,7 +3,7 @@
3
3
  import React, { useState } from 'react';
4
4
  import NextLink from 'next/link';
5
5
  import { useGetMenuQuery } from '@akinon/next/data/client/misc';
6
- import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { getResponsiveValue, partAttrs, resolveForceOpen } from '../utils';
7
7
  import { useOverlay } from '../hooks/use-overlay';
8
8
  import { BlockRendererProps } from './block-renderer-registry';
9
9
  import { hasItemTemplate, ItemTemplateList } from './item-template';
@@ -51,6 +51,11 @@ const HamburgerMenuBlock = ({
51
51
  const [open, setOpen] = useState(false);
52
52
  const [expanded, setExpanded] = useState<string[]>([]);
53
53
 
54
+ // Designer-only: hold the drawer mounted so its open state can be styled.
55
+ // Backdrop, close button and navigation stay rendered but never unmount it.
56
+ const forcedOpen =
57
+ isDesigner && resolveForceOpen(block.styles, currentBreakpoint);
58
+
54
59
  const depth = Number(
55
60
  getResponsiveValue(block.properties?.depth, currentBreakpoint, 2)
56
61
  );
@@ -153,7 +158,8 @@ const HamburgerMenuBlock = ({
153
158
  depth: Number.isFinite(depth) && depth > 0 ? depth : 2,
154
159
  ...(parent ? { parent } : {})
155
160
  } as Parameters<typeof useGetMenuQuery>[0],
156
- { skip: !open }
161
+ // The forced drawer must fetch too, or it renders with zero item rows.
162
+ { skip: !(open || forcedOpen) }
157
163
  );
158
164
 
159
165
  const allItems: MenuItemLike[] = Array.isArray(menu) ? menu : [];
@@ -204,7 +210,7 @@ const HamburgerMenuBlock = ({
204
210
  // page they are editing, so every drawer link is inert there.
205
211
  const handleNavigate = (event: React.MouseEvent) => {
206
212
  if (isDesigner) event.preventDefault();
207
- if (closeOnNavigate) setOpen(false);
213
+ if (closeOnNavigate && !forcedOpen) setOpen(false);
208
214
  };
209
215
 
210
216
  // A designer who marked a child as the row layout gets that layout repeated
@@ -233,9 +239,9 @@ const HamburgerMenuBlock = ({
233
239
  <button
234
240
  type="button"
235
241
  data-hamburger-menu=""
236
- {...partAttrs('trigger', { open })}
242
+ {...partAttrs('trigger', { open: open || forcedOpen })}
237
243
  aria-label={ariaLabel}
238
- aria-expanded={open}
244
+ aria-expanded={open || forcedOpen}
239
245
  onClick={() => setOpen(true)}
240
246
  style={{
241
247
  display: 'inline-flex',
@@ -268,7 +274,7 @@ const HamburgerMenuBlock = ({
268
274
  )}
269
275
  </button>
270
276
 
271
- {open && (
277
+ {(open || forcedOpen) && (
272
278
  <div
273
279
  data-hamburger-drawer=""
274
280
  style={{ position: 'fixed', inset: 0, zIndex: 1000 }}
@@ -277,7 +283,10 @@ const HamburgerMenuBlock = ({
277
283
  <div
278
284
  data-hamburger-overlay=""
279
285
  {...partAttrs('overlay')}
280
- onClick={() => setOpen(false)}
286
+ onClick={() => {
287
+ if (forcedOpen) return;
288
+ setOpen(false);
289
+ }}
281
290
  style={{
282
291
  position: 'absolute',
283
292
  inset: 0,
@@ -317,7 +326,10 @@ const HamburgerMenuBlock = ({
317
326
  aria-label={closeLabel}
318
327
  data-hamburger-close=""
319
328
  {...partAttrs('close')}
320
- onClick={() => setOpen(false)}
329
+ onClick={() => {
330
+ if (forcedOpen) return;
331
+ setOpen(false);
332
+ }}
321
333
  style={{
322
334
  background: 'none',
323
335
  border: 'none',
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React, { useState } from 'react';
4
4
  import { BlockRendererProps } from './block-renderer-registry';
5
- import { getResponsiveValue, getCSSStyles } from '../utils';
5
+ import { getResponsiveValue, getCSSStyles, resolveForceOpen } from '../utils';
6
6
  import { useThemeSettingsContext } from '../theme-settings-context';
7
7
  import { useLocalization } from '@akinon/next/hooks';
8
8
  import ThemeBlock from '../theme-block';
@@ -24,6 +24,10 @@ const HotspotBlock = ({
24
24
  const styles = block.styles || {};
25
25
  const properties = block.properties || {};
26
26
 
27
+ // Designer-only: hold the popup mounted so its open state can be styled.
28
+ const forcedOpen =
29
+ isDesigner && resolveForceOpen(styles, currentBreakpoint);
30
+
27
31
  const getLocalizedContent = (content: any): string => {
28
32
  if (typeof content === 'string') return content;
29
33
  if (typeof content === 'object' && content !== null) {
@@ -102,13 +106,14 @@ const HotspotBlock = ({
102
106
  ></span>
103
107
  </button>
104
108
 
105
- {(isOpen || (isDesigner && styles.forceOpen)) && (
109
+ {(isOpen || forcedOpen) && (
106
110
  <div className="absolute left-1/2 bottom-full mb-2 -translate-x-1/2 w-64 bg-white p-4 rounded-lg shadow-xl z-20 text-left text-sm pointer-events-auto">
107
111
  <div className="flex justify-between items-start mb-2">
108
112
  {title && <h3 className="font-bold text-gray-900">{title}</h3>}
109
113
  <button
110
114
  onClick={(e) => {
111
115
  e.stopPropagation();
116
+ if (forcedOpen) return;
112
117
  setIsOpen(false);
113
118
  }}
114
119
  className="text-gray-400 hover:text-gray-600"
@@ -10,7 +10,7 @@ import React, {
10
10
  import NextLink from 'next/link';
11
11
  import { usePathname } from 'next/navigation';
12
12
  import { useGetMenuQuery } from '@akinon/next/data/client/misc';
13
- import { getResponsiveValue, partAttrs } from '../utils';
13
+ 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';
@@ -326,6 +326,19 @@ const MegaMenuBlock = ({
326
326
  );
327
327
  const items = maxItems > 0 ? allItems.slice(0, maxItems) : allItems;
328
328
 
329
+ // Designer-only: pin a panel open so its open state can be styled. The
330
+ // first item that actually yields a panel (children, hence renderPanel is
331
+ // not null) is pinned by default; hover/click may switch WHICH item is
332
+ // open but never close them all.
333
+ const forcedOpen =
334
+ isDesigner && resolveForceOpen(block.styles, currentBreakpoint);
335
+ const forcedKey =
336
+ forcedOpen && openKey === null
337
+ ? (items.find((item) => item.children.length > 0) ?? items[0])?.key ??
338
+ null
339
+ : null;
340
+ const effectiveOpenKey = openKey ?? forcedKey;
341
+
329
342
  const clearTimers = useCallback(() => {
330
343
  if (openTimer.current) clearTimeout(openTimer.current);
331
344
  if (closeTimer.current) clearTimeout(closeTimer.current);
@@ -365,6 +378,9 @@ const MegaMenuBlock = ({
365
378
  };
366
379
 
367
380
  const scheduleClose = () => {
381
+ // While forced, the pointer leaving for the editor UI must not close the
382
+ // pinned panel — switching items stays live, closing them all does not.
383
+ if (forcedOpen) return;
368
384
  clearTimers();
369
385
  closeTimer.current = setTimeout(() => setOpenKey(null), closeDelay);
370
386
  };
@@ -641,7 +657,7 @@ const MegaMenuBlock = ({
641
657
  }}
642
658
  >
643
659
  {items.map((item) => {
644
- const open = openKey === item.key;
660
+ const open = effectiveOpenKey === item.key;
645
661
  const highlighted = isHighlighted(item.label);
646
662
  const current = isCurrent(item.url);
647
663
  const entryStyle: React.CSSProperties = {
@@ -670,6 +686,7 @@ const MegaMenuBlock = ({
670
686
  setOpenKey(item.key);
671
687
  }}
672
688
  onBlur={(event) => {
689
+ if (forcedOpen) return;
673
690
  if (
674
691
  !event.currentTarget.contains(event.relatedTarget as Node)
675
692
  ) {
@@ -683,7 +700,11 @@ const MegaMenuBlock = ({
683
700
  type="button"
684
701
  aria-expanded={open}
685
702
  {...partAttrs('item', { open, selected: current })}
686
- onClick={() => setOpenKey(open ? null : item.key)}
703
+ onClick={() =>
704
+ // While forced, clicking the open entry keeps it open:
705
+ // switching is allowed, closing them all is not.
706
+ setOpenKey(open && !forcedOpen ? null : item.key)
707
+ }
687
708
  style={entryStyle}
688
709
  >
689
710
  {item.label}
@@ -4,7 +4,7 @@ import React, { useState } from 'react';
4
4
  import NextLink from 'next/link';
5
5
  import { usePathname } from 'next/navigation';
6
6
  import { useGetMenuQuery } from '@akinon/next/data/client/misc';
7
- import { getResponsiveValue, partAttrs } from '../utils';
7
+ import { getResponsiveValue, partAttrs, resolveForceOpen } from '../utils';
8
8
  import { BlockRendererProps } from './block-renderer-registry';
9
9
  import { hasItemTemplate, ItemTemplateList } from './item-template';
10
10
  import { readToggle } from './product-block-utils';
@@ -145,6 +145,20 @@ const NavMenuBlock = ({
145
145
  ? allItems.slice(0, Math.round(maxItems))
146
146
  : allItems;
147
147
 
148
+ // Designer-only: pin a submenu open so its open state can be styled. The
149
+ // first item with children is pinned by default; hover/click may switch
150
+ // WHICH submenu is open but never close them all. It cannot conjure a
151
+ // submenu when the composition turned `showSubmenu` off — that gate stays.
152
+ const forcedOpen =
153
+ isDesigner && resolveForceOpen(block.styles, currentBreakpoint);
154
+ const forcedIndex =
155
+ forcedOpen && showSubmenu && openKey === null
156
+ ? items.findIndex((item) => (item.children || []).length > 0)
157
+ : -1;
158
+ const effectiveOpenKey =
159
+ openKey ??
160
+ (forcedIndex >= 0 ? String(items[forcedIndex].pk ?? forcedIndex) : null);
161
+
148
162
  // A designer who marked a child as the row layout gets that layout repeated
149
163
  // per menu entry; otherwise the built-in links render unchanged.
150
164
  const customItems = hasItemTemplate(block, currentBreakpoint) ? (
@@ -179,7 +193,8 @@ const NavMenuBlock = ({
179
193
 
180
194
  const submenuPanel = (item: MenuItemLike, key: string) => {
181
195
  const children = item.children || [];
182
- if (!showSubmenu || children.length === 0 || openKey !== key) return null;
196
+ if (!showSubmenu || children.length === 0 || effectiveOpenKey !== key)
197
+ return null;
183
198
 
184
199
  return (
185
200
  <div
@@ -213,7 +228,7 @@ const NavMenuBlock = ({
213
228
  // Navigating inside the designer iframe would drop the author
214
229
  // out of the page they are editing.
215
230
  if (isDesigner) event.preventDefault();
216
- setOpenKey(null);
231
+ if (!forcedOpen) setOpenKey(null);
217
232
  }}
218
233
  style={{
219
234
  color: 'inherit',
@@ -255,13 +270,15 @@ const NavMenuBlock = ({
255
270
  aria-current={current ? 'page' : undefined}
256
271
  {...partAttrs('item', {
257
272
  selected: current,
258
- open: showSubmenu && hasChildren && openKey === key
273
+ open: showSubmenu && hasChildren && effectiveOpenKey === key
259
274
  })}
260
275
  onClick={(event) => {
261
276
  if (isDesigner) event.preventDefault();
262
277
  if (showSubmenu && hasChildren && submenuTrigger === 'click') {
263
278
  event.preventDefault();
264
- setOpenKey(openKey === key ? null : key);
279
+ // While forced, clicking the open entry keeps it open:
280
+ // switching is allowed, closing them all is not.
281
+ setOpenKey(!forcedOpen && openKey === key ? null : key);
265
282
  }
266
283
  }}
267
284
  style={{
@@ -289,7 +306,7 @@ const NavMenuBlock = ({
289
306
  if (submenuTrigger === 'hover') setOpenKey(key);
290
307
  }}
291
308
  onMouseLeave={() => {
292
- if (submenuTrigger === 'hover') setOpenKey(null);
309
+ if (submenuTrigger === 'hover' && !forcedOpen) setOpenKey(null);
293
310
  }}
294
311
  style={{
295
312
  position: 'relative',
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React, { useEffect, useRef, useState } from 'react';
4
4
  import { useRouter } from 'next/navigation';
5
- import { getResponsiveValue, partAttrs } from '../utils';
5
+ import { getResponsiveValue, partAttrs, resolveForceOpen } from '../utils';
6
6
  import { BlockRendererProps } from './block-renderer-registry';
7
7
  import { readToggle } from './product-block-utils';
8
8
 
@@ -37,6 +37,12 @@ const SearchBoxBlock = ({
37
37
  const [term, setTerm] = useState('');
38
38
  const inputRef = useRef<HTMLInputElement>(null);
39
39
 
40
+ // Designer-only: hold the overlay panel mounted so its open state can be
41
+ // styled. Close routes stay rendered but never unmount it while forced.
42
+ const forcedOpen =
43
+ isDesigner && resolveForceOpen(block.styles, currentBreakpoint);
44
+ const panelOpen = open || forcedOpen;
45
+
40
46
  const prop = (key: string, fallback: string) =>
41
47
  String(
42
48
  getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
@@ -176,10 +182,13 @@ const SearchBoxBlock = ({
176
182
  <button
177
183
  type="button"
178
184
  data-search-box=""
179
- {...partAttrs('trigger', { open })}
185
+ {...partAttrs('trigger', { open: panelOpen })}
180
186
  aria-label={triggerLabel || placeholder}
181
- aria-expanded={open}
182
- onClick={() => setOpen((value) => !value)}
187
+ aria-expanded={panelOpen}
188
+ onClick={() => {
189
+ if (forcedOpen) return;
190
+ setOpen((value) => !value);
191
+ }}
183
192
  style={{
184
193
  display: 'inline-flex',
185
194
  alignItems: 'center',
@@ -193,7 +202,7 @@ const SearchBoxBlock = ({
193
202
  {icon}
194
203
  </button>
195
204
 
196
- {open && (
205
+ {panelOpen && (
197
206
  <div
198
207
  data-search-overlay=""
199
208
  {...partAttrs('panel', { open: true })}
@@ -247,7 +256,10 @@ const SearchBoxBlock = ({
247
256
  aria-label={closeLabel}
248
257
  data-search-close=""
249
258
  {...partAttrs('close')}
250
- onClick={() => setOpen(false)}
259
+ onClick={() => {
260
+ if (forcedOpen) return;
261
+ setOpen(false);
262
+ }}
251
263
  style={{
252
264
  background: 'none',
253
265
  border: 'none',
@@ -33,14 +33,17 @@ const tab = (id: string, headerChildId: string, contentChildId: string): AnyBloc
33
33
  ]
34
34
  });
35
35
 
36
- const makeSection = (blocks: AnyBlock[]): Section =>
36
+ const makeSection = (
37
+ blocks: AnyBlock[],
38
+ styles: Record<string, unknown> = {}
39
+ ): Section =>
37
40
  ({
38
41
  id: 'section-1',
39
42
  type: 'tabs',
40
43
  name: 'Tabs',
41
44
  label: 'Tabs',
42
45
  properties: {},
43
- styles: {},
46
+ styles,
44
47
  blocks,
45
48
  order: 0,
46
49
  hidden: false
@@ -112,4 +115,46 @@ describe('TabsSection', () => {
112
115
 
113
116
  expect(shown()).toEqual(['t1-c']);
114
117
  });
118
+
119
+ describe('under force-open', () => {
120
+ const contentIds = ['t1-c', 't2-c', 't3-c'];
121
+ const forcedSection = () =>
122
+ makeSection(
123
+ [tab('t1', 't1-h', 't1-c'), tab('t2', 't2-h', 't2-c'), tab('t3', 't3-h', 't3-c')],
124
+ { forceOpen: true }
125
+ );
126
+ const shown = () =>
127
+ contentIds.filter((id) => idsRendered().includes(id));
128
+
129
+ it('renders every pane stacked in the designer with no interaction', () => {
130
+ render(<TabsSection section={forcedSection()} isDesigner />);
131
+
132
+ expect(shown()).toEqual(contentIds);
133
+ });
134
+
135
+ it('keeps every pane mounted when a header is clicked', () => {
136
+ render(<TabsSection section={forcedSection()} isDesigner />);
137
+
138
+ fireEvent.click(document.querySelector('[data-id="t2-h"]') as Element);
139
+
140
+ expect(shown()).toEqual(contentIds);
141
+ });
142
+
143
+ it('keeps forceOpen out of the container inline styles', () => {
144
+ const { container } = render(
145
+ <TabsSection section={forcedSection()} isDesigner />
146
+ );
147
+
148
+ const style =
149
+ (container.firstElementChild as HTMLElement).getAttribute('style') ||
150
+ '';
151
+ expect(style.toLowerCase()).not.toContain('force');
152
+ });
153
+
154
+ it('ignores forceOpen on the storefront: only the active pane renders', () => {
155
+ render(<TabsSection section={forcedSection()} />);
156
+
157
+ expect(shown()).toEqual(['t1-c']);
158
+ });
159
+ });
115
160
  });
@@ -4,6 +4,7 @@ import ThemeBlock from '../theme-block';
4
4
  import {
5
5
  getCSSStyles,
6
6
  getResponsiveValue as readResponsiveValue,
7
+ resolveForceOpen,
7
8
  resolveThemeCssVariables,
8
9
  resolveThemeStyleObject
9
10
  } from '../utils';
@@ -30,6 +31,12 @@ const TabsSection: React.FC<TabsSectionProps> = ({
30
31
  const [activeTabIndex, setActiveTabIndex] = useState(0);
31
32
  const themeSettings = useThemeSettingsContext();
32
33
 
34
+ // Designer-only: render every pane stacked so each one is mounted and
35
+ // styleable without racing the selected-block effect below. Header clicks
36
+ // keep switching the active highlight but never unmount a pane.
37
+ const forcedOpen =
38
+ isDesigner && resolveForceOpen(section.styles, currentBreakpoint);
39
+
33
40
  // Separate tab blocks from non-tab blocks (like headers)
34
41
  const nonTabBlocks = section.blocks
35
42
  .filter(
@@ -136,6 +143,10 @@ const TabsSection: React.FC<TabsSectionProps> = ({
136
143
  const styles: Record<string, string | number> = {};
137
144
 
138
145
  Object.keys(properties).forEach((key) => {
146
+ // Not a CSS value: the designer-only force-open flag must never land in
147
+ // the container's inline styles.
148
+ if (key === 'forceOpen') return;
149
+
139
150
  const value = getResponsiveValue<string | number | undefined>(
140
151
  properties[key],
141
152
  undefined
@@ -493,7 +504,7 @@ const TabsSection: React.FC<TabsSectionProps> = ({
493
504
 
494
505
  <div>
495
506
  {tabBlocks.map((tabBlock, index) => {
496
- if (index !== activeTabIndex) return null;
507
+ if (!forcedOpen && index !== activeTabIndex) return null;
497
508
 
498
509
  const contentBlock = tabBlock.blocks?.find(
499
510
  (b) => b.label === 'Tab Content'
@@ -234,6 +234,27 @@ export const getResponsiveValue = (
234
234
  return value;
235
235
  };
236
236
 
237
+ /**
238
+ * Designer-only "hold this panel open" flag, read from `styles.forceOpen`.
239
+ *
240
+ * The editor stores it as a plain boolean, but published JSON may carry a
241
+ * per-breakpoint object or the string a select would store, so resolution
242
+ * goes through the same breakpoint chain as every other style value with the
243
+ * coercion the toggle reader applies. Callers gate on `isDesigner` — a value
244
+ * left in published widget JSON is inert on the storefront.
245
+ */
246
+ export const resolveForceOpen = (
247
+ styles: Record<string, unknown> | undefined | null,
248
+ currentBreakpoint = 'desktop'
249
+ ): boolean => {
250
+ const raw = getResponsiveValue(styles?.forceOpen, currentBreakpoint, false);
251
+ if (typeof raw === 'boolean') return raw;
252
+ if (typeof raw === 'number') return raw !== 0;
253
+
254
+ const normalized = String(raw).trim().toLowerCase();
255
+ return normalized === 'true' || normalized === '1' || normalized === 'yes';
256
+ };
257
+
237
258
  export const resolveOption = <T extends string>(
238
259
  value: unknown,
239
260
  currentBreakpoint: string,