@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 +7 -31
- package/package.json +4 -4
- package/src/blocks/__tests__/force-open.test.tsx +378 -0
- package/src/blocks/hamburger-menu-block.tsx +20 -8
- package/src/blocks/hotspot-block.tsx +7 -2
- package/src/blocks/mega-menu-block.tsx +24 -3
- package/src/blocks/nav-menu-block.tsx +23 -6
- package/src/blocks/search-box-block.tsx +18 -6
- package/src/sections/__tests__/tabs-section.test.tsx +47 -2
- package/src/sections/tabs-section.tsx +12 -1
- package/src/utils/index.ts +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,36 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.45
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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={() =>
|
|
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={() =>
|
|
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 ||
|
|
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 =
|
|
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={() =>
|
|
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 ||
|
|
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 &&
|
|
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
|
-
|
|
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={
|
|
182
|
-
onClick={() =>
|
|
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
|
-
{
|
|
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={() =>
|
|
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 = (
|
|
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'
|
package/src/utils/index.ts
CHANGED
|
@@ -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,
|