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

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.48-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea239]
8
- - Updated dependencies [324f97d5]
9
- - Updated dependencies [51ea0688]
10
- - Updated dependencies
11
- - Updated dependencies [2fea414]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1c]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08f]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f49]
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 [e9598c7]
27
- - Updated dependencies [4998a963]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b20]
32
- - @akinon/next@2.0.48-rc.0
33
- - @akinon/theme-contract@2.0.48-rc.0
3
+ ## 2.0.48
4
+
5
+ ### Patch Changes
6
+
7
+ - 1763ea1: ZERO-4741: Render authored children in the search overlay and route it through the shared overlay primitive
8
+ - @akinon/next@2.0.48
9
+ - @akinon/theme-contract@2.0.48
34
10
 
35
11
  ## 2.0.47
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.48-rc.0",
4
+ "version": "2.0.48",
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.48-rc.0",
11
+ "@akinon/next": "2.0.48",
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.48-rc.0",
16
+ "@akinon/theme-contract": "2.0.48",
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.48-rc.0",
23
+ "@akinon/next": "2.0.48",
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 = (isDesigner: boolean, styles: Record<string, unknown>) =>
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={mkBlock('search-box', {}, styles)}
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="Size guide (drop the size table blocks here)" />
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, { useEffect, useRef, useState } from '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
- * Parts: trigger, icon, form, input, submit, panel, close.
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 inputRef = useRef<HTMLInputElement>(null);
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
- useEffect(() => {
95
- if (open && autoFocus) inputRef.current?.focus();
96
- }, [open, autoFocus]);
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
- <div
207
- data-search-overlay=""
208
- {...partAttrs('panel', { open: true })}
209
- style={{
210
- position: 'fixed',
211
- top: 0,
212
- left: 0,
213
- right: 0,
214
- zIndex: 1000,
215
- background: '#ffffff',
216
- color: '#111827',
217
- boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
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
- background: 'none',
265
- border: 'none',
266
- cursor: 'pointer',
267
- padding: '4px'
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
- <svg
271
- width="20"
272
- height="20"
273
- viewBox="0 0 24 24"
274
- fill="none"
275
- stroke="currentColor"
276
- strokeWidth="1.8"
277
- strokeLinecap="round"
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
- <path d="M6 6l12 12M18 6L6 18" />
280
- </svg>
281
- </button>
282
- </form>
283
- </div>
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
  );