@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.
- package/CHANGELOG.md +15 -31
- package/package.json +4 -4
- package/src/__tests__/registry-parity.test.ts +1 -0
- package/src/blocks/__tests__/force-open.test.tsx +92 -0
- package/src/blocks/__tests__/overlay-sweep.test.tsx +39 -3
- package/src/blocks/__tests__/panel-block.test.tsx +329 -0
- package/src/blocks/__tests__/panel-legacy-golden.json +7 -0
- package/src/blocks/block-renderer-registry.tsx +5 -2
- package/src/blocks/panel-block.tsx +560 -0
- package/src/blocks/size-guide-block.tsx +0 -313
|
@@ -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;
|