@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.
@@ -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
@@ -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={handleClick}
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,