@zalify/storefront-kit 0.3.0 → 0.3.1

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.
@@ -6,20 +6,24 @@
6
6
  * sync — and hands app-specific concerns (template hot-apply, device
7
7
  * emulation) to callbacks.
8
8
  *
9
- * Editor mode keeps the page interactive buttons, drawers, variant
10
- * pickers, forms all work, so any state can be previewed — while the
11
- * bridge takes over two things a click must never do on its own:
9
+ * Editor mode has two interaction modes, switched by the host:
12
10
  *
13
- * - Leave the storefront. Same-origin links and GET forms are turned
14
- * into `location.replace` navigations that keep the editor-mode
15
- * query param (so the next document mounts the bridge again) and
16
- * add no browser-history entries; off-site links, new-tab links,
17
- * modifier-clicks and off-site paths (checkout, account) are blocked.
18
- * - Escape selection: every click still selects the enclosing block.
11
+ * - `select` (default): a click selects the block under it and nothing
12
+ * else happens links and buttons do not fire, so a merchant can
13
+ * reach for a nav link to edit it without leaving the page. Form
14
+ * controls (inputs, selects, labels, buttons inside a form) still
15
+ * work, so a search box or a sign-up form can be exercised.
16
+ * - `interact`: the storefront is fully live drawers, variant
17
+ * pickers, add to cart — and the bridge only polices navigation:
18
+ * same-origin links and GET forms become `location.replace`
19
+ * navigations that keep the editor-mode query param (so the next
20
+ * document mounts the bridge again) and add no browser-history
21
+ * entries; off-site links, new-tab links, modifier-clicks and
22
+ * off-site paths (checkout, account) are blocked.
19
23
  *
20
- * Double/middle clicks and context menus stay suppressed. Each mount
21
- * reports its URL as a `navigation`, which is how the editor follows
22
- * in-preview browsing.
24
+ * Every click selects in both modes. Double/middle clicks and context
25
+ * menus stay suppressed. Each mount reports its URL as a `navigation`,
26
+ * which is how the editor follows in-preview browsing.
23
27
  *
24
28
  * Security: the first `bridge:init` pins the editor origin; every
25
29
  * later message must match it, and nothing but `bridge:ready` is ever
@@ -75,6 +79,7 @@ export declare function decideNavigation(href: string, base: string, flags: {
75
79
  }): NavigationDecision;
76
80
  /** The storefront path the editor should remember: no editor-mode param. */
77
81
  export declare function previewPathOf(href: string): string;
82
+ export declare function isFormControl(target: EventTarget | null): boolean;
78
83
  export interface FrameBridgeController {
79
84
  unmount: () => void;
80
85
  /** Report that a persisted write round-tripped (HMR applied `hash`). */
@@ -6,20 +6,24 @@
6
6
  * sync — and hands app-specific concerns (template hot-apply, device
7
7
  * emulation) to callbacks.
8
8
  *
9
- * Editor mode keeps the page interactive buttons, drawers, variant
10
- * pickers, forms all work, so any state can be previewed — while the
11
- * bridge takes over two things a click must never do on its own:
9
+ * Editor mode has two interaction modes, switched by the host:
12
10
  *
13
- * - Leave the storefront. Same-origin links and GET forms are turned
14
- * into `location.replace` navigations that keep the editor-mode
15
- * query param (so the next document mounts the bridge again) and
16
- * add no browser-history entries; off-site links, new-tab links,
17
- * modifier-clicks and off-site paths (checkout, account) are blocked.
18
- * - Escape selection: every click still selects the enclosing block.
11
+ * - `select` (default): a click selects the block under it and nothing
12
+ * else happens links and buttons do not fire, so a merchant can
13
+ * reach for a nav link to edit it without leaving the page. Form
14
+ * controls (inputs, selects, labels, buttons inside a form) still
15
+ * work, so a search box or a sign-up form can be exercised.
16
+ * - `interact`: the storefront is fully live drawers, variant
17
+ * pickers, add to cart — and the bridge only polices navigation:
18
+ * same-origin links and GET forms become `location.replace`
19
+ * navigations that keep the editor-mode query param (so the next
20
+ * document mounts the bridge again) and add no browser-history
21
+ * entries; off-site links, new-tab links, modifier-clicks and
22
+ * off-site paths (checkout, account) are blocked.
19
23
  *
20
- * Double/middle clicks and context menus stay suppressed. Each mount
21
- * reports its URL as a `navigation`, which is how the editor follows
22
- * in-preview browsing.
24
+ * Every click selects in both modes. Double/middle clicks and context
25
+ * menus stay suppressed. Each mount reports its URL as a `navigation`,
26
+ * which is how the editor follows in-preview browsing.
23
27
  *
24
28
  * Security: the first `bridge:init` pins the editor origin; every
25
29
  * later message must match it, and nothing but `bridge:ready` is ever
@@ -100,6 +104,25 @@ export function previewPathOf(href) {
100
104
  url.searchParams.delete(EDITOR_MODE_PARAM);
101
105
  return url.pathname + url.search;
102
106
  }
107
+ /**
108
+ * Controls that keep their native behaviour in `select` mode: everything a
109
+ * form is made of, plus standalone inputs. Links and other buttons are the
110
+ * things a merchant reaches for to edit, so those stay selection-only.
111
+ */
112
+ const FORM_CONTROL_SELECTOR = [
113
+ 'input',
114
+ 'textarea',
115
+ 'select',
116
+ 'option',
117
+ 'label',
118
+ '[contenteditable=""]',
119
+ '[contenteditable="true"]',
120
+ 'form button',
121
+ 'form [role="button"]',
122
+ ].join(',');
123
+ export function isFormControl(target) {
124
+ return isElement(target) && target.closest(FORM_CONTROL_SELECTOR) !== null;
125
+ }
103
126
  function anchorOf(target) {
104
127
  return isElement(target) ? target.closest('a[href]') : null;
105
128
  }
@@ -111,6 +134,7 @@ export function mountFrameBridge(options) {
111
134
  const win = options.window ?? window;
112
135
  const doc = win.document;
113
136
  let editorOrigin = null;
137
+ let interaction = 'select';
114
138
  let lastHeight = 0;
115
139
  let selectedPath = null;
116
140
  const selectionHighlight = doc.createElement('div');
@@ -223,6 +247,7 @@ export function mountFrameBridge(options) {
223
247
  editorOrigin = event.origin;
224
248
  }
225
249
  options.onDeviceChange?.(message.payload.device);
250
+ interaction = message.payload.interaction ?? 'select';
226
251
  setSelection(message.payload.selectedPath);
227
252
  // The first measurement can run before bridge:init arrives. It is
228
253
  // intentionally not posted until the editor origin is pinned, so
@@ -280,6 +305,9 @@ export function mountFrameBridge(options) {
280
305
  case 'device:set':
281
306
  options.onDeviceChange?.(message.payload.device);
282
307
  break;
308
+ case 'interaction:set':
309
+ interaction = message.payload.mode;
310
+ break;
283
311
  case 'manifest:request': {
284
312
  const manifest = await options.getManifest();
285
313
  post({ type: 'manifest:response', payload: { manifest } });
@@ -309,7 +337,16 @@ export function mountFrameBridge(options) {
309
337
  setSelection(path);
310
338
  post({ type: 'block:clicked', payload: { path, rect: rectOf(node) } });
311
339
  }
312
- // The page keeps its interactivity; only navigation is policed.
340
+ if (interaction === 'select') {
341
+ // Selection only: nothing fires except form controls.
342
+ if (!isFormControl(event.target)) {
343
+ event.preventDefault();
344
+ event.stopPropagation();
345
+ }
346
+ return;
347
+ }
348
+ // Interact mode: the page keeps its interactivity; only navigation is
349
+ // policed.
313
350
  const anchor = anchorOf(event.target);
314
351
  if (!anchor)
315
352
  return;
@@ -16,6 +16,12 @@ export declare const DATA_PATH_ATTR = "data-z-path";
16
16
  /** DOM attribute the bridge sets on the selected node. */
17
17
  export declare const DATA_SELECTED_ATTR = "data-z-selected";
18
18
  export type Device = 'desktop' | 'mobile';
19
+ /**
20
+ * How clicks in the preview behave. `select` (default): a click selects the
21
+ * block; form controls still work but links and buttons do not fire.
22
+ * `interact`: the storefront is fully live and the editor follows navigation.
23
+ */
24
+ export type InteractionMode = 'select' | 'interact';
19
25
  export type EditorCapability = 'editor-bootstrap-v1' | 'apply-template-v1' | 'apply-groups-v1' | 'apply-settings-v1' | 'preview-navigation-v1';
20
26
  export type PreviewResourceType = 'index' | 'product' | 'collection' | 'page' | 'blog' | 'article' | 'cart' | 'search' | 'list-collections' | '404';
21
27
  export interface PreviewContext {
@@ -79,6 +85,8 @@ export type HostMessage = BridgeEnvelope<'bridge:init', {
79
85
  editorOrigin: string;
80
86
  device: Device;
81
87
  selectedPath: string | null;
88
+ /** Omitted by older hosts: behaves as `select`. */
89
+ interaction?: InteractionMode;
82
90
  }> | BridgeEnvelope<'block:select', {
83
91
  path: string | null;
84
92
  }> | BridgeEnvelope<'block:hover', {
@@ -92,6 +100,8 @@ export type HostMessage = BridgeEnvelope<'bridge:init', {
92
100
  settingsData?: SettingsData;
93
101
  }> | BridgeEnvelope<'device:set', {
94
102
  device: Device;
103
+ }> | BridgeEnvelope<'interaction:set', {
104
+ mode: InteractionMode;
95
105
  }> | BridgeEnvelope<'manifest:request', Record<string, never>> | BridgeEnvelope<'editor:bootstrap:request', Record<string, never>>;
96
106
  export type FrameMessage = BridgeEnvelope<'bridge:ready', {
97
107
  contractVersion: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalify/storefront-kit",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "The Zalify storefront SDK: framework-agnostic commerce logic (/commerce), the theme contract types and validators (/schemas), the canvas-editor bridge (/editor), and the React theme engine + shared components (/ui, /react/server). Consumed as TypeScript source inside the zalify-storefronts monorepo; published as compiled ESM + d.ts.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -6,20 +6,24 @@
6
6
  * sync — and hands app-specific concerns (template hot-apply, device
7
7
  * emulation) to callbacks.
8
8
  *
9
- * Editor mode keeps the page interactive buttons, drawers, variant
10
- * pickers, forms all work, so any state can be previewed — while the
11
- * bridge takes over two things a click must never do on its own:
9
+ * Editor mode has two interaction modes, switched by the host:
12
10
  *
13
- * - Leave the storefront. Same-origin links and GET forms are turned
14
- * into `location.replace` navigations that keep the editor-mode
15
- * query param (so the next document mounts the bridge again) and
16
- * add no browser-history entries; off-site links, new-tab links,
17
- * modifier-clicks and off-site paths (checkout, account) are blocked.
18
- * - Escape selection: every click still selects the enclosing block.
11
+ * - `select` (default): a click selects the block under it and nothing
12
+ * else happens links and buttons do not fire, so a merchant can
13
+ * reach for a nav link to edit it without leaving the page. Form
14
+ * controls (inputs, selects, labels, buttons inside a form) still
15
+ * work, so a search box or a sign-up form can be exercised.
16
+ * - `interact`: the storefront is fully live drawers, variant
17
+ * pickers, add to cart — and the bridge only polices navigation:
18
+ * same-origin links and GET forms become `location.replace`
19
+ * navigations that keep the editor-mode query param (so the next
20
+ * document mounts the bridge again) and add no browser-history
21
+ * entries; off-site links, new-tab links, modifier-clicks and
22
+ * off-site paths (checkout, account) are blocked.
19
23
  *
20
- * Double/middle clicks and context menus stay suppressed. Each mount
21
- * reports its URL as a `navigation`, which is how the editor follows
22
- * in-preview browsing.
24
+ * Every click selects in both modes. Double/middle clicks and context
25
+ * menus stay suppressed. Each mount reports its URL as a `navigation`,
26
+ * which is how the editor follows in-preview browsing.
23
27
  *
24
28
  * Security: the first `bridge:init` pins the editor origin; every
25
29
  * later message must match it, and nothing but `bridge:ready` is ever
@@ -37,6 +41,7 @@ import {
37
41
  type DOMRectLike,
38
42
  type EditorBootstrap,
39
43
  type EditorCapability,
44
+ type InteractionMode,
40
45
  type FrameMessage,
41
46
  type HostMessage,
42
47
  type SectionGroupData,
@@ -162,6 +167,27 @@ export function previewPathOf(href: string): string {
162
167
  return url.pathname + url.search;
163
168
  }
164
169
 
170
+ /**
171
+ * Controls that keep their native behaviour in `select` mode: everything a
172
+ * form is made of, plus standalone inputs. Links and other buttons are the
173
+ * things a merchant reaches for to edit, so those stay selection-only.
174
+ */
175
+ const FORM_CONTROL_SELECTOR = [
176
+ 'input',
177
+ 'textarea',
178
+ 'select',
179
+ 'option',
180
+ 'label',
181
+ '[contenteditable=""]',
182
+ '[contenteditable="true"]',
183
+ 'form button',
184
+ 'form [role="button"]',
185
+ ].join(',');
186
+
187
+ export function isFormControl(target: EventTarget | null): boolean {
188
+ return isElement(target) && target.closest(FORM_CONTROL_SELECTOR) !== null;
189
+ }
190
+
165
191
  function anchorOf(target: EventTarget | null): HTMLAnchorElement | null {
166
192
  return isElement(target) ? target.closest<HTMLAnchorElement>('a[href]') : null;
167
193
  }
@@ -186,6 +212,7 @@ export function mountFrameBridge(
186
212
  const win = options.window ?? window;
187
213
  const doc = win.document;
188
214
  let editorOrigin: string | null = null;
215
+ let interaction: InteractionMode = 'select';
189
216
  let lastHeight = 0;
190
217
  let selectedPath: string | null = null;
191
218
 
@@ -306,6 +333,7 @@ export function mountFrameBridge(
306
333
  editorOrigin = event.origin;
307
334
  }
308
335
  options.onDeviceChange?.(message.payload.device);
336
+ interaction = message.payload.interaction ?? 'select';
309
337
  setSelection(message.payload.selectedPath);
310
338
  // The first measurement can run before bridge:init arrives. It is
311
339
  // intentionally not posted until the editor origin is pinned, so
@@ -362,6 +390,9 @@ export function mountFrameBridge(
362
390
  case 'device:set':
363
391
  options.onDeviceChange?.(message.payload.device);
364
392
  break;
393
+ case 'interaction:set':
394
+ interaction = message.payload.mode;
395
+ break;
365
396
  case 'manifest:request': {
366
397
  const manifest = await options.getManifest();
367
398
  post({type: 'manifest:response', payload: {manifest}});
@@ -392,7 +423,16 @@ export function mountFrameBridge(
392
423
  setSelection(path);
393
424
  post({type: 'block:clicked', payload: {path, rect: rectOf(node)}});
394
425
  }
395
- // The page keeps its interactivity; only navigation is policed.
426
+ if (interaction === 'select') {
427
+ // Selection only: nothing fires except form controls.
428
+ if (!isFormControl(event.target)) {
429
+ event.preventDefault();
430
+ event.stopPropagation();
431
+ }
432
+ return;
433
+ }
434
+ // Interact mode: the page keeps its interactivity; only navigation is
435
+ // policed.
396
436
  const anchor = anchorOf(event.target);
397
437
  if (!anchor) return;
398
438
  const decision = decideNavigation(anchor.getAttribute('href') ?? '', win.location.href, {
@@ -25,6 +25,13 @@ export const DATA_SELECTED_ATTR = 'data-z-selected';
25
25
 
26
26
  export type Device = 'desktop' | 'mobile';
27
27
 
28
+ /**
29
+ * How clicks in the preview behave. `select` (default): a click selects the
30
+ * block; form controls still work but links and buttons do not fire.
31
+ * `interact`: the storefront is fully live and the editor follows navigation.
32
+ */
33
+ export type InteractionMode = 'select' | 'interact';
34
+
28
35
  export type EditorCapability =
29
36
  | 'editor-bootstrap-v1'
30
37
  | 'apply-template-v1'
@@ -139,6 +146,8 @@ export type HostMessage =
139
146
  editorOrigin: string;
140
147
  device: Device;
141
148
  selectedPath: string | null;
149
+ /** Omitted by older hosts: behaves as `select`. */
150
+ interaction?: InteractionMode;
142
151
  }
143
152
  >
144
153
  | BridgeEnvelope<'block:select', {path: string | null}>
@@ -154,6 +163,7 @@ export type HostMessage =
154
163
  }
155
164
  >
156
165
  | BridgeEnvelope<'device:set', {device: Device}>
166
+ | BridgeEnvelope<'interaction:set', {mode: InteractionMode}>
157
167
  | BridgeEnvelope<'manifest:request', Record<string, never>>
158
168
  | BridgeEnvelope<'editor:bootstrap:request', Record<string, never>>;
159
169