@akinon/pz-theme 2.0.48-rc.0 → 2.0.49-rc.0
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 +11 -3
- package/package.json +4 -4
- package/src/blocks/__tests__/force-open.test.tsx +114 -2
- package/src/blocks/__tests__/overlay-sweep.test.tsx +173 -0
- package/src/blocks/__tests__/panel-block.test.tsx +14 -0
- package/src/blocks/panel-block.tsx +1 -1
- package/src/blocks/search-box-block.tsx +277 -79
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.
|
|
3
|
+
## 2.0.49-rc.0
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
@@ -29,8 +29,16 @@
|
|
|
29
29
|
- Updated dependencies [3909d3224]
|
|
30
30
|
- Updated dependencies [6a3d8a63]
|
|
31
31
|
- Updated dependencies [e18836b20]
|
|
32
|
-
- @akinon/next@2.0.
|
|
33
|
-
- @akinon/theme-contract@2.0.
|
|
32
|
+
- @akinon/next@2.0.49-rc.0
|
|
33
|
+
- @akinon/theme-contract@2.0.49-rc.0
|
|
34
|
+
|
|
35
|
+
## 2.0.48
|
|
36
|
+
|
|
37
|
+
### Patch Changes
|
|
38
|
+
|
|
39
|
+
- 1763ea1: ZERO-4741: Render authored children in the search overlay and route it through the shared overlay primitive
|
|
40
|
+
- @akinon/next@2.0.48
|
|
41
|
+
- @akinon/theme-contract@2.0.48
|
|
34
42
|
|
|
35
43
|
## 2.0.47
|
|
36
44
|
|
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.
|
|
4
|
+
"version": "2.0.49-rc.0",
|
|
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.
|
|
11
|
+
"@akinon/next": "2.0.49-rc.0",
|
|
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.
|
|
16
|
+
"@akinon/theme-contract": "2.0.49-rc.0",
|
|
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.
|
|
23
|
+
"@akinon/next": "2.0.49-rc.0",
|
|
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",
|
|
@@ -93,15 +93,37 @@ const pin = (blockId: string, partName: string, state: string | null) =>
|
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
describe('SearchBoxBlock under force-open', () => {
|
|
96
|
-
const renderBox = (
|
|
96
|
+
const renderBox = (
|
|
97
|
+
isDesigner: boolean,
|
|
98
|
+
styles: Record<string, unknown>,
|
|
99
|
+
properties: Record<string, unknown> = {},
|
|
100
|
+
blocks: unknown[] = []
|
|
101
|
+
) =>
|
|
97
102
|
render(
|
|
98
103
|
<SearchBoxBlock
|
|
99
|
-
block={
|
|
104
|
+
block={
|
|
105
|
+
{
|
|
106
|
+
...(mkBlock('search-box', properties, styles) as object),
|
|
107
|
+
blocks
|
|
108
|
+
} as never
|
|
109
|
+
}
|
|
100
110
|
isDesigner={isDesigner}
|
|
101
111
|
{...blockProps}
|
|
102
112
|
/>
|
|
103
113
|
);
|
|
104
114
|
|
|
115
|
+
const suggestion = {
|
|
116
|
+
id: 'sugg-1',
|
|
117
|
+
type: 'text',
|
|
118
|
+
label: 'Suggestion',
|
|
119
|
+
order: 0,
|
|
120
|
+
hidden: false,
|
|
121
|
+
value: 'Trending',
|
|
122
|
+
properties: {},
|
|
123
|
+
styles: {},
|
|
124
|
+
blocks: []
|
|
125
|
+
};
|
|
126
|
+
|
|
105
127
|
it('mounts the overlay panel with no interaction in the designer', () => {
|
|
106
128
|
renderBox(true, { forceOpen: true });
|
|
107
129
|
|
|
@@ -155,6 +177,47 @@ describe('SearchBoxBlock under force-open', () => {
|
|
|
155
177
|
expect(panel?.getAttribute('data-state')).toContain('open');
|
|
156
178
|
});
|
|
157
179
|
|
|
180
|
+
it('mounts the backdrop it is asked for and leaves it inert', () => {
|
|
181
|
+
renderBox(true, { forceOpen: true }, { showBackdrop: true });
|
|
182
|
+
|
|
183
|
+
const backdrop = document.querySelector('[data-search-backdrop]');
|
|
184
|
+
// The backdrop is the point: it cannot be coloured or blurred in a canvas
|
|
185
|
+
// that never draws it.
|
|
186
|
+
expect(backdrop).toBeInTheDocument();
|
|
187
|
+
expect(backdrop).toHaveAttribute('data-part', 'backdrop');
|
|
188
|
+
|
|
189
|
+
fireEvent.click(backdrop as Element);
|
|
190
|
+
|
|
191
|
+
expect(document.querySelector('[data-search-overlay]')).toBeInTheDocument();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('renders the authored children behind the body part', () => {
|
|
195
|
+
renderBox(true, { forceOpen: true }, {}, [suggestion]);
|
|
196
|
+
|
|
197
|
+
const body = document.querySelector('[data-part="body"]');
|
|
198
|
+
expect(body).toBeInTheDocument();
|
|
199
|
+
expect(body?.querySelector('[data-testid="tb"]')).toHaveAttribute(
|
|
200
|
+
'data-id',
|
|
201
|
+
'sugg-1'
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('keeps a drop target in the canvas for a panel nobody has filled yet', () => {
|
|
206
|
+
renderBox(true, { forceOpen: true });
|
|
207
|
+
|
|
208
|
+
expect(document.querySelector('[data-part="body"]')).toBeInTheDocument();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('does not answer Escape in the canvas', () => {
|
|
212
|
+
renderBox(true, { forceOpen: true });
|
|
213
|
+
|
|
214
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
215
|
+
|
|
216
|
+
// The primitive is inert in the designer, so the key cannot take away the
|
|
217
|
+
// panel the author is styling.
|
|
218
|
+
expect(document.querySelector('[data-search-overlay]')).toBeInTheDocument();
|
|
219
|
+
});
|
|
220
|
+
|
|
158
221
|
it('ignores forceOpen on the storefront, byte for byte', () => {
|
|
159
222
|
const forced = renderBox(false, { forceOpen: true });
|
|
160
223
|
const forcedHtml = forced.container.innerHTML;
|
|
@@ -165,6 +228,55 @@ describe('SearchBoxBlock under force-open', () => {
|
|
|
165
228
|
expect(document.querySelector('[data-search-overlay]')).toBeNull();
|
|
166
229
|
expect(forcedHtml).toBe(plain.container.innerHTML);
|
|
167
230
|
});
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The back-compat pin. Published widget JSON carries none of the overlay
|
|
234
|
+
* properties and — because the renderer ignored `block.blocks` entirely —
|
|
235
|
+
* no children, so an opened panel must gain no node it did not have before.
|
|
236
|
+
*/
|
|
237
|
+
it('grows no backdrop and no body for a published composition', () => {
|
|
238
|
+
renderBox(false, {}, {}, []);
|
|
239
|
+
|
|
240
|
+
fireEvent.click(document.querySelector('[data-part="trigger"]') as Element);
|
|
241
|
+
|
|
242
|
+
expect(document.querySelector('[data-search-overlay]')).toBeInTheDocument();
|
|
243
|
+
expect(document.querySelector('[data-search-backdrop]')).toBeNull();
|
|
244
|
+
expect(document.querySelector('[data-part="body"]')).toBeNull();
|
|
245
|
+
// And with no backdrop the panel makes no claim about the page behind it.
|
|
246
|
+
expect(
|
|
247
|
+
document.querySelector('[data-search-overlay]')
|
|
248
|
+
).not.toHaveAttribute('aria-modal');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('moves focus into the field on open and hands it back on close', () => {
|
|
252
|
+
renderBox(false, {});
|
|
253
|
+
|
|
254
|
+
const trigger = document.querySelector('[data-part="trigger"]') as
|
|
255
|
+
HTMLElement;
|
|
256
|
+
trigger.focus();
|
|
257
|
+
fireEvent.click(trigger);
|
|
258
|
+
|
|
259
|
+
expect(document.activeElement).toBe(
|
|
260
|
+
document.querySelector('[data-search-input]')
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
fireEvent.click(document.querySelector('[data-search-close]') as Element);
|
|
264
|
+
|
|
265
|
+
// Never handed back before the migration: a keyboard shopper who closed
|
|
266
|
+
// the panel was dropped at the top of the document.
|
|
267
|
+
expect(document.activeElement).toBe(trigger);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('leaves focus on the icon when the composition turned it off', () => {
|
|
271
|
+
renderBox(false, {}, { autoFocus: false });
|
|
272
|
+
|
|
273
|
+
const trigger = document.querySelector('[data-part="trigger"]') as
|
|
274
|
+
HTMLElement;
|
|
275
|
+
trigger.focus();
|
|
276
|
+
fireEvent.click(trigger);
|
|
277
|
+
|
|
278
|
+
expect(document.activeElement).toBe(trigger);
|
|
279
|
+
});
|
|
168
280
|
});
|
|
169
281
|
|
|
170
282
|
describe('HamburgerMenuBlock under force-open', () => {
|
|
@@ -25,6 +25,7 @@ jest.mock('@akinon/next/data/client/misc', () => ({
|
|
|
25
25
|
import FacetFiltersBlock from '../facet-filters-block';
|
|
26
26
|
import PanelBlock from '../panel-block';
|
|
27
27
|
import HamburgerMenuBlock from '../hamburger-menu-block';
|
|
28
|
+
import SearchBoxBlock from '../search-box-block';
|
|
28
29
|
import MegaMenuBlock from '../mega-menu-block';
|
|
29
30
|
import SortSelectBlock from '../sort-select-block';
|
|
30
31
|
import { useOverlay } from '../../hooks/use-overlay';
|
|
@@ -487,6 +488,178 @@ describe('the size guide next to an overlay already on the primitive', () => {
|
|
|
487
488
|
});
|
|
488
489
|
});
|
|
489
490
|
|
|
491
|
+
/**
|
|
492
|
+
* The search panel was never an overlay at all: a bare `position: fixed` div
|
|
493
|
+
* with a close button, no backdrop, no role, no Escape and no scroll lock. It
|
|
494
|
+
* lives in the header, which is exactly where a shopper opens a hamburger
|
|
495
|
+
* drawer or a mini basket over it, so it joins the same stack as the rest.
|
|
496
|
+
*/
|
|
497
|
+
describe('the search panel next to an overlay already on the primitive', () => {
|
|
498
|
+
const SearchPage = ({
|
|
499
|
+
migratedOpen = false,
|
|
500
|
+
properties = {}
|
|
501
|
+
}: {
|
|
502
|
+
migratedOpen?: boolean;
|
|
503
|
+
properties?: Record<string, unknown>;
|
|
504
|
+
}) => (
|
|
505
|
+
<>
|
|
506
|
+
<SearchBoxBlock
|
|
507
|
+
block={mkBlock('search-box', properties)}
|
|
508
|
+
placeholderId="p"
|
|
509
|
+
sectionId="s"
|
|
510
|
+
isDesigner={false}
|
|
511
|
+
currentBreakpoint="desktop"
|
|
512
|
+
/>
|
|
513
|
+
<MigratedPanel open={migratedOpen} />
|
|
514
|
+
</>
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
const openPanel = () =>
|
|
518
|
+
fireEvent.click(document.querySelector('[data-search-box]') as HTMLElement);
|
|
519
|
+
|
|
520
|
+
it('opens exactly the panel it always opened', () => {
|
|
521
|
+
render(<SearchPage />);
|
|
522
|
+
openPanel();
|
|
523
|
+
|
|
524
|
+
// The shipped behaviour, stated: no scrim over the page, no claim that the
|
|
525
|
+
// page behind is hidden, and a page that still scrolls.
|
|
526
|
+
expect(document.querySelector('[data-search-backdrop]')).toBeNull();
|
|
527
|
+
expect(screen.getByRole('dialog', { name: 'Search' })).not.toHaveAttribute(
|
|
528
|
+
'aria-modal'
|
|
529
|
+
);
|
|
530
|
+
expect(document.body.style.overflow).toBe('');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('claims the page only while it is the backdropped panel on top', () => {
|
|
534
|
+
const { rerender } = render(
|
|
535
|
+
<SearchPage properties={{ showBackdrop: true }} />
|
|
536
|
+
);
|
|
537
|
+
openPanel();
|
|
538
|
+
|
|
539
|
+
expect(screen.getByRole('dialog', { name: 'Search' })).toHaveAttribute(
|
|
540
|
+
'aria-modal',
|
|
541
|
+
'true'
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
rerender(<SearchPage properties={{ showBackdrop: true }} migratedOpen />);
|
|
545
|
+
expect(screen.getByRole('dialog', { name: 'Search' })).not.toHaveAttribute(
|
|
546
|
+
'aria-modal'
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
rerender(
|
|
550
|
+
<SearchPage properties={{ showBackdrop: true }} migratedOpen={false} />
|
|
551
|
+
);
|
|
552
|
+
expect(screen.getByRole('dialog', { name: 'Search' })).toHaveAttribute(
|
|
553
|
+
'aria-modal',
|
|
554
|
+
'true'
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('counts its scroll lock with every other overlay', () => {
|
|
559
|
+
document.body.style.overflow = 'auto';
|
|
560
|
+
|
|
561
|
+
const { rerender } = render(
|
|
562
|
+
<SearchPage
|
|
563
|
+
properties={{ showBackdrop: true, lockScroll: true }}
|
|
564
|
+
migratedOpen
|
|
565
|
+
/>
|
|
566
|
+
);
|
|
567
|
+
openPanel();
|
|
568
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
569
|
+
|
|
570
|
+
// The panel that froze the page first closes while the search panel is up.
|
|
571
|
+
rerender(
|
|
572
|
+
<SearchPage
|
|
573
|
+
properties={{ showBackdrop: true, lockScroll: true }}
|
|
574
|
+
migratedOpen={false}
|
|
575
|
+
/>
|
|
576
|
+
);
|
|
577
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
578
|
+
|
|
579
|
+
fireEvent.click(document.querySelector('[data-search-close]') as HTMLElement);
|
|
580
|
+
expect(document.body.style.overflow).toBe('auto');
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('closes the panel on the backdrop it was given', () => {
|
|
584
|
+
render(<SearchPage properties={{ showBackdrop: true }} />);
|
|
585
|
+
openPanel();
|
|
586
|
+
|
|
587
|
+
fireEvent.click(
|
|
588
|
+
document.querySelector('[data-search-backdrop]') as HTMLElement
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
expect(screen.queryByRole('dialog', { name: 'Search' })).toBeNull();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('honours a composition that keeps the backdrop but not the dismissal', () => {
|
|
595
|
+
render(
|
|
596
|
+
<SearchPage properties={{ showBackdrop: true, closeOnBackdrop: false }} />
|
|
597
|
+
);
|
|
598
|
+
openPanel();
|
|
599
|
+
|
|
600
|
+
fireEvent.click(
|
|
601
|
+
document.querySelector('[data-search-backdrop]') as HTMLElement
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
expect(screen.getByRole('dialog', { name: 'Search' })).toBeInTheDocument();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('answers Escape when it is the panel on top', () => {
|
|
608
|
+
render(<SearchPage />);
|
|
609
|
+
openPanel();
|
|
610
|
+
|
|
611
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
612
|
+
|
|
613
|
+
// It answered nothing at all before: a keyboard shopper was stuck with it.
|
|
614
|
+
expect(screen.queryByRole('dialog', { name: 'Search' })).toBeNull();
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('leaves Escape to the overlay on top', () => {
|
|
618
|
+
const { rerender } = render(<SearchPage />);
|
|
619
|
+
openPanel();
|
|
620
|
+
rerender(<SearchPage migratedOpen />);
|
|
621
|
+
|
|
622
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
623
|
+
|
|
624
|
+
expect(screen.getByRole('dialog', { name: 'Search' })).toBeInTheDocument();
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('honours a composition that turned Escape off', () => {
|
|
628
|
+
render(<SearchPage properties={{ closeOnEscape: false }} />);
|
|
629
|
+
openPanel();
|
|
630
|
+
|
|
631
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
632
|
+
|
|
633
|
+
expect(screen.getByRole('dialog', { name: 'Search' })).toBeInTheDocument();
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('re-arms Escape for a panel with no pointer route out', () => {
|
|
637
|
+
render(
|
|
638
|
+
<SearchPage
|
|
639
|
+
properties={{
|
|
640
|
+
showClose: false,
|
|
641
|
+
showBackdrop: false,
|
|
642
|
+
closeOnEscape: false
|
|
643
|
+
}}
|
|
644
|
+
/>
|
|
645
|
+
);
|
|
646
|
+
openPanel();
|
|
647
|
+
|
|
648
|
+
// Every pointer route is a designer toggle, so a composition can legally
|
|
649
|
+
// turn all of them off. It must never seal a shopper in.
|
|
650
|
+
expect(document.querySelector('[data-search-close]')).toBeInTheDocument();
|
|
651
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
652
|
+
expect(screen.queryByRole('dialog', { name: 'Search' })).toBeNull();
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('drops the close button when the backdrop can dismiss it', () => {
|
|
656
|
+
render(<SearchPage properties={{ showClose: false, showBackdrop: true }} />);
|
|
657
|
+
openPanel();
|
|
658
|
+
|
|
659
|
+
expect(document.querySelector('[data-search-close]')).toBeNull();
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
490
663
|
describe('the hamburger drawer next to an overlay already on the primitive', () => {
|
|
491
664
|
const HamburgerPage = ({
|
|
492
665
|
migratedOpen = false,
|
|
@@ -326,4 +326,18 @@ describe('an empty panel', () => {
|
|
|
326
326
|
|
|
327
327
|
expect(container.querySelector('[data-size-guide]')).toBeInTheDocument();
|
|
328
328
|
});
|
|
329
|
+
|
|
330
|
+
it('invites any blocks into its drop target, not a size table', () => {
|
|
331
|
+
const { container } = renderPanel({}, { isDesigner: true, blocks: [] });
|
|
332
|
+
|
|
333
|
+
const placeholder = container.querySelector(
|
|
334
|
+
'[data-theme-block-placeholder]'
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
expect(placeholder).toHaveTextContent(
|
|
338
|
+
'Panel (drop the blocks it opens here)'
|
|
339
|
+
);
|
|
340
|
+
// The generic block must not steer the author towards its legacy use.
|
|
341
|
+
expect(placeholder?.textContent).not.toMatch(/size/i);
|
|
342
|
+
});
|
|
329
343
|
});
|
|
@@ -295,7 +295,7 @@ const PanelBlock = ({
|
|
|
295
295
|
{childBlocks.length > 0 ? (
|
|
296
296
|
renderChildren()
|
|
297
297
|
) : (
|
|
298
|
-
<DesignerPlaceholder label="
|
|
298
|
+
<DesignerPlaceholder label="Panel (drop the blocks it opens here)" />
|
|
299
299
|
)}
|
|
300
300
|
</div>
|
|
301
301
|
</div>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, {
|
|
3
|
+
import React, { useRef, useState } from 'react';
|
|
4
4
|
import { useRouter } from 'next/navigation';
|
|
5
5
|
import { getResponsiveValue, partAttrs, resolveForceOpen } from '../utils';
|
|
6
|
+
import { useOverlay } from '../hooks/use-overlay';
|
|
7
|
+
import ThemeBlock from '../theme-block';
|
|
6
8
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
|
-
import { readToggle } from './product-block-utils';
|
|
9
|
+
import { DesignerPlaceholder, readToggle } from './product-block-utils';
|
|
8
10
|
|
|
9
11
|
const clampNumber = (
|
|
10
12
|
value: unknown,
|
|
@@ -22,20 +24,32 @@ const clampNumber = (
|
|
|
22
24
|
* expands into an overlay input (mode: 'icon', the common brand pattern)
|
|
23
25
|
* or as an always-visible inline input (mode: 'inline'). The editor owns
|
|
24
26
|
* placement, mode, every label and the styling of each piece.
|
|
27
|
+
*
|
|
28
|
+
* The open panel is a surface the author fills: whatever is dropped into the
|
|
29
|
+
* block renders inside it as CHILD blocks behind the `body` part — recent
|
|
30
|
+
* searches, quick links, a promo — so none of that is hardcoded here. The
|
|
31
|
+
* panel itself is a real overlay rather than a bare fixed div: Escape, the
|
|
32
|
+
* focus trap, the counted scroll lock and the `aria-modal` claim all belong to
|
|
33
|
+
* the shared primitive, which keeps ONE stack for the page.
|
|
34
|
+
*
|
|
25
35
|
* Properties: mode, placeholder, searchUrl, queryParam, showIcon, iconSize,
|
|
26
36
|
* showSubmit, submitLabel, submitStyle, closeLabel, triggerLabel,
|
|
27
|
-
* formMaxWidth, autoFocus, clearOnSubmit
|
|
28
|
-
*
|
|
37
|
+
* formMaxWidth, autoFocus, clearOnSubmit, showClose, showBackdrop,
|
|
38
|
+
* closeOnBackdrop, closeOnEscape, lockScroll.
|
|
39
|
+
* Parts: trigger, icon, backdrop, form, input, submit, panel, body, close.
|
|
29
40
|
*/
|
|
30
41
|
const SearchBoxBlock = ({
|
|
31
42
|
block,
|
|
43
|
+
placeholderId,
|
|
44
|
+
sectionId,
|
|
45
|
+
selectedBlockId,
|
|
32
46
|
currentBreakpoint = 'desktop',
|
|
33
47
|
isDesigner = false
|
|
34
48
|
}: BlockRendererProps) => {
|
|
35
49
|
const router = useRouter();
|
|
36
50
|
const [open, setOpen] = useState(false);
|
|
37
51
|
const [term, setTerm] = useState('');
|
|
38
|
-
const
|
|
52
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
39
53
|
|
|
40
54
|
// Designer-only: hold the overlay panel mounted so its open state can be
|
|
41
55
|
// styled. Close routes stay rendered but never unmount it while forced.
|
|
@@ -91,9 +105,80 @@ const SearchBoxBlock = ({
|
|
|
91
105
|
1600
|
|
92
106
|
);
|
|
93
107
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Every fallback below is the behaviour this block already shipped with, so
|
|
110
|
+
* a composition published before these properties existed keeps rendering
|
|
111
|
+
* exactly what it rendered. They have to stay identical to the editor's
|
|
112
|
+
* `defaultValue`s, or a widget saved after the change behaves differently
|
|
113
|
+
* from one saved before it.
|
|
114
|
+
*/
|
|
115
|
+
const showClose = readToggle(
|
|
116
|
+
block.properties?.showClose,
|
|
117
|
+
currentBreakpoint,
|
|
118
|
+
true
|
|
119
|
+
);
|
|
120
|
+
// Off, because no storefront draws one today: turning it on is a designer's
|
|
121
|
+
// deliberate choice, not something a published header grows overnight.
|
|
122
|
+
const showBackdrop = readToggle(
|
|
123
|
+
block.properties?.showBackdrop,
|
|
124
|
+
currentBreakpoint,
|
|
125
|
+
false
|
|
126
|
+
);
|
|
127
|
+
const closeOnBackdrop = readToggle(
|
|
128
|
+
block.properties?.closeOnBackdrop,
|
|
129
|
+
currentBreakpoint,
|
|
130
|
+
true
|
|
131
|
+
);
|
|
132
|
+
const closeOnEscape = readToggle(
|
|
133
|
+
block.properties?.closeOnEscape,
|
|
134
|
+
currentBreakpoint,
|
|
135
|
+
true
|
|
136
|
+
);
|
|
137
|
+
// The panel is a band across the top with the page still readable behind it,
|
|
138
|
+
// and that page has always kept scrolling.
|
|
139
|
+
const lockScroll = readToggle(
|
|
140
|
+
block.properties?.lockScroll,
|
|
141
|
+
currentBreakpoint,
|
|
142
|
+
false
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Every pointer route out of the panel is a designer toggle, so a
|
|
146
|
+
// composition can legally turn all of them off and seal a shopper inside.
|
|
147
|
+
// When none survives, the close button comes back and Escape is re-armed no
|
|
148
|
+
// matter what the properties say.
|
|
149
|
+
const hasPointerDismissRoute = showClose || (showBackdrop && closeOnBackdrop);
|
|
150
|
+
const closeVisible = showClose || !hasPointerDismissRoute;
|
|
151
|
+
const escapeClosesPanel = closeOnEscape || !hasPointerDismissRoute;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Escape, the focus trap, the scroll lock, handing focus back to the icon
|
|
155
|
+
* and the `aria-modal` claim are the shared overlay primitive's.
|
|
156
|
+
*
|
|
157
|
+
* This panel had none of them: it was a bare `position: fixed` div with a
|
|
158
|
+
* close button, so a keyboard shopper who opened it had no way out and
|
|
159
|
+
* assistive tech was told nothing about it. A header composes several
|
|
160
|
+
* overlays — this, the hamburger drawer, a mini basket — and the primitive
|
|
161
|
+
* keeps one stack for the page, so the claim and the key belong to whichever
|
|
162
|
+
* overlay is actually on top.
|
|
163
|
+
*
|
|
164
|
+
* `modal` is `showBackdrop` because the panel only really covers the page
|
|
165
|
+
* once a backdrop is drawn; with the page behind still clickable the claim
|
|
166
|
+
* would be the opposite of what is true. `open` rather than the forced state
|
|
167
|
+
* is deliberate: nothing in the primitive may ever see the canvas's held-open
|
|
168
|
+
* panel as a live overlay.
|
|
169
|
+
*/
|
|
170
|
+
const overlay = useOverlay<HTMLDivElement>({
|
|
171
|
+
open,
|
|
172
|
+
onClose: () => setOpen(false),
|
|
173
|
+
isDesigner,
|
|
174
|
+
modal: showBackdrop,
|
|
175
|
+
lockScroll,
|
|
176
|
+
closeOnEscape: escapeClosesPanel,
|
|
177
|
+
// The block's own "Focus Field On Open", now honoured by the primitive —
|
|
178
|
+
// which also hands focus back to the icon on close, which it never did.
|
|
179
|
+
manageFocus: autoFocus,
|
|
180
|
+
returnFocusRef: triggerRef
|
|
181
|
+
});
|
|
97
182
|
|
|
98
183
|
const submit = (event: React.FormEvent) => {
|
|
99
184
|
event.preventDefault();
|
|
@@ -177,11 +262,67 @@ const SearchBoxBlock = ({
|
|
|
177
262
|
);
|
|
178
263
|
}
|
|
179
264
|
|
|
265
|
+
const childBlocks = (block.blocks || [])
|
|
266
|
+
.filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
|
|
267
|
+
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
268
|
+
|
|
269
|
+
const renderChildren = () =>
|
|
270
|
+
childBlocks.map((childBlock, index) => {
|
|
271
|
+
const createActionHandler = (actionType: string) => () => {
|
|
272
|
+
if (window.parent) {
|
|
273
|
+
window.parent.postMessage(
|
|
274
|
+
{
|
|
275
|
+
type: actionType,
|
|
276
|
+
data: { placeholderId, sectionId, blockId: childBlock.id }
|
|
277
|
+
},
|
|
278
|
+
'*'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const handleRename = (newLabel: string) => {
|
|
284
|
+
if (window.parent) {
|
|
285
|
+
window.parent.postMessage(
|
|
286
|
+
{
|
|
287
|
+
type: 'RENAME_BLOCK',
|
|
288
|
+
data: {
|
|
289
|
+
placeholderId,
|
|
290
|
+
sectionId,
|
|
291
|
+
blockId: childBlock.id,
|
|
292
|
+
label: newLabel
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
'*'
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<ThemeBlock
|
|
302
|
+
key={childBlock.id || `search-box-block-${index}`}
|
|
303
|
+
block={childBlock}
|
|
304
|
+
placeholderId={placeholderId}
|
|
305
|
+
sectionId={sectionId}
|
|
306
|
+
isDesigner={isDesigner}
|
|
307
|
+
isSelected={selectedBlockId === childBlock.id}
|
|
308
|
+
selectedBlockId={selectedBlockId}
|
|
309
|
+
currentBreakpoint={currentBreakpoint}
|
|
310
|
+
onMoveUp={createActionHandler('MOVE_BLOCK_UP')}
|
|
311
|
+
onMoveDown={createActionHandler('MOVE_BLOCK_DOWN')}
|
|
312
|
+
onDuplicate={createActionHandler('DUPLICATE_BLOCK')}
|
|
313
|
+
onToggleVisibility={createActionHandler('TOGGLE_BLOCK_VISIBILITY')}
|
|
314
|
+
onDelete={createActionHandler('DELETE_BLOCK')}
|
|
315
|
+
onRename={handleRename}
|
|
316
|
+
/>
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
180
320
|
return (
|
|
181
321
|
<>
|
|
182
322
|
<button
|
|
183
323
|
type="button"
|
|
184
324
|
data-search-box=""
|
|
325
|
+
ref={triggerRef}
|
|
185
326
|
{...partAttrs('trigger', { open: panelOpen })}
|
|
186
327
|
aria-label={triggerLabel || placeholder}
|
|
187
328
|
aria-expanded={panelOpen}
|
|
@@ -203,84 +344,141 @@ const SearchBoxBlock = ({
|
|
|
203
344
|
</button>
|
|
204
345
|
|
|
205
346
|
{panelOpen && (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
padding: '20px 24px'
|
|
219
|
-
}}
|
|
220
|
-
>
|
|
221
|
-
<form
|
|
222
|
-
onSubmit={submit}
|
|
223
|
-
data-search-form=""
|
|
224
|
-
{...partAttrs('form')}
|
|
225
|
-
style={{
|
|
226
|
-
display: 'flex',
|
|
227
|
-
alignItems: 'center',
|
|
228
|
-
gap: '12px',
|
|
229
|
-
maxWidth: `${formMaxWidth}px`,
|
|
230
|
-
margin: '0 auto'
|
|
231
|
-
}}
|
|
232
|
-
>
|
|
233
|
-
{showIcon && icon}
|
|
234
|
-
<input
|
|
235
|
-
ref={inputRef}
|
|
236
|
-
type="search"
|
|
237
|
-
value={term}
|
|
238
|
-
onChange={(e) => setTerm(e.target.value)}
|
|
239
|
-
placeholder={placeholder}
|
|
240
|
-
aria-label={placeholder}
|
|
241
|
-
data-search-input=""
|
|
242
|
-
{...partAttrs('input')}
|
|
243
|
-
style={{
|
|
244
|
-
flex: 1,
|
|
245
|
-
border: 'none',
|
|
246
|
-
borderBottom: '1px solid #d1d5db',
|
|
247
|
-
background: 'transparent',
|
|
248
|
-
fontSize: '18px',
|
|
249
|
-
padding: '8px 0',
|
|
250
|
-
outline: 'none'
|
|
251
|
-
}}
|
|
252
|
-
/>
|
|
253
|
-
{submitButton}
|
|
254
|
-
<button
|
|
255
|
-
type="button"
|
|
256
|
-
aria-label={closeLabel}
|
|
257
|
-
data-search-close=""
|
|
258
|
-
{...partAttrs('close')}
|
|
347
|
+
<>
|
|
348
|
+
{/*
|
|
349
|
+
A sibling of the panel rather than a wrapper around it: the panel's
|
|
350
|
+
own `position` and `z-index` are controls a brand already styles
|
|
351
|
+
against, and hoisting it into a fixed root would take both away.
|
|
352
|
+
Held open in the canvas the backdrop is inert, so the author can
|
|
353
|
+
click the scrim they are colouring without it vanishing.
|
|
354
|
+
*/}
|
|
355
|
+
{showBackdrop && (
|
|
356
|
+
<div
|
|
357
|
+
data-search-backdrop=""
|
|
358
|
+
{...partAttrs('backdrop', { open: true })}
|
|
259
359
|
onClick={() => {
|
|
260
360
|
if (forcedOpen) return;
|
|
261
|
-
setOpen(false);
|
|
361
|
+
if (closeOnBackdrop) setOpen(false);
|
|
262
362
|
}}
|
|
263
363
|
style={{
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
364
|
+
position: 'fixed',
|
|
365
|
+
inset: 0,
|
|
366
|
+
zIndex: 999,
|
|
367
|
+
background: 'rgba(0,0,0,0.5)'
|
|
368
|
+
}}
|
|
369
|
+
/>
|
|
370
|
+
)}
|
|
371
|
+
<div
|
|
372
|
+
data-search-overlay=""
|
|
373
|
+
{...partAttrs('panel', { open: true })}
|
|
374
|
+
role="dialog"
|
|
375
|
+
// Carries the panel ref and the `aria-modal` claim, made only
|
|
376
|
+
// while a backdrop really is hiding the page behind it.
|
|
377
|
+
{...overlay.overlayProps}
|
|
378
|
+
aria-label={triggerLabel || placeholder}
|
|
379
|
+
// Focus target of last resort, for a panel whose every control a
|
|
380
|
+
// composition turned off.
|
|
381
|
+
tabIndex={-1}
|
|
382
|
+
style={{
|
|
383
|
+
position: 'fixed',
|
|
384
|
+
top: 0,
|
|
385
|
+
left: 0,
|
|
386
|
+
right: 0,
|
|
387
|
+
zIndex: 1000,
|
|
388
|
+
background: '#ffffff',
|
|
389
|
+
color: '#111827',
|
|
390
|
+
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
|
391
|
+
padding: '20px 24px'
|
|
392
|
+
}}
|
|
393
|
+
>
|
|
394
|
+
<form
|
|
395
|
+
onSubmit={submit}
|
|
396
|
+
data-search-form=""
|
|
397
|
+
{...partAttrs('form')}
|
|
398
|
+
style={{
|
|
399
|
+
display: 'flex',
|
|
400
|
+
alignItems: 'center',
|
|
401
|
+
gap: '12px',
|
|
402
|
+
maxWidth: `${formMaxWidth}px`,
|
|
403
|
+
margin: '0 auto'
|
|
268
404
|
}}
|
|
269
405
|
>
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
406
|
+
{showIcon && icon}
|
|
407
|
+
<input
|
|
408
|
+
type="search"
|
|
409
|
+
value={term}
|
|
410
|
+
onChange={(e) => setTerm(e.target.value)}
|
|
411
|
+
placeholder={placeholder}
|
|
412
|
+
aria-label={placeholder}
|
|
413
|
+
data-search-input=""
|
|
414
|
+
{...partAttrs('input')}
|
|
415
|
+
style={{
|
|
416
|
+
flex: 1,
|
|
417
|
+
border: 'none',
|
|
418
|
+
borderBottom: '1px solid #d1d5db',
|
|
419
|
+
background: 'transparent',
|
|
420
|
+
fontSize: '18px',
|
|
421
|
+
padding: '8px 0',
|
|
422
|
+
outline: 'none'
|
|
423
|
+
}}
|
|
424
|
+
/>
|
|
425
|
+
{submitButton}
|
|
426
|
+
{closeVisible && (
|
|
427
|
+
<button
|
|
428
|
+
type="button"
|
|
429
|
+
aria-label={closeLabel}
|
|
430
|
+
data-search-close=""
|
|
431
|
+
{...partAttrs('close')}
|
|
432
|
+
onClick={() => {
|
|
433
|
+
if (forcedOpen) return;
|
|
434
|
+
setOpen(false);
|
|
435
|
+
}}
|
|
436
|
+
style={{
|
|
437
|
+
background: 'none',
|
|
438
|
+
border: 'none',
|
|
439
|
+
cursor: 'pointer',
|
|
440
|
+
padding: '4px'
|
|
441
|
+
}}
|
|
442
|
+
>
|
|
443
|
+
<svg
|
|
444
|
+
width="20"
|
|
445
|
+
height="20"
|
|
446
|
+
viewBox="0 0 24 24"
|
|
447
|
+
fill="none"
|
|
448
|
+
stroke="currentColor"
|
|
449
|
+
strokeWidth="1.8"
|
|
450
|
+
strokeLinecap="round"
|
|
451
|
+
>
|
|
452
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
453
|
+
</svg>
|
|
454
|
+
</button>
|
|
455
|
+
)}
|
|
456
|
+
</form>
|
|
457
|
+
|
|
458
|
+
{/*
|
|
459
|
+
Whatever the author dropped into the block. Gated so that a
|
|
460
|
+
published composition — none of which could have children, since
|
|
461
|
+
the renderer ignored them — grows no empty wrapper, while the
|
|
462
|
+
canvas always offers somewhere to drop the first one.
|
|
463
|
+
*/}
|
|
464
|
+
{(childBlocks.length > 0 || isDesigner) && (
|
|
465
|
+
<div
|
|
466
|
+
data-search-body=""
|
|
467
|
+
{...partAttrs('body')}
|
|
468
|
+
style={{
|
|
469
|
+
maxWidth: `${formMaxWidth}px`,
|
|
470
|
+
margin: '0 auto'
|
|
471
|
+
}}
|
|
278
472
|
>
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
473
|
+
{childBlocks.length > 0 ? (
|
|
474
|
+
renderChildren()
|
|
475
|
+
) : (
|
|
476
|
+
<DesignerPlaceholder label="Search panel (drop suggestions, links or promos here)" />
|
|
477
|
+
)}
|
|
478
|
+
</div>
|
|
479
|
+
)}
|
|
480
|
+
</div>
|
|
481
|
+
</>
|
|
284
482
|
)}
|
|
285
483
|
</>
|
|
286
484
|
);
|