@akinon/pz-theme 2.0.58-rc.0 → 2.0.59-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 +19 -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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
closeModal,
|
|
5
|
+
closeTopModal,
|
|
6
|
+
openModal,
|
|
7
|
+
toggleModal
|
|
8
|
+
} from './overlay-bus';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Two ways in for markup React does not own.
|
|
12
|
+
*
|
|
13
|
+
* The block dictionary is not the only thing on a composed page. The `embed`
|
|
14
|
+
* block renders authored HTML, a brand still owns part of its own chrome, and
|
|
15
|
+
* an agency drops a snippet into a campaign banner. None of those can call a
|
|
16
|
+
* hook, and without a way in they simply cannot open a modal — which pushes
|
|
17
|
+
* people back to writing their own overlay, the outcome this whole system
|
|
18
|
+
* exists to avoid.
|
|
19
|
+
*
|
|
20
|
+
* <button data-pz-modal-open="newsletter">Sign up</button>
|
|
21
|
+
* <button data-pz-modal-close="newsletter">No thanks</button>
|
|
22
|
+
*
|
|
23
|
+
* window.pzTheme.openModal('newsletter');
|
|
24
|
+
*
|
|
25
|
+
* The listener is delegated from the document, so markup injected at any time
|
|
26
|
+
* works with no registration step.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const ATTRIBUTES = [
|
|
30
|
+
['data-pz-modal-open', openModal],
|
|
31
|
+
['data-pz-modal-close', closeModal],
|
|
32
|
+
['data-pz-modal-toggle', toggleModal]
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
const handleDocumentClick = (event: MouseEvent) => {
|
|
36
|
+
const start = event.target as Element | null;
|
|
37
|
+
if (!start || typeof start.closest !== 'function') return;
|
|
38
|
+
|
|
39
|
+
for (const [attribute, action] of ATTRIBUTES) {
|
|
40
|
+
const trigger = start.closest(`[${attribute}]`);
|
|
41
|
+
if (!trigger) continue;
|
|
42
|
+
|
|
43
|
+
const id = trigger.getAttribute(attribute);
|
|
44
|
+
if (!id) continue;
|
|
45
|
+
|
|
46
|
+
// A trigger inside an <a> or a <form> must not also navigate or submit.
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
action(id);
|
|
49
|
+
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (start.closest('[data-pz-modal-close-top]')) {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
closeTopModal();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export interface PzThemeWindowApi {
|
|
60
|
+
openModal: typeof openModal;
|
|
61
|
+
closeModal: typeof closeModal;
|
|
62
|
+
toggleModal: typeof toggleModal;
|
|
63
|
+
closeTopModal: typeof closeTopModal;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
declare global {
|
|
67
|
+
interface Window {
|
|
68
|
+
pzTheme?: PzThemeWindowApi;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let installed = false;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Install both entry points. Idempotent, because the modal host mounts it and
|
|
76
|
+
* a page could legitimately mount more than one host during a transition.
|
|
77
|
+
*/
|
|
78
|
+
export const installModalDomBridge = (): (() => void) => {
|
|
79
|
+
if (typeof window === 'undefined' || installed) return () => undefined;
|
|
80
|
+
|
|
81
|
+
installed = true;
|
|
82
|
+
document.addEventListener('click', handleDocumentClick);
|
|
83
|
+
|
|
84
|
+
window.pzTheme = {
|
|
85
|
+
...(window.pzTheme || {}),
|
|
86
|
+
openModal,
|
|
87
|
+
closeModal,
|
|
88
|
+
toggleModal,
|
|
89
|
+
closeTopModal
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return () => {
|
|
93
|
+
installed = false;
|
|
94
|
+
document.removeEventListener('click', handleDocumentClick);
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Test-only: forget that the bridge was installed. */
|
|
99
|
+
export const __resetModalDomBridgeForTests = (): void => {
|
|
100
|
+
if (typeof document !== 'undefined') {
|
|
101
|
+
document.removeEventListener('click', handleDocumentClick);
|
|
102
|
+
}
|
|
103
|
+
installed = false;
|
|
104
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { ModalFrequency } from './modal-registry';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How often an auto-opening modal may show itself.
|
|
5
|
+
*
|
|
6
|
+
* This is the difference between a welcome popup and a reason to leave the
|
|
7
|
+
* site. Nothing else in the theme system needs persistent per-visitor state,
|
|
8
|
+
* so it lives here rather than growing a general store.
|
|
9
|
+
*
|
|
10
|
+
* `once-per-session` uses sessionStorage (cleared with the tab, which is what
|
|
11
|
+
* a "visit" means to a shopper); the day and forever caps use localStorage.
|
|
12
|
+
* Both are wrapped: Safari in private mode throws on write, and an exception
|
|
13
|
+
* here would take the whole page down for a marketing banner.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const STORAGE_PREFIX = 'pz-theme:modal-seen:';
|
|
17
|
+
|
|
18
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
const storageFor = (frequency: ModalFrequency): Storage | null => {
|
|
21
|
+
if (typeof window === 'undefined') return null;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
return frequency === 'once-per-session'
|
|
25
|
+
? window.sessionStorage
|
|
26
|
+
: window.localStorage;
|
|
27
|
+
} catch {
|
|
28
|
+
// Storage disabled entirely (some privacy modes, some embedded webviews).
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const keyFor = (modalId: string) => `${STORAGE_PREFIX}${modalId}`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether the cap still allows this modal to open.
|
|
37
|
+
*
|
|
38
|
+
* Fails OPEN when storage is unavailable: a shopper whose browser refuses
|
|
39
|
+
* storage should still see the newsletter offer once, and the alternative —
|
|
40
|
+
* suppressing it forever — is a silent regression nobody would trace back to
|
|
41
|
+
* a privacy setting.
|
|
42
|
+
*/
|
|
43
|
+
export const canAutoOpen = (
|
|
44
|
+
modalId: string,
|
|
45
|
+
frequency: ModalFrequency
|
|
46
|
+
): boolean => {
|
|
47
|
+
if (frequency === 'always') return true;
|
|
48
|
+
|
|
49
|
+
const storage = storageFor(frequency);
|
|
50
|
+
if (!storage) return true;
|
|
51
|
+
|
|
52
|
+
let raw: string | null = null;
|
|
53
|
+
try {
|
|
54
|
+
raw = storage.getItem(keyFor(modalId));
|
|
55
|
+
} catch {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!raw) return true;
|
|
60
|
+
if (frequency === 'once-per-session' || frequency === 'once-ever') {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// once-per-day: a stamp we cannot read is treated as "never shown" rather
|
|
65
|
+
// than as "shown forever", so a corrupted value self-heals on the next view.
|
|
66
|
+
const shownAt = Number(raw);
|
|
67
|
+
if (!Number.isFinite(shownAt)) return true;
|
|
68
|
+
|
|
69
|
+
return Date.now() - shownAt >= DAY_MS;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Record that the modal auto-opened. Never throws. */
|
|
73
|
+
export const markAutoOpened = (
|
|
74
|
+
modalId: string,
|
|
75
|
+
frequency: ModalFrequency
|
|
76
|
+
): void => {
|
|
77
|
+
if (frequency === 'always') return;
|
|
78
|
+
|
|
79
|
+
const storage = storageFor(frequency);
|
|
80
|
+
if (!storage) return;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
storage.setItem(keyFor(modalId), String(Date.now()));
|
|
84
|
+
} catch {
|
|
85
|
+
// Quota or private mode. The modal simply shows again next time, which is
|
|
86
|
+
// the harmless end of the two possible failures.
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Test-only helper, and what an editor "reset this modal" control would call. */
|
|
91
|
+
export const clearAutoOpenRecord = (modalId: string): void => {
|
|
92
|
+
if (typeof window === 'undefined') return;
|
|
93
|
+
|
|
94
|
+
[
|
|
95
|
+
() => window.sessionStorage.removeItem(keyFor(modalId)),
|
|
96
|
+
() => window.localStorage.removeItem(keyFor(modalId))
|
|
97
|
+
].forEach((remove) => {
|
|
98
|
+
try {
|
|
99
|
+
remove();
|
|
100
|
+
} catch {
|
|
101
|
+
// Nothing to clear if storage is unavailable.
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
import { installModalDomBridge } from './modal-dom-bridge';
|
|
6
|
+
import { registerModalHost } from './overlay-bus';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Announces that `<ThemeModals />` is on the page.
|
|
10
|
+
*
|
|
11
|
+
* The host has to be mounted by the brand, in its root layout — one line, but
|
|
12
|
+
* one line sixteen brands each have to add. Without this, forgetting it
|
|
13
|
+
* produces the worst possible symptom: a trigger the designer configured
|
|
14
|
+
* correctly, in a theme that saved correctly, that does nothing at all when
|
|
15
|
+
* clicked, with no error anywhere to suggest why.
|
|
16
|
+
*
|
|
17
|
+
* With it, `openModal` can say the actual sentence.
|
|
18
|
+
*/
|
|
19
|
+
export const ModalHostBeacon = () => {
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const unregister = registerModalHost();
|
|
22
|
+
// The `data-pz-modal-open` / `window.pzTheme` entry points live with the
|
|
23
|
+
// host: they can only work when there is something to open.
|
|
24
|
+
const uninstall = installModalDomBridge();
|
|
25
|
+
|
|
26
|
+
return () => {
|
|
27
|
+
unregister();
|
|
28
|
+
uninstall();
|
|
29
|
+
};
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
return null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default ModalHostBeacon;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import ThemePlaceholderWrapper from '../theme-placeholder-wrapper';
|
|
6
|
+
import {
|
|
7
|
+
CentredModalSurface,
|
|
8
|
+
LayeredOverlaySurface,
|
|
9
|
+
OverlayCloseButton,
|
|
10
|
+
OverlayHandleProps
|
|
11
|
+
} from './overlay-surface';
|
|
12
|
+
import ThemeModalMount from './theme-modal-mount';
|
|
13
|
+
import { useEditorModals } from './use-editor-modals';
|
|
14
|
+
import { useHeldModal } from './use-modal-preview';
|
|
15
|
+
|
|
16
|
+
interface ModalPreviewHostProps {
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
dataSources?: any[];
|
|
19
|
+
themeSettings?: Record<string, unknown> | null;
|
|
20
|
+
pageContext?: Record<string, unknown> | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Nothing to grab and nothing to claim.
|
|
25
|
+
*
|
|
26
|
+
* The canvas is a workspace, not a shopper's viewport: no focus trap into the
|
|
27
|
+
* page being styled, no `aria-modal` hiding the rest of the document from the
|
|
28
|
+
* accessibility tree, no scroll lock, no Escape. Passing inert props rather
|
|
29
|
+
* than calling `useOverlay` with a flag says that plainly — this surface never
|
|
30
|
+
* takes anything over.
|
|
31
|
+
*/
|
|
32
|
+
const INERT_OVERLAY: OverlayHandleProps = {
|
|
33
|
+
ref: undefined,
|
|
34
|
+
'aria-modal': undefined
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The modal a designer is composing, drawn in the canvas.
|
|
39
|
+
*
|
|
40
|
+
* This is the ONLY renderer for an editor-held modal, and it draws from the
|
|
41
|
+
* entry the editor pushes rather than from `theme-config`. That single choice
|
|
42
|
+
* is what makes the three cases behave the same:
|
|
43
|
+
*
|
|
44
|
+
* - a modal created seconds ago that the server has never heard of,
|
|
45
|
+
* - a saved modal whose chrome is being changed right now,
|
|
46
|
+
* - a saved modal being filled with blocks.
|
|
47
|
+
*
|
|
48
|
+
* All three need a surface on screen before anything is saved. A
|
|
49
|
+
* server-rendered surface could only ever manage the third, and only after a
|
|
50
|
+
* save and a reload.
|
|
51
|
+
*
|
|
52
|
+
* The composition inside comes from an ordinary `ThemePlaceholderWrapper`, the
|
|
53
|
+
* same one every other editable region on the page uses, so the modal's
|
|
54
|
+
* interior registers itself, accepts drops and answers selection with no
|
|
55
|
+
* special case anywhere in the editor. It starts empty on purpose: on
|
|
56
|
+
* `LOAD_THEME`/`UPDATE_THEME` the wrapper replaces its sections wholesale from
|
|
57
|
+
* the editor's copy, which is the authority while editing.
|
|
58
|
+
*/
|
|
59
|
+
export const ModalPreviewHost = ({
|
|
60
|
+
dataSources,
|
|
61
|
+
themeSettings = null,
|
|
62
|
+
pageContext = null
|
|
63
|
+
}: ModalPreviewHostProps) => {
|
|
64
|
+
const modal = useHeldModal();
|
|
65
|
+
const editorModals = useEditorModals();
|
|
66
|
+
|
|
67
|
+
const composition = (slug: string) => (
|
|
68
|
+
<ThemePlaceholderWrapper
|
|
69
|
+
slug={slug}
|
|
70
|
+
initialSections={[]}
|
|
71
|
+
initialPlaceholderId={slug}
|
|
72
|
+
isDesignMode={false}
|
|
73
|
+
dataSources={dataSources}
|
|
74
|
+
initialThemeSettings={themeSettings}
|
|
75
|
+
initialPageContext={pageContext}
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Every modal the editor knows, mounted the ordinary way.
|
|
81
|
+
*
|
|
82
|
+
* This is what lets a TRIGGER work while composing. The saved mounts can
|
|
83
|
+
* only offer modals the backend has stored, so pressing a trigger in browse
|
|
84
|
+
* mode did nothing at all for a modal created minutes ago — and nothing
|
|
85
|
+
* distinguished that from a broken trigger. These mounts answer the overlay
|
|
86
|
+
* bus exactly as the saved ones do, so an unsaved modal opens, dismisses and
|
|
87
|
+
* traps focus like a real one.
|
|
88
|
+
*
|
|
89
|
+
* The one the editor is holding open stands itself down here and is drawn
|
|
90
|
+
* below instead, as an inert editing surface.
|
|
91
|
+
*/
|
|
92
|
+
const triggerable = (
|
|
93
|
+
<>
|
|
94
|
+
{editorModals.map((entry) => (
|
|
95
|
+
<ThemeModalMount key={entry.id} modal={entry} origin="editor">
|
|
96
|
+
{composition(entry.slug)}
|
|
97
|
+
</ThemeModalMount>
|
|
98
|
+
))}
|
|
99
|
+
</>
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (!modal) return triggerable;
|
|
103
|
+
|
|
104
|
+
const { chrome, presentation } = modal;
|
|
105
|
+
|
|
106
|
+
// Every dismissal is inert: the designer has to be able to click the close
|
|
107
|
+
// button and the backdrop they are colouring without the surface vanishing.
|
|
108
|
+
const noop = () => undefined;
|
|
109
|
+
|
|
110
|
+
const header = chrome.showClose ? (
|
|
111
|
+
<OverlayCloseButton label={chrome.closeLabel} onClick={noop} />
|
|
112
|
+
) : null;
|
|
113
|
+
|
|
114
|
+
const ariaLabel = chrome.ariaLabel || modal.name;
|
|
115
|
+
|
|
116
|
+
const surface =
|
|
117
|
+
presentation === 'modal' ? (
|
|
118
|
+
<CentredModalSurface
|
|
119
|
+
overlayProps={INERT_OVERLAY}
|
|
120
|
+
ariaLabel={ariaLabel}
|
|
121
|
+
closeOnOverlay={false}
|
|
122
|
+
onDismiss={noop}
|
|
123
|
+
width={chrome.width}
|
|
124
|
+
header={header}
|
|
125
|
+
>
|
|
126
|
+
{composition(modal.slug)}
|
|
127
|
+
</CentredModalSurface>
|
|
128
|
+
) : (
|
|
129
|
+
<LayeredOverlaySurface
|
|
130
|
+
presentation={presentation}
|
|
131
|
+
overlayProps={INERT_OVERLAY}
|
|
132
|
+
ariaLabel={ariaLabel}
|
|
133
|
+
showBackdrop={chrome.showBackdrop}
|
|
134
|
+
closeOnOverlay={false}
|
|
135
|
+
onDismiss={noop}
|
|
136
|
+
width={chrome.width}
|
|
137
|
+
panelSide={chrome.panelSide}
|
|
138
|
+
sheetHeight={chrome.sheetHeight}
|
|
139
|
+
header={header}
|
|
140
|
+
>
|
|
141
|
+
{composition(modal.slug)}
|
|
142
|
+
</LayeredOverlaySurface>
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<>
|
|
147
|
+
{triggerable}
|
|
148
|
+
<div
|
|
149
|
+
id={`theme-modal-${modal.id}`}
|
|
150
|
+
data-theme-modal={modal.id}
|
|
151
|
+
data-theme-modal-state="preview"
|
|
152
|
+
>
|
|
153
|
+
{surface}
|
|
154
|
+
</div>
|
|
155
|
+
</>
|
|
156
|
+
);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export default ModalPreviewHost;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { modalSlug } from '../chrome/placeholder-slugs';
|
|
2
|
+
import { devWarn } from '../utils/dev-warn';
|
|
3
|
+
import type { VisibilityRules } from '../utils/visibility-rules';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The modal registry: what modals this theme has, read off `theme-config`.
|
|
7
|
+
*
|
|
8
|
+
* A modal's CONTENT is an ordinary composition saved under its own slug, so
|
|
9
|
+
* nothing about the block tree lives here. This is only the index — the part
|
|
10
|
+
* the page needs before it can decide which modals to fetch at all.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type ModalPresentation = 'modal' | 'drawer' | 'sheet';
|
|
14
|
+
|
|
15
|
+
export const MODAL_PRESENTATIONS: ModalPresentation[] = [
|
|
16
|
+
'modal',
|
|
17
|
+
'drawer',
|
|
18
|
+
'sheet'
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export type ModalAutoTriggerEvent =
|
|
22
|
+
| 'none'
|
|
23
|
+
| 'page-load'
|
|
24
|
+
| 'delay'
|
|
25
|
+
| 'scroll-depth'
|
|
26
|
+
| 'exit-intent'
|
|
27
|
+
| 'idle'
|
|
28
|
+
| 'element-visible';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How often an auto-triggered modal may show itself. Without this a "welcome"
|
|
32
|
+
* popup reappears on every navigation, which is the single most common way
|
|
33
|
+
* this feature gets switched back off after launch.
|
|
34
|
+
*/
|
|
35
|
+
export type ModalFrequency =
|
|
36
|
+
| 'always'
|
|
37
|
+
| 'once-per-session'
|
|
38
|
+
| 'once-per-day'
|
|
39
|
+
| 'once-ever';
|
|
40
|
+
|
|
41
|
+
export interface ModalAutoTrigger {
|
|
42
|
+
event: ModalAutoTriggerEvent;
|
|
43
|
+
/** `delay` and `idle`, in milliseconds. */
|
|
44
|
+
delayMs?: number;
|
|
45
|
+
/** `scroll-depth`, as a percentage of scrollable height. */
|
|
46
|
+
scrollPercent?: number;
|
|
47
|
+
/** `element-visible`, an authored `htmlId` elsewhere on the page. */
|
|
48
|
+
anchorId?: string;
|
|
49
|
+
frequency: ModalFrequency;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The frame around the authored content: what the surface does, not what is
|
|
54
|
+
* in it. Kept on the registry rather than inside the composition because the
|
|
55
|
+
* host needs it before it renders anything, and because a designer changing
|
|
56
|
+
* "close on backdrop" should not be editing a block.
|
|
57
|
+
*/
|
|
58
|
+
export interface ModalChrome {
|
|
59
|
+
/**
|
|
60
|
+
* The dialog's accessible name. Defaults to the modal's own name — a dialog
|
|
61
|
+
* with no name is announced as just "dialog", which tells a screen reader
|
|
62
|
+
* user nothing about what just took over their page.
|
|
63
|
+
*/
|
|
64
|
+
ariaLabel?: string;
|
|
65
|
+
showClose: boolean;
|
|
66
|
+
closeLabel: string;
|
|
67
|
+
showBackdrop: boolean;
|
|
68
|
+
closeOnBackdrop: boolean;
|
|
69
|
+
closeOnEscape: boolean;
|
|
70
|
+
/** Drawer/anchored width, and the centred modal's max width, in px. */
|
|
71
|
+
width: number;
|
|
72
|
+
panelSide: 'left' | 'right';
|
|
73
|
+
/** Sheet height as a percentage of the viewport. */
|
|
74
|
+
sheetHeight: number;
|
|
75
|
+
lockScroll: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ThemeModal {
|
|
79
|
+
/** Generated, stable, and what every trigger points at. Never the name. */
|
|
80
|
+
id: string;
|
|
81
|
+
/** Designer-facing label. Free to change without touching saved content. */
|
|
82
|
+
name: string;
|
|
83
|
+
/** `modal-<id>`, the widget slug the composition is saved under. */
|
|
84
|
+
slug: string;
|
|
85
|
+
presentation: ModalPresentation;
|
|
86
|
+
chrome: ModalChrome;
|
|
87
|
+
autoTrigger?: ModalAutoTrigger;
|
|
88
|
+
/**
|
|
89
|
+
* Where this modal EXISTS — the same rule object sections and blocks already
|
|
90
|
+
* use, so route, auth state, locale, currency, breakpoint and page-data
|
|
91
|
+
* conditions all work with no second condition language to learn or maintain.
|
|
92
|
+
*/
|
|
93
|
+
visibilityRules?: VisibilityRules;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const asString = (value: unknown): string =>
|
|
97
|
+
typeof value === 'string' ? value.trim() : '';
|
|
98
|
+
|
|
99
|
+
const asOption = <T extends string>(
|
|
100
|
+
value: unknown,
|
|
101
|
+
allowed: readonly T[],
|
|
102
|
+
fallback: T
|
|
103
|
+
): T => (allowed.includes(value as T) ? (value as T) : fallback);
|
|
104
|
+
|
|
105
|
+
const asPositiveNumber = (value: unknown): number | undefined => {
|
|
106
|
+
const numeric = Number(value);
|
|
107
|
+
|
|
108
|
+
return Number.isFinite(numeric) && numeric >= 0 ? numeric : undefined;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const AUTO_TRIGGER_EVENTS: ModalAutoTriggerEvent[] = [
|
|
112
|
+
'none',
|
|
113
|
+
'page-load',
|
|
114
|
+
'delay',
|
|
115
|
+
'scroll-depth',
|
|
116
|
+
'exit-intent',
|
|
117
|
+
'idle',
|
|
118
|
+
'element-visible'
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const FREQUENCIES: ModalFrequency[] = [
|
|
122
|
+
'always',
|
|
123
|
+
'once-per-session',
|
|
124
|
+
'once-per-day',
|
|
125
|
+
'once-ever'
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const readAutoTrigger = (value: unknown): ModalAutoTrigger | undefined => {
|
|
129
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const raw = value as Record<string, unknown>;
|
|
134
|
+
const event = asOption(raw.event, AUTO_TRIGGER_EVENTS, 'none');
|
|
135
|
+
|
|
136
|
+
if (event === 'none') return undefined;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
event,
|
|
140
|
+
delayMs: asPositiveNumber(raw.delayMs),
|
|
141
|
+
scrollPercent: asPositiveNumber(raw.scrollPercent),
|
|
142
|
+
anchorId: asString(raw.anchorId) || undefined,
|
|
143
|
+
// Defaults to the safe end: an auto-opening modal that has not been told
|
|
144
|
+
// how often it may appear should not appear on every page view.
|
|
145
|
+
frequency: asOption(raw.frequency, FREQUENCIES, 'once-per-session')
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const asToggle = (value: unknown, fallback: boolean): boolean =>
|
|
150
|
+
typeof value === 'boolean' ? value : fallback;
|
|
151
|
+
|
|
152
|
+
const asClamped = (
|
|
153
|
+
value: unknown,
|
|
154
|
+
fallback: number,
|
|
155
|
+
min: number,
|
|
156
|
+
max: number
|
|
157
|
+
): number => {
|
|
158
|
+
const numeric = Math.round(Number(value));
|
|
159
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
160
|
+
|
|
161
|
+
return Math.min(max, Math.max(min, numeric));
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const readChrome = (value: unknown, name: string): ModalChrome => {
|
|
165
|
+
const raw =
|
|
166
|
+
value && typeof value === 'object' && !Array.isArray(value)
|
|
167
|
+
? (value as Record<string, unknown>)
|
|
168
|
+
: {};
|
|
169
|
+
|
|
170
|
+
const showClose = asToggle(raw.showClose, true);
|
|
171
|
+
const closeOnBackdrop = asToggle(raw.closeOnBackdrop, true);
|
|
172
|
+
const closeOnEscape = asToggle(raw.closeOnEscape, true);
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
ariaLabel: asString(raw.ariaLabel) || name,
|
|
176
|
+
/**
|
|
177
|
+
* Forced back on when nothing else could dismiss the modal. A shopper
|
|
178
|
+
* sealed inside an overlay has lost the page, and this is the one setting
|
|
179
|
+
* combination where honouring every switch as authored produces exactly
|
|
180
|
+
* that — so the close button comes back rather than the designer finding
|
|
181
|
+
* out from a support ticket.
|
|
182
|
+
*/
|
|
183
|
+
showClose: showClose || (!closeOnBackdrop && !closeOnEscape),
|
|
184
|
+
closeLabel: asString(raw.closeLabel) || 'Close',
|
|
185
|
+
showBackdrop: asToggle(raw.showBackdrop, true),
|
|
186
|
+
closeOnBackdrop,
|
|
187
|
+
closeOnEscape,
|
|
188
|
+
width: asClamped(raw.width, 640, 240, 1600),
|
|
189
|
+
panelSide: raw.panelSide === 'left' ? 'left' : 'right',
|
|
190
|
+
sheetHeight: asClamped(raw.sheetHeight, 60, 20, 100),
|
|
191
|
+
lockScroll: asToggle(raw.lockScroll, true)
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Normalise ONE registry entry.
|
|
197
|
+
*
|
|
198
|
+
* Exported because the editor pushes a live entry over postMessage while a
|
|
199
|
+
* designer is composing a modal, and that entry has to survive exactly the
|
|
200
|
+
* same defaulting and rejection as one read off `theme-config` — otherwise the
|
|
201
|
+
* canvas would render a modal the published page never could.
|
|
202
|
+
*/
|
|
203
|
+
export const readThemeModal = (value: unknown): ThemeModal | null => {
|
|
204
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
205
|
+
|
|
206
|
+
const raw = value as Record<string, unknown>;
|
|
207
|
+
const id = asString(raw.id);
|
|
208
|
+
const slug = modalSlug(id);
|
|
209
|
+
|
|
210
|
+
// An id this slug family cannot address has no composition to fetch, so the
|
|
211
|
+
// entry is dropped rather than rendered as an empty overlay a trigger can
|
|
212
|
+
// "open" to nothing. Named, because it means the editor wrote a bad id.
|
|
213
|
+
if (!slug) {
|
|
214
|
+
if (id) {
|
|
215
|
+
devWarn(
|
|
216
|
+
'modalRegistry',
|
|
217
|
+
`Modal id "${id}" is not addressable (expected lowercase letters, digits and hyphens); the modal is ignored`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const name = asString(raw.name) || id;
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
id,
|
|
227
|
+
name,
|
|
228
|
+
slug,
|
|
229
|
+
presentation: asOption(raw.presentation, MODAL_PRESENTATIONS, 'modal'),
|
|
230
|
+
chrome: readChrome(raw.chrome, name),
|
|
231
|
+
autoTrigger: readAutoTrigger(raw.autoTrigger),
|
|
232
|
+
visibilityRules:
|
|
233
|
+
raw.visibilityRules && typeof raw.visibilityRules === 'object'
|
|
234
|
+
? (raw.visibilityRules as VisibilityRules)
|
|
235
|
+
: undefined
|
|
236
|
+
};
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Parse the `theme_modals` attribute into the registry.
|
|
241
|
+
*
|
|
242
|
+
* Accepts the array itself or the JSON string the editor writes, matching how
|
|
243
|
+
* every other `theme_*` attribute is read. Malformed entries are dropped one by
|
|
244
|
+
* one: one bad modal must not cost the page the rest of them.
|
|
245
|
+
*
|
|
246
|
+
* Duplicate ids collapse to the first, because two entries claiming one id
|
|
247
|
+
* claim one slug, and letting both through would mean two surfaces racing to
|
|
248
|
+
* render the same composition.
|
|
249
|
+
*/
|
|
250
|
+
export const readThemeModals = (value: unknown): ThemeModal[] => {
|
|
251
|
+
let parsed: unknown = value;
|
|
252
|
+
|
|
253
|
+
if (typeof parsed === 'string') {
|
|
254
|
+
if (!parsed.trim()) return [];
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
parsed = JSON.parse(parsed);
|
|
258
|
+
} catch {
|
|
259
|
+
devWarn(
|
|
260
|
+
'modalRegistry',
|
|
261
|
+
'theme_modals is not valid JSON; the theme renders with no modals'
|
|
262
|
+
);
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!Array.isArray(parsed)) return [];
|
|
268
|
+
|
|
269
|
+
const seen = new Set<string>();
|
|
270
|
+
const modals: ThemeModal[] = [];
|
|
271
|
+
|
|
272
|
+
parsed.forEach((entry) => {
|
|
273
|
+
const modal = readThemeModal(entry);
|
|
274
|
+
if (!modal) return;
|
|
275
|
+
|
|
276
|
+
if (seen.has(modal.id)) {
|
|
277
|
+
devWarn(
|
|
278
|
+
'modalRegistry',
|
|
279
|
+
`Duplicate modal id "${modal.id}"; only the first entry is used`
|
|
280
|
+
);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
seen.add(modal.id);
|
|
285
|
+
modals.push(modal);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
return modals;
|
|
289
|
+
};
|