@akinon/pz-theme 2.0.47-rc.0 → 2.0.48-beta.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.
@@ -0,0 +1,560 @@
1
+ 'use client';
2
+
3
+ import React, { useRef, useState } from 'react';
4
+ import {
5
+ getResponsiveValue,
6
+ partAttrs,
7
+ resolveForceOpen,
8
+ resolveOption
9
+ } from '../utils';
10
+ import { useOverlay } from '../hooks/use-overlay';
11
+ import ThemeBlock from '../theme-block';
12
+ import { BlockRendererProps } from './block-renderer-registry';
13
+ import { DesignerPlaceholder, readToggle } from './product-block-utils';
14
+
15
+ const clampNumber = (
16
+ value: unknown,
17
+ fallback: number,
18
+ min: number,
19
+ max: number
20
+ ) => {
21
+ const numeric = Math.round(Number(value));
22
+ if (!Number.isFinite(numeric)) return fallback;
23
+ return Math.min(max, Math.max(min, numeric));
24
+ };
25
+
26
+ type Presentation = 'modal' | 'drawer' | 'sheet' | 'anchored';
27
+
28
+ const PRESENTATIONS: Presentation[] = ['modal', 'drawer', 'sheet', 'anchored'];
29
+
30
+ /**
31
+ * Generic overlay surface: a trigger that opens a panel the author fills.
32
+ *
33
+ * The panel's contents are authored in the editor as CHILD blocks, so nothing
34
+ * about them is hardcoded here — this block owns the trigger, the surface and
35
+ * how that surface arrives (centred modal, edge drawer, bottom sheet, or a
36
+ * dropdown anchored to the trigger, chosen per breakpoint). In the designer
37
+ * the children render inline and stay selectable — a real overlay would put
38
+ * them behind a click the author cannot perform inside the preview iframe —
39
+ * unless the author holds the panel open with the force-open toggle, which is
40
+ * what makes the backdrop dressable.
41
+ *
42
+ * `size-guide` is this block's legacy type id: published widget JSON and the
43
+ * AI generator manifest still carry it, both types register this renderer, and
44
+ * the `modal` presentation is byte for byte the markup that shipped under that
45
+ * name (see __tests__/panel-legacy-golden.json).
46
+ *
47
+ * Properties: presentation, triggerLabel, modalTitle, closeLabel, showClose,
48
+ * showBackdrop, closeOnOverlay, closeOnEscape, modalWidth, panelSide,
49
+ * sheetHeight, anchorAlign, anchorOffset.
50
+ * Parts: trigger, overlay, modal, title, close, handle.
51
+ */
52
+ const PanelBlock = ({
53
+ block,
54
+ placeholderId,
55
+ sectionId,
56
+ isDesigner = false,
57
+ selectedBlockId,
58
+ currentBreakpoint = 'desktop'
59
+ }: BlockRendererProps) => {
60
+ const [isOpen, setIsOpen] = useState(false);
61
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
62
+
63
+ // Designer-only: hold the panel mounted so its open state — backdrop
64
+ // included — can be styled. Every close route stays inert.
65
+ const forcedOpen =
66
+ isDesigner && resolveForceOpen(block.styles, currentBreakpoint);
67
+
68
+ const prop = (key: string, fallback: string) =>
69
+ String(
70
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
71
+ );
72
+
73
+ const presentation = resolveOption<Presentation>(
74
+ block.properties?.presentation,
75
+ currentBreakpoint,
76
+ 'modal',
77
+ PRESENTATIONS
78
+ );
79
+ const isModal = presentation === 'modal';
80
+
81
+ const triggerLabel = prop('triggerLabel', 'Size guide');
82
+ const modalTitle = prop('modalTitle', '');
83
+ const closeLabel = prop('closeLabel', 'Close');
84
+ const showClose = readToggle(
85
+ block.properties?.showClose,
86
+ currentBreakpoint,
87
+ true
88
+ );
89
+ // Defaults on, because every composition published before this property
90
+ // existed drew a backdrop unconditionally.
91
+ const showBackdrop = readToggle(
92
+ block.properties?.showBackdrop,
93
+ currentBreakpoint,
94
+ true
95
+ );
96
+ const closeOnOverlay = readToggle(
97
+ block.properties?.closeOnOverlay,
98
+ currentBreakpoint,
99
+ true
100
+ );
101
+ const closeOnEscape = readToggle(
102
+ block.properties?.closeOnEscape,
103
+ currentBreakpoint,
104
+ true
105
+ );
106
+ const modalWidthValue = Number(
107
+ getResponsiveValue(block.properties?.modalWidth, currentBreakpoint, 640)
108
+ );
109
+ const modalWidth =
110
+ Number.isFinite(modalWidthValue) && modalWidthValue > 0
111
+ ? modalWidthValue
112
+ : 640;
113
+ const panelSide = String(
114
+ getResponsiveValue(block.properties?.panelSide, currentBreakpoint, 'right')
115
+ );
116
+ const sheetHeight = clampNumber(
117
+ getResponsiveValue(block.properties?.sheetHeight, currentBreakpoint, 60),
118
+ 60,
119
+ 20,
120
+ 100
121
+ );
122
+ const anchorAlign = resolveOption<'start' | 'center' | 'end'>(
123
+ block.properties?.anchorAlign,
124
+ currentBreakpoint,
125
+ 'start',
126
+ ['start', 'center', 'end']
127
+ );
128
+ const anchorOffset = clampNumber(
129
+ getResponsiveValue(block.properties?.anchorOffset, currentBreakpoint, 8),
130
+ 8,
131
+ 0,
132
+ 48
133
+ );
134
+
135
+ /**
136
+ * Escape, the focus trap, returning focus to the trigger and the
137
+ * `aria-modal` claim are the shared overlay primitive's, not this block's.
138
+ *
139
+ * This modal hard-coded `aria-modal="true"` and answered Escape from its own
140
+ * document listener. A PDP composes several overlays — a size picker, a
141
+ * store panel, a quick view — and two of them open at once meant two dialogs
142
+ * each telling assistive tech the other was hidden, and one Escape closing
143
+ * both. The primitive keeps one stack for the page, so the claim and the key
144
+ * belong to whichever overlay is actually on top.
145
+ *
146
+ * The centred modal really does cover the page behind its backdrop, so
147
+ * `modal` is unconditionally true there. The presentations added later draw
148
+ * their backdrop only when the author asked for one, and with the page
149
+ * behind still clickable the claim would be a lie — so they make it only
150
+ * behind a backdrop, exactly as the filter drawer does. `lockScroll` is left
151
+ * to default to `modal` for the same reason: a panel that does not cover the
152
+ * page must not freeze it.
153
+ *
154
+ * `isDesigner` keeps the whole primitive inert, which is what lets the
155
+ * forced-open branch below render an overlay without stealing focus,
156
+ * freezing the canvas or answering Escape.
157
+ */
158
+ const overlay = useOverlay<HTMLDivElement>({
159
+ open: isOpen,
160
+ onClose: () => setIsOpen(false),
161
+ isDesigner,
162
+ modal: isModal ? true : showBackdrop,
163
+ closeOnEscape,
164
+ returnFocusRef: triggerRef
165
+ });
166
+
167
+ const childBlocks = (block.blocks || [])
168
+ .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
169
+ .sort((a, b) => (a.order || 0) - (b.order || 0));
170
+
171
+ const renderChildren = () =>
172
+ childBlocks.map((childBlock, index) => {
173
+ const createActionHandler = (actionType: string) => () => {
174
+ if (window.parent) {
175
+ window.parent.postMessage(
176
+ {
177
+ type: actionType,
178
+ data: { placeholderId, sectionId, blockId: childBlock.id }
179
+ },
180
+ '*'
181
+ );
182
+ }
183
+ };
184
+
185
+ const handleRename = (newLabel: string) => {
186
+ if (window.parent) {
187
+ window.parent.postMessage(
188
+ {
189
+ type: 'RENAME_BLOCK',
190
+ data: {
191
+ placeholderId,
192
+ sectionId,
193
+ blockId: childBlock.id,
194
+ label: newLabel
195
+ }
196
+ },
197
+ '*'
198
+ );
199
+ }
200
+ };
201
+
202
+ return (
203
+ <ThemeBlock
204
+ key={childBlock.id || `size-guide-block-${index}`}
205
+ block={childBlock}
206
+ placeholderId={placeholderId}
207
+ sectionId={sectionId}
208
+ isDesigner={isDesigner}
209
+ isSelected={selectedBlockId === childBlock.id}
210
+ selectedBlockId={selectedBlockId}
211
+ currentBreakpoint={currentBreakpoint}
212
+ onMoveUp={createActionHandler('MOVE_BLOCK_UP')}
213
+ onMoveDown={createActionHandler('MOVE_BLOCK_DOWN')}
214
+ onDuplicate={createActionHandler('DUPLICATE_BLOCK')}
215
+ onToggleVisibility={createActionHandler('TOGGLE_BLOCK_VISIBILITY')}
216
+ onDelete={createActionHandler('DELETE_BLOCK')}
217
+ onRename={handleRename}
218
+ />
219
+ );
220
+ });
221
+
222
+ // A trigger that opens an empty panel is a dead control, so on the storefront
223
+ // the block disappears until the author has put something inside it.
224
+ if (childBlocks.length === 0 && !isDesigner) return null;
225
+
226
+ // Held open in the canvas, every dismissal is a no-op: the author has to be
227
+ // able to click the backdrop they are colouring without it vanishing.
228
+ const dismiss = () => {
229
+ if (forcedOpen) return;
230
+ setIsOpen(false);
231
+ };
232
+
233
+ const opened = isOpen || forcedOpen;
234
+
235
+ const trigger = (
236
+ <button
237
+ type="button"
238
+ data-size-guide-trigger=""
239
+ ref={triggerRef}
240
+ {...partAttrs('trigger', { open: opened })}
241
+ aria-haspopup="dialog"
242
+ aria-expanded={opened}
243
+ onClick={() => {
244
+ // The designer already shows the content inline, and opening a fixed
245
+ // overlay there would cover the canvas the author is working in.
246
+ if (isDesigner) return;
247
+ setIsOpen(true);
248
+ }}
249
+ style={{
250
+ display: 'inline-flex',
251
+ alignItems: 'center',
252
+ gap: '6px',
253
+ padding: 0,
254
+ margin: 0,
255
+ border: 'none',
256
+ background: 'transparent',
257
+ color: 'inherit',
258
+ font: 'inherit',
259
+ textDecoration: 'underline',
260
+ cursor: 'pointer'
261
+ }}
262
+ >
263
+ {triggerLabel}
264
+ </button>
265
+ );
266
+
267
+ const titleNode = modalTitle ? (
268
+ <div
269
+ data-size-guide-title=""
270
+ {...partAttrs('title')}
271
+ style={{ marginBottom: '12px', fontWeight: 600 }}
272
+ >
273
+ {modalTitle}
274
+ </div>
275
+ ) : null;
276
+
277
+ if (isDesigner && !forcedOpen) {
278
+ return (
279
+ <div data-size-guide="" data-size-guide-state="inline">
280
+ {trigger}
281
+ <div
282
+ data-size-guide-content=""
283
+ data-size-guide-mode="inline"
284
+ // The same part as the storefront panel: the author has to see the
285
+ // panel styling they just typed, and the panel never opens here.
286
+ {...partAttrs('modal')}
287
+ style={{
288
+ marginTop: '8px',
289
+ padding: '12px',
290
+ border: '1px dashed #cbd5e1',
291
+ borderRadius: '4px'
292
+ }}
293
+ >
294
+ {titleNode}
295
+ {childBlocks.length > 0 ? (
296
+ renderChildren()
297
+ ) : (
298
+ <DesignerPlaceholder label="Size guide (drop the size table blocks here)" />
299
+ )}
300
+ </div>
301
+ </div>
302
+ );
303
+ }
304
+
305
+ const closeButton = showClose ? (
306
+ <button
307
+ type="button"
308
+ data-size-guide-close=""
309
+ {...partAttrs('close')}
310
+ aria-label={closeLabel}
311
+ onClick={dismiss}
312
+ style={{
313
+ position: 'absolute',
314
+ top: '12px',
315
+ right: '12px',
316
+ border: 'none',
317
+ background: 'transparent',
318
+ color: 'inherit',
319
+ cursor: 'pointer',
320
+ padding: 0
321
+ }}
322
+ >
323
+ <svg
324
+ width="18"
325
+ height="18"
326
+ viewBox="0 0 24 24"
327
+ fill="none"
328
+ stroke="currentColor"
329
+ strokeWidth="1.6"
330
+ strokeLinecap="round"
331
+ >
332
+ <path d="M6 6l12 12M18 6L6 18" />
333
+ </svg>
334
+ </button>
335
+ ) : null;
336
+
337
+ /**
338
+ * The centred modal keeps the shape it published with: ONE node that is both
339
+ * the fixed root and the backdrop, carrying `data-part="overlay"`, with the
340
+ * panel centred inside it by flex. Refactoring it into the root + backdrop
341
+ * sibling pair the other presentations use would move markup live
342
+ * storefronts already style, for no gain — so the new modes get their own
343
+ * branch instead. `panel-legacy-golden.json` is what holds this to it.
344
+ */
345
+ const modalOverlay = (
346
+ <div
347
+ data-size-guide-overlay=""
348
+ {...partAttrs('overlay', { open: true })}
349
+ onClick={() => {
350
+ if (closeOnOverlay) dismiss();
351
+ }}
352
+ style={{
353
+ position: 'fixed',
354
+ inset: 0,
355
+ zIndex: 1000,
356
+ display: 'flex',
357
+ alignItems: 'center',
358
+ justifyContent: 'center',
359
+ padding: '16px',
360
+ background: 'rgba(0, 0, 0, 0.5)'
361
+ }}
362
+ >
363
+ <div
364
+ data-size-guide-content=""
365
+ data-size-guide-mode="modal"
366
+ {...partAttrs('modal', { open: true })}
367
+ role="dialog"
368
+ // Carries the panel ref and the `aria-modal` claim, which is now
369
+ // made only while this really is the dialog on top. Spread after
370
+ // `role` so the attribute order stays exactly where it was.
371
+ //
372
+ // The primitive asks a panel to carry `tabIndex={-1}` as its focus
373
+ // target of last resort; this one cannot be given it without
374
+ // moving markup a published storefront already renders. The gap
375
+ // only shows in a composition with the close button off AND no
376
+ // focusable child, where focus simply stays put — the same place
377
+ // it stayed before the migration.
378
+ {...overlay.overlayProps}
379
+ aria-label={modalTitle || triggerLabel}
380
+ // The backdrop closes the modal; clicks on the panel itself must
381
+ // not bubble up into that handler.
382
+ onClick={(event) => event.stopPropagation()}
383
+ style={{
384
+ position: 'relative',
385
+ width: '100%',
386
+ maxWidth: `${modalWidth}px`,
387
+ maxHeight: '80vh',
388
+ overflow: 'auto',
389
+ padding: '24px',
390
+ borderRadius: '4px',
391
+ background: '#ffffff',
392
+ color: '#111827',
393
+ textAlign: 'left'
394
+ }}
395
+ >
396
+ {closeButton}
397
+ {titleNode}
398
+ {renderChildren()}
399
+ </div>
400
+ </div>
401
+ );
402
+
403
+ const backdrop = showBackdrop ? (
404
+ <div
405
+ data-panel-backdrop=""
406
+ {...partAttrs('overlay', { open: true })}
407
+ onClick={() => {
408
+ if (closeOnOverlay) dismiss();
409
+ }}
410
+ style={{
411
+ position: 'absolute',
412
+ inset: 0,
413
+ background: 'rgba(0, 0, 0, 0.5)'
414
+ }}
415
+ />
416
+ ) : null;
417
+
418
+ const panelSurface: React.CSSProperties = {
419
+ overflow: 'auto',
420
+ padding: '24px',
421
+ background: '#ffffff',
422
+ color: '#111827',
423
+ textAlign: 'left',
424
+ outline: 'none'
425
+ };
426
+
427
+ const anchorPlacement: React.CSSProperties =
428
+ anchorAlign === 'center'
429
+ ? { left: '50%', transform: 'translateX(-50%)' }
430
+ : anchorAlign === 'end'
431
+ ? { right: 0 }
432
+ : { left: 0 };
433
+
434
+ const panelGeometry: React.CSSProperties =
435
+ presentation === 'drawer'
436
+ ? {
437
+ position: 'absolute',
438
+ top: 0,
439
+ bottom: 0,
440
+ [panelSide === 'left' ? 'left' : 'right']: 0,
441
+ width: `min(${modalWidth}px, 92vw)`,
442
+ boxShadow: '0 0 24px rgba(0, 0, 0, 0.2)'
443
+ }
444
+ : presentation === 'sheet'
445
+ ? {
446
+ position: 'absolute',
447
+ left: 0,
448
+ right: 0,
449
+ bottom: 0,
450
+ width: '100%',
451
+ maxHeight: `${sheetHeight}vh`,
452
+ boxShadow: '0 -12px 32px rgba(0, 0, 0, 0.18)'
453
+ }
454
+ : {
455
+ // Anchored hangs off the block root rather than a fixed layer, so it
456
+ // travels with the trigger the way a dropdown must.
457
+ position: 'absolute',
458
+ top: `calc(100% + ${anchorOffset}px)`,
459
+ zIndex: 1000,
460
+ width: `${modalWidth}px`,
461
+ maxWidth: '92vw',
462
+ maxHeight: '80vh',
463
+ borderRadius: '4px',
464
+ boxShadow: '0 12px 32px rgba(0, 0, 0, 0.18)',
465
+ ...anchorPlacement
466
+ };
467
+
468
+ const panel = (
469
+ <div
470
+ data-size-guide-content=""
471
+ data-panel-presentation={presentation}
472
+ {...partAttrs('modal', { open: true })}
473
+ role="dialog"
474
+ // Spread after `role` for the same attribute-order reason the modal
475
+ // branch documents.
476
+ {...overlay.overlayProps}
477
+ aria-label={modalTitle || triggerLabel}
478
+ // Focus target of last resort, for a panel whose every control the
479
+ // designer turned off. The centred modal deliberately does without it:
480
+ // adding it there would move published markup.
481
+ tabIndex={-1}
482
+ onClick={(event) => event.stopPropagation()}
483
+ style={{
484
+ ...panelSurface,
485
+ ...panelGeometry,
486
+ // Turn events back on for the panel itself when the see-through root
487
+ // below is letting them pass.
488
+ pointerEvents: showBackdrop ? undefined : 'auto'
489
+ }}
490
+ >
491
+ {presentation === 'sheet' && (
492
+ <div
493
+ data-panel-handle=""
494
+ {...partAttrs('handle')}
495
+ style={{
496
+ width: '40px',
497
+ height: '4px',
498
+ margin: '0 auto 12px',
499
+ borderRadius: '999px',
500
+ background: '#d1d5db'
501
+ }}
502
+ />
503
+ )}
504
+ {closeButton}
505
+ {titleNode}
506
+ {renderChildren()}
507
+ </div>
508
+ );
509
+
510
+ /**
511
+ * Drawer and sheet live on a fixed layer over the page; anchored lives in
512
+ * the block's own flow so it travels with the trigger. With the backdrop off
513
+ * that fixed layer would still be an invisible sheet of glass over the whole
514
+ * viewport, so events pass through it and the panel turns them back on for
515
+ * itself.
516
+ */
517
+ const layeredOverlay =
518
+ presentation === 'anchored' ? (
519
+ <>
520
+ {showBackdrop && (
521
+ <div
522
+ data-panel-backdrop-root=""
523
+ style={{ position: 'fixed', inset: 0, zIndex: 999 }}
524
+ >
525
+ {backdrop}
526
+ </div>
527
+ )}
528
+ {panel}
529
+ </>
530
+ ) : (
531
+ <div
532
+ data-panel-root=""
533
+ style={{
534
+ position: 'fixed',
535
+ inset: 0,
536
+ zIndex: 1000,
537
+ pointerEvents: showBackdrop ? undefined : 'none'
538
+ }}
539
+ >
540
+ {backdrop}
541
+ {panel}
542
+ </div>
543
+ );
544
+
545
+ return (
546
+ <div
547
+ data-size-guide=""
548
+ data-size-guide-state={opened ? 'open' : 'closed'}
549
+ // Only the anchored presentation needs a positioning context, and only
550
+ // it gets one — every other composition keeps the bare root it shipped.
551
+ style={presentation === 'anchored' ? { position: 'relative' } : undefined}
552
+ >
553
+ {trigger}
554
+
555
+ {opened && (isModal ? modalOverlay : layeredOverlay)}
556
+ </div>
557
+ );
558
+ };
559
+
560
+ export default PanelBlock;