@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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { getWidgetData } from '@akinon/next/data/server';
|
|
3
3
|
|
|
4
|
+
import { readThemeModals, ThemeModal } from '../overlays/modal-registry';
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* The `theme-config` widget, read and parsed once per request.
|
|
6
8
|
*
|
|
@@ -15,6 +17,12 @@ export interface ThemeConfig {
|
|
|
15
17
|
settings: Record<string, unknown> | null;
|
|
16
18
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
17
19
|
dataSources: any[];
|
|
20
|
+
/**
|
|
21
|
+
* The modals this theme defines. Read here rather than by the modal host so
|
|
22
|
+
* it costs nothing: the host would otherwise be a third reader of a widget
|
|
23
|
+
* every page already fetches once.
|
|
24
|
+
*/
|
|
25
|
+
modals: ThemeModal[];
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
type ThemeConfigWidget = {
|
|
@@ -34,11 +42,17 @@ const unwrapAttribute = (raw: unknown): unknown => {
|
|
|
34
42
|
};
|
|
35
43
|
|
|
36
44
|
export const readThemeConfig = (data: ThemeConfigWidget): ThemeConfig => {
|
|
37
|
-
const config: ThemeConfig = { settings: null, dataSources: [] };
|
|
45
|
+
const config: ThemeConfig = { settings: null, dataSources: [], modals: [] };
|
|
38
46
|
const attributes = data?.attributes;
|
|
39
47
|
|
|
40
48
|
if (!attributes) return config;
|
|
41
49
|
|
|
50
|
+
if (attributes.theme_modals) {
|
|
51
|
+
// `readThemeModals` already survives a malformed blob one entry at a time,
|
|
52
|
+
// so it needs no try/catch of its own here.
|
|
53
|
+
config.modals = readThemeModals(unwrapAttribute(attributes.theme_modals));
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
if (attributes.theme_dataSources) {
|
|
43
57
|
try {
|
|
44
58
|
const resolved = unwrapAttribute(attributes.theme_dataSources);
|
|
@@ -76,12 +90,13 @@ const load = async (): Promise<ThemeConfig> => {
|
|
|
76
90
|
const data = await getWidgetData<{
|
|
77
91
|
theme_settings?: unknown;
|
|
78
92
|
theme_dataSources?: unknown;
|
|
93
|
+
theme_modals?: unknown;
|
|
79
94
|
}>({ slug: 'theme-config' });
|
|
80
95
|
|
|
81
96
|
return readThemeConfig(data as ThemeConfigWidget);
|
|
82
97
|
} catch {
|
|
83
98
|
// A brand with no published theme still renders: no settings, no sources.
|
|
84
|
-
return { settings: null, dataSources: [] };
|
|
99
|
+
return { settings: null, dataSources: [], modals: [] };
|
|
85
100
|
}
|
|
86
101
|
};
|
|
87
102
|
|
|
@@ -5,6 +5,10 @@ import React, { useEffect, useRef } from 'react';
|
|
|
5
5
|
import { getResponsiveValue } from '../utils';
|
|
6
6
|
import DesignerOverlay from './designer-overlay';
|
|
7
7
|
import { useDesignerFeatures } from '../hooks/use-designer-features';
|
|
8
|
+
import {
|
|
9
|
+
NATIVELY_INTERACTIVE_BLOCK_TYPES,
|
|
10
|
+
useBlockAction
|
|
11
|
+
} from '../hooks/use-block-action';
|
|
8
12
|
|
|
9
13
|
import { isTrustedEditorEvent } from '../editor-bridge';
|
|
10
14
|
interface WithDesignerFeaturesProps {
|
|
@@ -77,6 +81,42 @@ export function WithDesignerFeatures({
|
|
|
77
81
|
return () => window.removeEventListener('message', handleMessage);
|
|
78
82
|
}, [actionBlockId, isDesigner]);
|
|
79
83
|
|
|
84
|
+
/**
|
|
85
|
+
* An authored action makes ANY block a trigger.
|
|
86
|
+
*
|
|
87
|
+
* It is wired here rather than in each renderer because the design decides
|
|
88
|
+
* what the trigger is — an image, an icon, a whole card — and enumerating
|
|
89
|
+
* those in advance is exactly what cannot be done. The wrapper is also the
|
|
90
|
+
* only element in the chain that already exists, so a block that becomes
|
|
91
|
+
* clickable gains no extra box and its layout does not move.
|
|
92
|
+
*
|
|
93
|
+
* `button` and `link` are excluded: they render a real `<button>`/`<a>` and
|
|
94
|
+
* wire the action onto it themselves. Handling it here too would nest a
|
|
95
|
+
* control inside a control, which is both invalid and double-fires.
|
|
96
|
+
*/
|
|
97
|
+
const action = useBlockAction(block, { currentBreakpoint, isDesigner });
|
|
98
|
+
const ownsAction =
|
|
99
|
+
action.isActive && !NATIVELY_INTERACTIVE_BLOCK_TYPES.has(block.type);
|
|
100
|
+
|
|
101
|
+
const handleRootClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
102
|
+
handleClick(event);
|
|
103
|
+
|
|
104
|
+
if (ownsAction) action.onClick?.(event);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Keyboard parity for the role this element just claimed. A div announced as
|
|
109
|
+
* a button that only answers the mouse is worse than an unannounced div: it
|
|
110
|
+
* promises an affordance to a keyboard user and then withholds it.
|
|
111
|
+
*/
|
|
112
|
+
const handleRootKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
113
|
+
if (!ownsAction) return;
|
|
114
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
115
|
+
// Space would scroll the page underneath the modal it just opened.
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
action.onClick?.(event as unknown as React.MouseEvent<HTMLElement>);
|
|
118
|
+
};
|
|
119
|
+
|
|
80
120
|
const isButtonBlock = block.type === 'button';
|
|
81
121
|
|
|
82
122
|
const buttonDisplay = isButtonBlock
|
|
@@ -95,7 +135,10 @@ export function WithDesignerFeatures({
|
|
|
95
135
|
// but visually hide them.
|
|
96
136
|
const mergedStyle: React.CSSProperties = {
|
|
97
137
|
...(style || {}),
|
|
98
|
-
...(isDesigner && block.hidden ? { display: 'none' } : null)
|
|
138
|
+
...(isDesigner && block.hidden ? { display: 'none' } : null),
|
|
139
|
+
// Only on the live site: the canvas already shows a pointer for selection,
|
|
140
|
+
// and a trigger there selects rather than fires.
|
|
141
|
+
...(ownsAction && !isDesigner ? { cursor: 'pointer' } : null)
|
|
99
142
|
};
|
|
100
143
|
|
|
101
144
|
return (
|
|
@@ -109,7 +152,11 @@ export function WithDesignerFeatures({
|
|
|
109
152
|
block.type === 'tab' && 'h-full'
|
|
110
153
|
)}
|
|
111
154
|
style={mergedStyle}
|
|
112
|
-
onClick={
|
|
155
|
+
onClick={handleRootClick}
|
|
156
|
+
onKeyDown={ownsAction ? handleRootKeyDown : undefined}
|
|
157
|
+
role={ownsAction ? 'button' : undefined}
|
|
158
|
+
tabIndex={ownsAction ? 0 : undefined}
|
|
159
|
+
{...(ownsAction ? action.ariaProps : null)}
|
|
113
160
|
>
|
|
114
161
|
{isDesigner && (
|
|
115
162
|
<DesignerOverlay
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
import { getResponsiveValue } from '../utils';
|
|
6
|
+
import {
|
|
7
|
+
closeModal,
|
|
8
|
+
closeTopModal,
|
|
9
|
+
openModal,
|
|
10
|
+
toggleModal,
|
|
11
|
+
useModalOpen
|
|
12
|
+
} from '../overlays/overlay-bus';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a block does when it is clicked.
|
|
16
|
+
*
|
|
17
|
+
* Until now the answer was hardcoded per block: `button` and `link` followed a
|
|
18
|
+
* `url` and nothing else could be clicked at all. A modal has to be openable
|
|
19
|
+
* from an image, an icon, a group — from whatever the design says the trigger
|
|
20
|
+
* is — so the behaviour becomes a property instead of a block type.
|
|
21
|
+
*
|
|
22
|
+
* Backward compatibility is the point of `'none'` being the absent value and
|
|
23
|
+
* `'link'` being what a url-carrying block already did: a composition
|
|
24
|
+
* published before this existed carries no `actionType` and behaves exactly as
|
|
25
|
+
* it did.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type BlockActionType =
|
|
29
|
+
| 'none'
|
|
30
|
+
| 'link'
|
|
31
|
+
| 'open-modal'
|
|
32
|
+
| 'close-modal'
|
|
33
|
+
| 'toggle-modal'
|
|
34
|
+
| 'close-top-modal'
|
|
35
|
+
| 'scroll-to';
|
|
36
|
+
|
|
37
|
+
export const BLOCK_ACTION_TYPES: BlockActionType[] = [
|
|
38
|
+
'none',
|
|
39
|
+
'link',
|
|
40
|
+
'open-modal',
|
|
41
|
+
'close-modal',
|
|
42
|
+
'toggle-modal',
|
|
43
|
+
'close-top-modal',
|
|
44
|
+
'scroll-to'
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const MODAL_ACTIONS: BlockActionType[] = [
|
|
48
|
+
'open-modal',
|
|
49
|
+
'close-modal',
|
|
50
|
+
'toggle-modal'
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Blocks that render a real `<button>` or `<a>` and therefore wire the action
|
|
55
|
+
* onto that element themselves.
|
|
56
|
+
*
|
|
57
|
+
* The generic path in `WithDesignerFeatures` must skip them: a wrapper
|
|
58
|
+
* claiming `role="button"` around a real button nests one control inside
|
|
59
|
+
* another, which is invalid markup, announces twice, and fires the action
|
|
60
|
+
* twice on one click.
|
|
61
|
+
*/
|
|
62
|
+
export const NATIVELY_INTERACTIVE_BLOCK_TYPES: ReadonlySet<string> = new Set([
|
|
63
|
+
'button',
|
|
64
|
+
'link'
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
export interface BlockActionSource {
|
|
68
|
+
properties?: Record<string, unknown> | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface BlockAction {
|
|
72
|
+
type: BlockActionType;
|
|
73
|
+
target: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const resolveBlockAction = (
|
|
77
|
+
block: BlockActionSource,
|
|
78
|
+
currentBreakpoint = 'desktop'
|
|
79
|
+
): BlockAction => {
|
|
80
|
+
const rawType = getResponsiveValue(
|
|
81
|
+
block.properties?.actionType,
|
|
82
|
+
currentBreakpoint,
|
|
83
|
+
'none'
|
|
84
|
+
);
|
|
85
|
+
const type = BLOCK_ACTION_TYPES.includes(rawType as BlockActionType)
|
|
86
|
+
? (rawType as BlockActionType)
|
|
87
|
+
: 'none';
|
|
88
|
+
|
|
89
|
+
const target = String(
|
|
90
|
+
getResponsiveValue(block.properties?.actionTarget, currentBreakpoint, '') ??
|
|
91
|
+
''
|
|
92
|
+
).trim();
|
|
93
|
+
|
|
94
|
+
return { type, target };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** True for an action this hook handles itself, i.e. not navigation. */
|
|
98
|
+
export const isInteractiveAction = (type: BlockActionType): boolean =>
|
|
99
|
+
type !== 'none' && type !== 'link';
|
|
100
|
+
|
|
101
|
+
export interface UseBlockActionResult {
|
|
102
|
+
/** Whether this block has an action that this hook performs. */
|
|
103
|
+
isActive: boolean;
|
|
104
|
+
/** Attach to the clickable element. `undefined` when there is no action. */
|
|
105
|
+
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
|
106
|
+
/**
|
|
107
|
+
* Spread onto the same element. Announces a dialog trigger as one, which is
|
|
108
|
+
* what turns a styled div into something a screen reader user can act on
|
|
109
|
+
* deliberately rather than discover by accident.
|
|
110
|
+
*/
|
|
111
|
+
ariaProps: {
|
|
112
|
+
'aria-haspopup'?: 'dialog';
|
|
113
|
+
'aria-expanded'?: boolean;
|
|
114
|
+
'aria-controls'?: string;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const scrollToAnchor = (target: string) => {
|
|
119
|
+
if (!target) return;
|
|
120
|
+
|
|
121
|
+
// Accepts `#reviews` and `reviews` alike: the designer typed one of them and
|
|
122
|
+
// does not care which, and `htmlId` is stored without the hash.
|
|
123
|
+
const id = target.startsWith('#') ? target.slice(1) : target;
|
|
124
|
+
const element = document.getElementById(id);
|
|
125
|
+
|
|
126
|
+
element?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Wire a block's authored action to the overlay bus.
|
|
131
|
+
*
|
|
132
|
+
* Inert in the designer: clicking a trigger in the canvas should select the
|
|
133
|
+
* block for editing, not open the modal over the workspace. The canvas has its
|
|
134
|
+
* own way to preview a modal (`use-modal-preview.ts`).
|
|
135
|
+
*/
|
|
136
|
+
export const useBlockAction = (
|
|
137
|
+
block: BlockActionSource,
|
|
138
|
+
{
|
|
139
|
+
currentBreakpoint = 'desktop',
|
|
140
|
+
isDesigner = false
|
|
141
|
+
}: { currentBreakpoint?: string; isDesigner?: boolean } = {}
|
|
142
|
+
): UseBlockActionResult => {
|
|
143
|
+
const { type, target } = resolveBlockAction(block, currentBreakpoint);
|
|
144
|
+
|
|
145
|
+
// Subscribed unconditionally — a hook cannot be called on a branch — but an
|
|
146
|
+
// empty id never matches an open modal, so a block with no action pays only
|
|
147
|
+
// for the subscription.
|
|
148
|
+
const isTargetOpen = useModalOpen(
|
|
149
|
+
MODAL_ACTIONS.includes(type) ? target : ''
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const handleClick = useCallback(
|
|
153
|
+
(event: React.MouseEvent<HTMLElement>) => {
|
|
154
|
+
if (isDesigner) return;
|
|
155
|
+
|
|
156
|
+
switch (type) {
|
|
157
|
+
case 'open-modal':
|
|
158
|
+
// A trigger that also navigates would race its own modal.
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
openModal(target);
|
|
161
|
+
break;
|
|
162
|
+
case 'close-modal':
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
closeModal(target);
|
|
165
|
+
break;
|
|
166
|
+
case 'toggle-modal':
|
|
167
|
+
event.preventDefault();
|
|
168
|
+
toggleModal(target);
|
|
169
|
+
break;
|
|
170
|
+
case 'close-top-modal':
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
closeTopModal();
|
|
173
|
+
break;
|
|
174
|
+
case 'scroll-to':
|
|
175
|
+
event.preventDefault();
|
|
176
|
+
scrollToAnchor(target);
|
|
177
|
+
break;
|
|
178
|
+
default:
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
[isDesigner, type, target]
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const isActive = isInteractiveAction(type) && (type === 'close-top-modal' || Boolean(target));
|
|
186
|
+
|
|
187
|
+
if (!isActive) {
|
|
188
|
+
return { isActive: false, ariaProps: {} };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const opensDialog = type === 'open-modal' || type === 'toggle-modal';
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
isActive: true,
|
|
195
|
+
onClick: handleClick,
|
|
196
|
+
ariaProps: opensDialog
|
|
197
|
+
? {
|
|
198
|
+
'aria-haspopup': 'dialog',
|
|
199
|
+
'aria-expanded': isTargetOpen,
|
|
200
|
+
// Points at the mount wrapper, which carries `data-theme-modal` and
|
|
201
|
+
// is present only while the modal is open — so the reference is
|
|
202
|
+
// honest rather than pointing at markup that is not there.
|
|
203
|
+
'aria-controls': isTargetOpen ? `theme-modal-${target}` : undefined
|
|
204
|
+
}
|
|
205
|
+
: {}
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export default useBlockAction;
|
package/src/index.ts
CHANGED
|
@@ -61,10 +61,60 @@ export {
|
|
|
61
61
|
export {
|
|
62
62
|
PLACEHOLDER_SLUGS,
|
|
63
63
|
customPageSlug,
|
|
64
|
-
customPageSlugFromPath
|
|
64
|
+
customPageSlugFromPath,
|
|
65
|
+
MODAL_SLUG_PREFIX,
|
|
66
|
+
modalSlug,
|
|
67
|
+
isModalSlug,
|
|
68
|
+
modalIdFromSlug
|
|
65
69
|
} from './chrome/placeholder-slugs';
|
|
66
70
|
export type { PlaceholderSlug } from './chrome/placeholder-slugs';
|
|
67
71
|
|
|
72
|
+
export {
|
|
73
|
+
openModal,
|
|
74
|
+
closeModal,
|
|
75
|
+
toggleModal,
|
|
76
|
+
closeTopModal,
|
|
77
|
+
closeAllModals,
|
|
78
|
+
isModalOpen,
|
|
79
|
+
getOpenModalIds,
|
|
80
|
+
subscribeToModals,
|
|
81
|
+
registerModalHost,
|
|
82
|
+
isModalHostMounted,
|
|
83
|
+
useModalOpen,
|
|
84
|
+
useOpenModalIds
|
|
85
|
+
} from './overlays/overlay-bus';
|
|
86
|
+
|
|
87
|
+
export {
|
|
88
|
+
readThemeModals,
|
|
89
|
+
MODAL_PRESENTATIONS
|
|
90
|
+
} from './overlays/modal-registry';
|
|
91
|
+
export type {
|
|
92
|
+
ThemeModal,
|
|
93
|
+
ModalChrome,
|
|
94
|
+
ModalPresentation,
|
|
95
|
+
ModalAutoTrigger,
|
|
96
|
+
ModalAutoTriggerEvent,
|
|
97
|
+
ModalFrequency
|
|
98
|
+
} from './overlays/modal-registry';
|
|
99
|
+
|
|
100
|
+
export { default as ThemeModals } from './overlays/theme-modals';
|
|
101
|
+
export { default as ThemeModalMount } from './overlays/theme-modal-mount';
|
|
102
|
+
|
|
103
|
+
export {
|
|
104
|
+
CentredModalSurface,
|
|
105
|
+
LayeredOverlaySurface,
|
|
106
|
+
OverlayCloseButton,
|
|
107
|
+
OVERLAY_PRESENTATIONS,
|
|
108
|
+
resolveOverlayPanelGeometry
|
|
109
|
+
} from './overlays/overlay-surface';
|
|
110
|
+
export type { OverlayPresentation } from './overlays/overlay-surface';
|
|
111
|
+
|
|
112
|
+
export {
|
|
113
|
+
canAutoOpen,
|
|
114
|
+
markAutoOpened,
|
|
115
|
+
clearAutoOpenRecord
|
|
116
|
+
} from './overlays/modal-frequency';
|
|
117
|
+
|
|
68
118
|
export {
|
|
69
119
|
resolveBodySlug,
|
|
70
120
|
resolveBodySlugFromUrl,
|