@akinon/pz-theme 2.0.58-rc.0 → 2.0.58
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 -31
- package/package.json +4 -4
- package/src/blocks/button-block.tsx +23 -1
- package/src/blocks/link-block.tsx +21 -10
- package/src/blocks/panel-block.tsx +67 -237
- package/src/chrome/__tests__/theme-config-source.test.ts +53 -7
- package/src/chrome/placeholder-slugs.ts +41 -0
- package/src/chrome/theme-config-source.ts +17 -2
- package/src/components/with-designer-features.tsx +49 -2
- package/src/hooks/use-block-action.ts +209 -0
- package/src/index.ts +51 -1
- package/src/overlays/__tests__/modal-preview-host.test.tsx +316 -0
- package/src/overlays/__tests__/modal-registry.test.ts +205 -0
- package/src/overlays/__tests__/modal-triggers.test.tsx +272 -0
- package/src/overlays/__tests__/overlay-bus.test.tsx +197 -0
- package/src/overlays/__tests__/theme-modal-mount.test.tsx +234 -0
- package/src/overlays/modal-dom-bridge.ts +104 -0
- package/src/overlays/modal-frequency.ts +104 -0
- package/src/overlays/modal-host-beacon.tsx +35 -0
- package/src/overlays/modal-preview-host.tsx +159 -0
- package/src/overlays/modal-registry.ts +289 -0
- package/src/overlays/overlay-bus.ts +214 -0
- package/src/overlays/overlay-surface.tsx +386 -0
- package/src/overlays/theme-modal-mount.tsx +179 -0
- package/src/overlays/theme-modals.tsx +82 -0
- package/src/overlays/use-editor-modals.ts +109 -0
- package/src/overlays/use-modal-auto-trigger.ts +187 -0
- package/src/overlays/use-modal-preview.ts +127 -0
- package/src/overlays/use-overlay-breakpoint.ts +68 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,36 +1,16 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.58
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
- Updated dependencies [
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
- Updated dependencies [143be2b9d]
|
|
15
|
-
- Updated dependencies [7889b08fe]
|
|
16
|
-
- Updated dependencies [9f8cd3bc5]
|
|
17
|
-
- Updated dependencies [bfafa3f49]
|
|
18
|
-
- Updated dependencies [57d7eb305]
|
|
19
|
-
- Updated dependencies [d99a6a7d5]
|
|
20
|
-
- Updated dependencies [9db81a714]
|
|
21
|
-
- Updated dependencies [591e345e1]
|
|
22
|
-
- Updated dependencies [4de5303c5]
|
|
23
|
-
- Updated dependencies [95b139dc1]
|
|
24
|
-
- Updated dependencies [1d00f2d06]
|
|
25
|
-
- Updated dependencies [4ac7b2a1e]
|
|
26
|
-
- Updated dependencies [e9598c71]
|
|
27
|
-
- Updated dependencies [4998a9631]
|
|
28
|
-
- Updated dependencies [804d2bd6]
|
|
29
|
-
- Updated dependencies [3909d3224]
|
|
30
|
-
- Updated dependencies [6a3d8a63]
|
|
31
|
-
- Updated dependencies [e18836b20]
|
|
32
|
-
- @akinon/next@2.0.58-rc.0
|
|
33
|
-
- @akinon/theme-contract@2.0.58-rc.0
|
|
3
|
+
## 2.0.58
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e11783b: ZERO-4790: Draw the modal being composed in the theme editor canvas, from the editor's live entry rather than the saved one — so a modal that has never been saved is visible, and chrome edits show up without a save and a reload
|
|
8
|
+
- b992ca5: ZERO-4790: Read the theme editor's modal registry in the preview, so a trigger pointed at a modal that has not been saved yet actually opens it
|
|
9
|
+
- 08f060d: ZERO-4790: Add composable modals — a global modal host, a block action vocabulary for triggering them, and auto-open with frequency capping
|
|
10
|
+
- 7eb9396: ZERO-4790: Add the modal addressing foundation — modal slug family, overlay bus and theme_modals registry
|
|
11
|
+
- Updated dependencies [08f060d]
|
|
12
|
+
- @akinon/theme-contract@2.0.58
|
|
13
|
+
- @akinon/next@2.0.58
|
|
34
14
|
|
|
35
15
|
## 2.0.57
|
|
36
16
|
|
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.58
|
|
4
|
+
"version": "2.0.58",
|
|
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.58
|
|
11
|
+
"@akinon/next": "2.0.58",
|
|
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.58
|
|
16
|
+
"@akinon/theme-contract": "2.0.58",
|
|
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.58
|
|
23
|
+
"@akinon/next": "2.0.58",
|
|
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",
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getResponsiveValue,
|
|
10
10
|
resolveThemeCssVariables
|
|
11
11
|
} from '../utils';
|
|
12
|
+
import { useBlockAction } from '../hooks/use-block-action';
|
|
12
13
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
13
14
|
import { useThemeSettingsContext } from '../theme-settings-context';
|
|
14
15
|
|
|
@@ -18,6 +19,7 @@ const ButtonBlock = ({
|
|
|
18
19
|
isDesigner = false
|
|
19
20
|
}: BlockRendererProps) => {
|
|
20
21
|
const themeSettings = useThemeSettingsContext();
|
|
22
|
+
const action = useBlockAction(block, { currentBreakpoint, isDesigner });
|
|
21
23
|
const { locale } = useLocalization();
|
|
22
24
|
const [emailSubscription] = useEmailSubscriptionMutation();
|
|
23
25
|
const [feedbackModal, setFeedbackModal] = useState({
|
|
@@ -386,6 +388,20 @@ const ButtonBlock = ({
|
|
|
386
388
|
return;
|
|
387
389
|
}
|
|
388
390
|
|
|
391
|
+
/**
|
|
392
|
+
* An authored action wins over the href.
|
|
393
|
+
*
|
|
394
|
+
* A button that both opens a modal and navigates would race itself, so the
|
|
395
|
+
* action calls `preventDefault` and this returns rather than falling
|
|
396
|
+
* through into the newsletter submit path below. With no action authored
|
|
397
|
+
* the hook is inert and everything published before this existed behaves
|
|
398
|
+
* exactly as it did.
|
|
399
|
+
*/
|
|
400
|
+
if (action.isActive) {
|
|
401
|
+
action.onClick?.(e);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
389
405
|
if (tag === 'button' && buttonType === 'submit') {
|
|
390
406
|
e.preventDefault();
|
|
391
407
|
}
|
|
@@ -566,7 +582,12 @@ const ButtonBlock = ({
|
|
|
566
582
|
if (tag === 'button') {
|
|
567
583
|
return (
|
|
568
584
|
<>
|
|
569
|
-
<button
|
|
585
|
+
<button
|
|
586
|
+
type={buttonType}
|
|
587
|
+
style={finalStyles}
|
|
588
|
+
onClick={handleClick}
|
|
589
|
+
{...action.ariaProps}
|
|
590
|
+
>
|
|
570
591
|
{content}
|
|
571
592
|
</button>
|
|
572
593
|
{feedbackModalElement}
|
|
@@ -582,6 +603,7 @@ const ButtonBlock = ({
|
|
|
582
603
|
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
|
|
583
604
|
style={finalStyles}
|
|
584
605
|
onClick={handleClick}
|
|
606
|
+
{...action.ariaProps}
|
|
585
607
|
>
|
|
586
608
|
{content}
|
|
587
609
|
</a>
|
|
@@ -6,6 +6,7 @@ import { useLocalization } from '@akinon/next/hooks';
|
|
|
6
6
|
import { LocaleUrlStrategy } from '@akinon/next/localization';
|
|
7
7
|
import { urlLocaleMatcherRegex, urlSchemes } from '@akinon/next/utils';
|
|
8
8
|
import { getResponsiveValue } from '../utils';
|
|
9
|
+
import { useBlockAction } from '../hooks/use-block-action';
|
|
9
10
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
10
11
|
import ThemeBlock from '../theme-block';
|
|
11
12
|
|
|
@@ -20,6 +21,22 @@ const LinkBlock = ({
|
|
|
20
21
|
isDesigner = false
|
|
21
22
|
}: BlockRendererProps) => {
|
|
22
23
|
const { locale, defaultLocaleValue, localeUrlStrategy } = useLocalization();
|
|
24
|
+
const action = useBlockAction(block, { currentBreakpoint, isDesigner });
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* An authored action replaces the navigation rather than joining it: a link
|
|
28
|
+
* that both opens a modal and follows its href would race itself. With no
|
|
29
|
+
* action authored this is exactly the designer-only preventDefault the link
|
|
30
|
+
* always had.
|
|
31
|
+
*/
|
|
32
|
+
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
|
33
|
+
if (isDesigner) {
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (action.isActive) action.onClick?.(event);
|
|
39
|
+
};
|
|
23
40
|
|
|
24
41
|
const href = block.properties?.href
|
|
25
42
|
? String(getResponsiveValue(block.properties.href, currentBreakpoint, '#'))
|
|
@@ -136,11 +153,8 @@ const LinkBlock = ({
|
|
|
136
153
|
target={isDesigner ? undefined : target}
|
|
137
154
|
rel={isDesigner ? undefined : rel}
|
|
138
155
|
style={inlineStyles}
|
|
139
|
-
onClick={
|
|
140
|
-
|
|
141
|
-
e.preventDefault();
|
|
142
|
-
}
|
|
143
|
-
}}
|
|
156
|
+
onClick={handleClick}
|
|
157
|
+
{...action.ariaProps}
|
|
144
158
|
>
|
|
145
159
|
<div style={{ padding: '20px', color: '#6b7280' }}>
|
|
146
160
|
Empty link block
|
|
@@ -155,11 +169,8 @@ const LinkBlock = ({
|
|
|
155
169
|
target={isDesigner ? undefined : target}
|
|
156
170
|
rel={isDesigner ? undefined : rel}
|
|
157
171
|
style={inlineStyles}
|
|
158
|
-
onClick={
|
|
159
|
-
|
|
160
|
-
e.preventDefault();
|
|
161
|
-
}
|
|
162
|
-
}}
|
|
172
|
+
onClick={handleClick}
|
|
173
|
+
{...action.ariaProps}
|
|
163
174
|
>
|
|
164
175
|
{block.blocks
|
|
165
176
|
.filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
|
|
@@ -8,24 +8,21 @@ import {
|
|
|
8
8
|
resolveOption
|
|
9
9
|
} from '../utils';
|
|
10
10
|
import { useOverlay } from '../hooks/use-overlay';
|
|
11
|
+
import {
|
|
12
|
+
CentredModalSurface,
|
|
13
|
+
LayeredOverlaySurface,
|
|
14
|
+
OverlayCloseButton,
|
|
15
|
+
OverlayPresentation,
|
|
16
|
+
OVERLAY_PRESENTATIONS,
|
|
17
|
+
clampOverlayNumber as clampNumber
|
|
18
|
+
} from '../overlays/overlay-surface';
|
|
11
19
|
import ThemeBlock from '../theme-block';
|
|
12
20
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
13
21
|
import { DesignerPlaceholder, readToggle } from './product-block-utils';
|
|
14
22
|
|
|
15
|
-
|
|
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';
|
|
23
|
+
type Presentation = OverlayPresentation;
|
|
27
24
|
|
|
28
|
-
const PRESENTATIONS
|
|
25
|
+
const PRESENTATIONS = OVERLAY_PRESENTATIONS;
|
|
29
26
|
|
|
30
27
|
/**
|
|
31
28
|
* Generic overlay surface: a trigger that opens a panel the author fills.
|
|
@@ -303,245 +300,78 @@ const PanelBlock = ({
|
|
|
303
300
|
}
|
|
304
301
|
|
|
305
302
|
const closeButton = showClose ? (
|
|
306
|
-
<
|
|
307
|
-
|
|
308
|
-
data-size-guide-close=""
|
|
309
|
-
{...partAttrs('close')}
|
|
310
|
-
aria-label={closeLabel}
|
|
303
|
+
<OverlayCloseButton
|
|
304
|
+
label={closeLabel}
|
|
311
305
|
onClick={dismiss}
|
|
312
|
-
|
|
313
|
-
|
|
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>
|
|
306
|
+
markerAttrs={{ 'data-size-guide-close': '' }}
|
|
307
|
+
/>
|
|
335
308
|
) : null;
|
|
336
309
|
|
|
337
310
|
/**
|
|
338
|
-
*
|
|
339
|
-
* the
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
* branch instead. `panel-legacy-golden.json` is what holds this to it.
|
|
311
|
+
* Both branches come from `overlays/overlay-surface.tsx`, so this block and
|
|
312
|
+
* the composed modal host draw the same surface. The legacy
|
|
313
|
+
* `data-size-guide-*` / `data-panel-*` markers stay this block's own: live
|
|
314
|
+
* storefronts style them and `panel-legacy-golden.json` pins them byte for
|
|
315
|
+
* byte, which is also what proves the extraction moved nothing.
|
|
344
316
|
*/
|
|
317
|
+
const header = (
|
|
318
|
+
<>
|
|
319
|
+
{closeButton}
|
|
320
|
+
{titleNode}
|
|
321
|
+
</>
|
|
322
|
+
);
|
|
323
|
+
|
|
345
324
|
const modalOverlay = (
|
|
346
|
-
<
|
|
347
|
-
|
|
348
|
-
{
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
325
|
+
<CentredModalSurface
|
|
326
|
+
overlayProps={overlay.overlayProps}
|
|
327
|
+
ariaLabel={modalTitle || triggerLabel}
|
|
328
|
+
closeOnOverlay={closeOnOverlay}
|
|
329
|
+
onDismiss={dismiss}
|
|
330
|
+
width={modalWidth}
|
|
331
|
+
anchorAlign={anchorAlign}
|
|
332
|
+
anchorOffset={anchorOffset}
|
|
333
|
+
header={header}
|
|
334
|
+
markers={{
|
|
335
|
+
root: { 'data-size-guide-overlay': '' },
|
|
336
|
+
panel: {
|
|
337
|
+
'data-size-guide-content': '',
|
|
338
|
+
'data-size-guide-mode': 'modal'
|
|
339
|
+
}
|
|
361
340
|
}}
|
|
362
341
|
>
|
|
363
|
-
|
|
364
|
-
|
|
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>
|
|
342
|
+
{renderChildren()}
|
|
343
|
+
</CentredModalSurface>
|
|
401
344
|
);
|
|
402
345
|
|
|
403
|
-
const
|
|
404
|
-
<
|
|
405
|
-
|
|
406
|
-
{
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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'
|
|
346
|
+
const layeredOverlay = (
|
|
347
|
+
<LayeredOverlaySurface
|
|
348
|
+
presentation={presentation}
|
|
349
|
+
overlayProps={overlay.overlayProps}
|
|
350
|
+
ariaLabel={modalTitle || triggerLabel}
|
|
351
|
+
showBackdrop={showBackdrop}
|
|
352
|
+
closeOnOverlay={closeOnOverlay}
|
|
353
|
+
onDismiss={dismiss}
|
|
354
|
+
width={modalWidth}
|
|
355
|
+
panelSide={panelSide}
|
|
356
|
+
sheetHeight={sheetHeight}
|
|
357
|
+
anchorAlign={anchorAlign}
|
|
358
|
+
anchorOffset={anchorOffset}
|
|
359
|
+
header={header}
|
|
360
|
+
markers={{
|
|
361
|
+
root: { 'data-panel-root': '' },
|
|
362
|
+
backdrop: { 'data-panel-backdrop': '' },
|
|
363
|
+
backdropRoot: { 'data-panel-backdrop-root': '' },
|
|
364
|
+
panel: {
|
|
365
|
+
'data-size-guide-content': '',
|
|
366
|
+
'data-panel-presentation': presentation
|
|
367
|
+
},
|
|
368
|
+
handle: { 'data-panel-handle': '' }
|
|
489
369
|
}}
|
|
490
370
|
>
|
|
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
371
|
{renderChildren()}
|
|
507
|
-
</
|
|
372
|
+
</LayeredOverlaySurface>
|
|
508
373
|
);
|
|
509
374
|
|
|
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
375
|
return (
|
|
546
376
|
<div
|
|
547
377
|
data-size-guide=""
|
|
@@ -24,7 +24,8 @@ describe('readThemeConfig', () => {
|
|
|
24
24
|
})
|
|
25
25
|
).toEqual({
|
|
26
26
|
settings: { mainMenuSticky: true },
|
|
27
|
-
dataSources: [{ id: 'ds-1' }]
|
|
27
|
+
dataSources: [{ id: 'ds-1' }],
|
|
28
|
+
modals: []
|
|
28
29
|
});
|
|
29
30
|
});
|
|
30
31
|
|
|
@@ -38,7 +39,8 @@ describe('readThemeConfig', () => {
|
|
|
38
39
|
})
|
|
39
40
|
).toEqual({
|
|
40
41
|
settings: { mainMenuSticky: true },
|
|
41
|
-
dataSources: [{ id: 'ds-1' }]
|
|
42
|
+
dataSources: [{ id: 'ds-1' }],
|
|
43
|
+
modals: []
|
|
42
44
|
});
|
|
43
45
|
});
|
|
44
46
|
|
|
@@ -52,7 +54,45 @@ describe('readThemeConfig', () => {
|
|
|
52
54
|
})
|
|
53
55
|
).toEqual({
|
|
54
56
|
settings: { mainMenuSticky: false },
|
|
55
|
-
dataSources: [{ id: 'ds-1' }]
|
|
57
|
+
dataSources: [{ id: 'ds-1' }],
|
|
58
|
+
modals: []
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('reads the modal registry alongside the rest', () => {
|
|
63
|
+
expect(
|
|
64
|
+
readThemeConfig({
|
|
65
|
+
attributes: {
|
|
66
|
+
theme_modals: JSON.stringify([
|
|
67
|
+
{ id: 'newsletter', name: 'Newsletter', presentation: 'drawer' }
|
|
68
|
+
])
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
).toEqual({
|
|
72
|
+
settings: null,
|
|
73
|
+
dataSources: [],
|
|
74
|
+
modals: [
|
|
75
|
+
{
|
|
76
|
+
id: 'newsletter',
|
|
77
|
+
name: 'Newsletter',
|
|
78
|
+
slug: 'modal-newsletter',
|
|
79
|
+
presentation: 'drawer',
|
|
80
|
+
chrome: {
|
|
81
|
+
ariaLabel: 'Newsletter',
|
|
82
|
+
showClose: true,
|
|
83
|
+
closeLabel: 'Close',
|
|
84
|
+
showBackdrop: true,
|
|
85
|
+
closeOnBackdrop: true,
|
|
86
|
+
closeOnEscape: true,
|
|
87
|
+
width: 640,
|
|
88
|
+
panelSide: 'right',
|
|
89
|
+
sheetHeight: 60,
|
|
90
|
+
lockScroll: true
|
|
91
|
+
},
|
|
92
|
+
autoTrigger: undefined,
|
|
93
|
+
visibilityRules: undefined
|
|
94
|
+
}
|
|
95
|
+
]
|
|
56
96
|
});
|
|
57
97
|
});
|
|
58
98
|
|
|
@@ -66,16 +106,21 @@ describe('readThemeConfig', () => {
|
|
|
66
106
|
theme_dataSources: JSON.stringify([{ id: 'ds-1' }])
|
|
67
107
|
}
|
|
68
108
|
})
|
|
69
|
-
).toEqual({ settings: null, dataSources: [{ id: 'ds-1' }] });
|
|
109
|
+
).toEqual({ settings: null, dataSources: [{ id: 'ds-1' }], modals: [] });
|
|
70
110
|
|
|
71
111
|
error.mockRestore();
|
|
72
112
|
});
|
|
73
113
|
|
|
74
114
|
it('answers empty for a brand with no theme at all', () => {
|
|
75
|
-
expect(readThemeConfig(null)).toEqual({
|
|
115
|
+
expect(readThemeConfig(null)).toEqual({
|
|
116
|
+
settings: null,
|
|
117
|
+
dataSources: [],
|
|
118
|
+
modals: []
|
|
119
|
+
});
|
|
76
120
|
expect(readThemeConfig({ attributes: {} })).toEqual({
|
|
77
121
|
settings: null,
|
|
78
|
-
dataSources: []
|
|
122
|
+
dataSources: [],
|
|
123
|
+
modals: []
|
|
79
124
|
});
|
|
80
125
|
});
|
|
81
126
|
});
|
|
@@ -90,7 +135,8 @@ describe('getThemeConfig', () => {
|
|
|
90
135
|
|
|
91
136
|
await expect(getThemeConfig()).resolves.toEqual({
|
|
92
137
|
settings: null,
|
|
93
|
-
dataSources: []
|
|
138
|
+
dataSources: [],
|
|
139
|
+
modals: []
|
|
94
140
|
});
|
|
95
141
|
});
|
|
96
142
|
});
|
|
@@ -79,6 +79,47 @@ export type PlaceholderSlug =
|
|
|
79
79
|
/** Custom pages created from the editor resolve to `page-<slug>` */
|
|
80
80
|
export const customPageSlug = (slug: string) => `page-${slug}`;
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Modals created from the editor resolve to `modal-<id>`.
|
|
84
|
+
*
|
|
85
|
+
* A modal is the one composable surface with no route behind it: the designer
|
|
86
|
+
* creates it, names it, and opens it from anywhere. So its slug is derived from
|
|
87
|
+
* a GENERATED id rather than from the name — renaming "Newsletter" to "Sign up"
|
|
88
|
+
* must not orphan the composition saved under it — and never from a path,
|
|
89
|
+
* because a modal has none.
|
|
90
|
+
*/
|
|
91
|
+
export const MODAL_SLUG_PREFIX = 'modal-';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Ids this slug family accepts. Deliberately a REFUSAL rather than the
|
|
95
|
+
* sanitising the path-derived helper above does.
|
|
96
|
+
*
|
|
97
|
+
* Sanitising maps several inputs onto one output, and for a content key that is
|
|
98
|
+
* not a tidy-up, it is a collision: two modals whose ids differ only by a
|
|
99
|
+
* character the cleaner strips would share one slug and silently overwrite each
|
|
100
|
+
* other's composition — the exact failure the table at the top of this file
|
|
101
|
+
* exists to prevent. The ids are ours (the editor generates them), so anything
|
|
102
|
+
* failing this pattern is a bug upstream and is better refused loudly than
|
|
103
|
+
* rewritten quietly.
|
|
104
|
+
*/
|
|
105
|
+
const MODAL_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
106
|
+
|
|
107
|
+
/** `null` for an id this family cannot address — callers skip that modal. */
|
|
108
|
+
export const modalSlug = (id: string): string | null =>
|
|
109
|
+
MODAL_ID_PATTERN.test(id) ? `${MODAL_SLUG_PREFIX}${id}` : null;
|
|
110
|
+
|
|
111
|
+
export const isModalSlug = (slug: string): boolean =>
|
|
112
|
+
typeof slug === 'string' && slug.startsWith(MODAL_SLUG_PREFIX);
|
|
113
|
+
|
|
114
|
+
/** The inverse of `modalSlug`, for turning a placeholder back into a modal id. */
|
|
115
|
+
export const modalIdFromSlug = (slug: string): string | null => {
|
|
116
|
+
if (!isModalSlug(slug)) return null;
|
|
117
|
+
|
|
118
|
+
const id = slug.slice(MODAL_SLUG_PREFIX.length);
|
|
119
|
+
|
|
120
|
+
return MODAL_ID_PATTERN.test(id) ? id : null;
|
|
121
|
+
};
|
|
122
|
+
|
|
82
123
|
/**
|
|
83
124
|
* Slug for a brand page that has no canonical placeholder — every bespoke
|
|
84
125
|
* content route (about us, campaigns, returns policy…) gets one derived from
|