@kubex/zinc 1.1.72 → 1.1.74

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.
@@ -62,6 +62,7 @@ export default class ZnPage extends ZnTabs {
62
62
  @state() private hasExpandingActions = false;
63
63
  private actionObserver: MutationObserver | null = null;
64
64
  private tabObserver: MutationObserver | null = null;
65
+ private pageHistoryKey: string | null = null;
65
66
 
66
67
  async connectedCallback() {
67
68
  const connected = super.connectedCallback();
@@ -281,22 +282,45 @@ export default class ZnPage extends ZnTabs {
281
282
 
282
283
  private handleNavigationSelect(event: ZnSelectEvent) {
283
284
  const item = event.detail.item as HTMLElement;
285
+ if (this.getNavigationItemPage(item) !== this) {
286
+ return;
287
+ }
288
+
284
289
  const tabUri = item.getAttribute('tab-uri');
285
290
  const tabId = item.getAttribute('tab');
286
291
 
287
292
  if (tabUri) {
288
- this.clickTab(item, false);
293
+ this.clickTab(item, false, true);
289
294
  this.syncNavigationActive(item);
290
295
  return;
291
296
  }
292
297
 
293
298
  if (tabId !== null) {
294
- this.activateTab(tabId, true);
299
+ this.activateTab(tabId, true, true);
295
300
  }
296
301
  }
297
302
 
298
- private activateTab(tabId: string, store: boolean) {
299
- this.setActiveTab(tabId, store, false);
303
+ private getNavigationItemPage(item: HTMLElement): ZnPage | null {
304
+ let current: Node | null = item;
305
+
306
+ while (current) {
307
+ if (current instanceof ZnPage) {
308
+ return current;
309
+ }
310
+
311
+ const root = current.getRootNode();
312
+ if (!(root instanceof ShadowRoot)) {
313
+ return item.closest<ZnPage>('zn-page');
314
+ }
315
+
316
+ current = root.host;
317
+ }
318
+
319
+ return null;
320
+ }
321
+
322
+ private activateTab(tabId: string, store: boolean, pushHistory = false) {
323
+ this.setActiveTab(tabId, store, false, null, pushHistory);
300
324
  const navItem = this.shadowRoot?.querySelector<HTMLElement>(`zn-navbar li[tab="${CSS.escape(tabId)}"]`);
301
325
  if (navItem) {
302
326
  this.syncNavigationActive(navItem);
@@ -304,6 +328,15 @@ export default class ZnPage extends ZnTabs {
304
328
  }
305
329
 
306
330
  private activateInitialPageTab() {
331
+ const restoredTab = this.getStoredTab();
332
+ if (restoredTab !== null) {
333
+ const definition = this.tabDefinitions.find(tab => tab.id === restoredTab || tab.uri === restoredTab);
334
+ if (definition) {
335
+ this.activateTabDefinition(definition);
336
+ return;
337
+ }
338
+ }
339
+
307
340
  const preselected = this.tabDefinitions.find(tab => tab.selected);
308
341
  if (preselected) {
309
342
  this.activateTabDefinition(preselected);
@@ -318,7 +351,39 @@ export default class ZnPage extends ZnTabs {
318
351
  }
319
352
  }
320
353
 
321
- private activateTabDefinition(tab: TabDefinition) {
354
+ // Pages have no store-key, so they persist against their own identity.
355
+ protected getTabStoreKey(): string {
356
+ return this.getPageHistoryKey();
357
+ }
358
+
359
+ // A page's default is its preselected tab, or the first one - not the empty
360
+ // active tab zn-tabs starts from.
361
+ protected activateDefaultTab() {
362
+ const preselected = this.tabDefinitions.find(tab => tab.selected) ?? this.tabDefinitions[0];
363
+ if (preselected) {
364
+ this.activateTabDefinition(preselected, true);
365
+ }
366
+ }
367
+
368
+ private getPageHistoryKey(): string {
369
+ if (this.pageHistoryKey !== null) {
370
+ return this.pageHistoryKey;
371
+ }
372
+
373
+ const pages: ZnPage[] = [this];
374
+ let ancestor = this.parentElement?.closest<ZnPage>('zn-page') ?? null;
375
+ while (ancestor) {
376
+ pages.unshift(ancestor);
377
+ ancestor = ancestor.parentElement?.closest<ZnPage>('zn-page') ?? null;
378
+ }
379
+
380
+ this.pageHistoryKey = pages
381
+ .map(page => page.id || page.getAttribute('caption') || 'page')
382
+ .join('/');
383
+ return this.pageHistoryKey;
384
+ }
385
+
386
+ private activateTabDefinition(tab: TabDefinition, store = false) {
322
387
  if (tab.uri) {
323
388
  const navItem = this.findNavItemForUri(tab.uri);
324
389
  if (navItem) {
@@ -327,7 +392,7 @@ export default class ZnPage extends ZnTabs {
327
392
  return;
328
393
  }
329
394
  }
330
- this.activateTab(tab.id, false);
395
+ this.activateTab(tab.id, store);
331
396
  }
332
397
 
333
398
  private findNavItemForUri(uri: string): HTMLElement | null {
@@ -85,6 +85,214 @@ describe('<zn-page>', () => {
85
85
  expect(getComputedStyle(selectedTab).display).to.not.equal('none');
86
86
  });
87
87
 
88
+ describe('tab persistence', () => {
89
+ const originalHref = window.location.href;
90
+ let locationCount = 0;
91
+
92
+ const renderPage = () => fixture<ZnPage>(html`
93
+ <zn-page caption="Persisted Page">
94
+ <zn-tab caption="Overview">Overview Content</zn-tab>
95
+ <zn-tab caption="Billing">Billing Content</zn-tab>
96
+ <zn-tab caption="Notes">Notes Content</zn-tab>
97
+ </zn-page>
98
+ `);
99
+
100
+ const clickTab = async (page: ZnPage, index: number) => {
101
+ const navbar = page.shadowRoot!.querySelector('zn-navbar')!;
102
+ navbar.shadowRoot!.querySelectorAll<HTMLElement>('li:not(.more)')[index].click();
103
+ await aTimeout(40);
104
+ };
105
+
106
+ const clickBilling = (page: ZnPage) => clickTab(page, 1);
107
+
108
+ const goBack = async () => {
109
+ await new Promise<void>(resolve => {
110
+ window.addEventListener('popstate', () => resolve(), {once: true});
111
+ window.history.back();
112
+ });
113
+ await aTimeout(40);
114
+ };
115
+
116
+ beforeEach(() => {
117
+ locationCount += 1;
118
+ window.history.pushState({}, '', `?page-test=case-${locationCount}`);
119
+ });
120
+
121
+ afterEach(() => window.history.replaceState({}, '', originalHref));
122
+
123
+ it('restores the active tab when the page is reloaded or returned to', async () => {
124
+ const page = await renderPage();
125
+ await aTimeout(40);
126
+ await clickBilling(page);
127
+ expect(page.getAttribute('active')).to.equal('billing');
128
+ page.remove();
129
+
130
+ // A reload replaces the document; the shell may also replace the history
131
+ // entry's state, so restoring must not depend on that state surviving.
132
+ window.history.replaceState({uri: window.location.pathname}, '');
133
+ window.dispatchEvent(new PopStateEvent('popstate'));
134
+
135
+ const restoredPage = await renderPage();
136
+ await aTimeout(40);
137
+ expect(restoredPage.getAttribute('active')).to.equal('billing');
138
+ });
139
+
140
+ it('uses the default tab when navigating to the page', async () => {
141
+ const page = await renderPage();
142
+ await aTimeout(40);
143
+ await clickBilling(page);
144
+ page.remove();
145
+
146
+ const stored = window.location.search;
147
+ window.history.pushState({}, '', '?page-test=elsewhere');
148
+ window.history.pushState({}, '', stored);
149
+
150
+ const freshPage = await renderPage();
151
+ await aTimeout(40);
152
+ expect(freshPage.getAttribute('active')).to.equal('');
153
+ });
154
+
155
+ it('restores a tab cycled without the navigation', async () => {
156
+ const page = await renderPage();
157
+ await aTimeout(40);
158
+
159
+ // Keyboard shortcuts cycle tabs directly, bypassing the navbar.
160
+ page.nextTab();
161
+ await aTimeout(40);
162
+ expect(page.getAttribute('active')).to.equal('billing');
163
+ page.remove();
164
+
165
+ window.dispatchEvent(new PopStateEvent('popstate'));
166
+ const restoredPage = await renderPage();
167
+ await aTimeout(40);
168
+ expect(restoredPage.getAttribute('active')).to.equal('billing');
169
+ });
170
+
171
+ it('steps back through each tab that was opened', async () => {
172
+ const page = await renderPage();
173
+ await aTimeout(40);
174
+
175
+ await clickTab(page, 1);
176
+ expect(page.getAttribute('active')).to.equal('billing');
177
+ await clickTab(page, 2);
178
+ expect(page.getAttribute('active')).to.equal('notes');
179
+
180
+ await goBack();
181
+ expect(page.getAttribute('active')).to.equal('billing');
182
+
183
+ await goBack();
184
+ expect(page.getAttribute('active')).to.equal('');
185
+ });
186
+
187
+ it('keeps the open tab when stepping back onto an entry that records no tab', async () => {
188
+ const page = await renderPage();
189
+ await aTimeout(40);
190
+
191
+ // The shell pushes an entry of its own for every document load, recording
192
+ // no tab of the page already on screen.
193
+ window.history.pushState({uri: window.location.pathname}, '', window.location.href);
194
+
195
+ await clickTab(page, 2);
196
+ expect(page.getAttribute('active')).to.equal('notes');
197
+
198
+ // Stepping onto the shell's entry says nothing about the tab, so the open
199
+ // one stays rather than the page dropping back to its first tab.
200
+ await goBack();
201
+ expect(page.getAttribute('active')).to.equal('notes');
202
+ });
203
+
204
+ it('keeps the tab history when the page is reloaded', async () => {
205
+ const page = await renderPage();
206
+ await aTimeout(40);
207
+ await clickTab(page, 1);
208
+ await clickTab(page, 2);
209
+ expect(page.getAttribute('active')).to.equal('notes');
210
+ page.remove();
211
+
212
+ // A reload replaces the document, and the shell pushes a fresh entry for it.
213
+ window.history.pushState({uri: window.location.pathname}, '', window.location.href);
214
+ window.dispatchEvent(new PopStateEvent('popstate'));
215
+
216
+ const reloadedPage = await renderPage();
217
+ await aTimeout(40);
218
+ expect(reloadedPage.getAttribute('active')).to.equal('notes');
219
+
220
+ await goBack();
221
+ expect(reloadedPage.getAttribute('active')).to.equal('notes');
222
+
223
+ await goBack();
224
+ expect(reloadedPage.getAttribute('active')).to.equal('billing');
225
+ });
226
+
227
+ it('steps straight back to the previous tab when the reloaded entry is kept', async () => {
228
+ const page = await renderPage();
229
+ await aTimeout(40);
230
+ await clickTab(page, 1);
231
+ await clickTab(page, 2);
232
+ expect(page.getAttribute('active')).to.equal('notes');
233
+ page.remove();
234
+
235
+ // The shell records the uri on the entry the document loaded on, rather
236
+ // than pushing a second entry for the same page.
237
+ const state = window.history.state as Record<string, unknown> | null;
238
+ window.history.replaceState({...state, uri: window.location.pathname}, '', window.location.href);
239
+ window.dispatchEvent(new PopStateEvent('popstate'));
240
+
241
+ const reloadedPage = await renderPage();
242
+ await aTimeout(40);
243
+ expect(reloadedPage.getAttribute('active')).to.equal('notes');
244
+
245
+ await goBack();
246
+ expect(reloadedPage.getAttribute('active')).to.equal('billing');
247
+
248
+ await goBack();
249
+ expect(reloadedPage.getAttribute('active')).to.equal('');
250
+ });
251
+
252
+ it('keeps the tab when the page is re-rendered at the same location', async () => {
253
+ const page = await renderPage();
254
+ await aTimeout(40);
255
+ await clickBilling(page);
256
+ page.remove();
257
+
258
+ const rerenderedPage = await renderPage();
259
+ await aTimeout(40);
260
+ expect(rerenderedPage.getAttribute('active')).to.equal('billing');
261
+ });
262
+
263
+ it('forgets the tab and its history once the page is navigated away from', async () => {
264
+ const page = await renderPage();
265
+ await aTimeout(40);
266
+ await clickBilling(page);
267
+ const pageLocation = window.location.search;
268
+ page.remove();
269
+
270
+ window.history.pushState({}, '', '?page-test=departed');
271
+ await aTimeout(40);
272
+
273
+ await goBack();
274
+ expect(window.location.search).to.equal(pageLocation);
275
+
276
+ const returnedPage = await renderPage();
277
+ await aTimeout(40);
278
+ expect(returnedPage.getAttribute('active')).to.equal('');
279
+ });
280
+
281
+ it('adds one history entry per tab, and none for the tab it opens on', async () => {
282
+ const lengthBeforeRender = window.history.length;
283
+ const page = await renderPage();
284
+ await aTimeout(40);
285
+ expect(window.history.length).to.equal(lengthBeforeRender);
286
+
287
+ await clickTab(page, 1);
288
+ expect(window.history.length).to.equal(lengthBeforeRender + 1);
289
+
290
+ // Reselecting the open tab is not a new step.
291
+ await clickTab(page, 1);
292
+ expect(window.history.length).to.equal(lengthBeforeRender + 1);
293
+ });
294
+ });
295
+
88
296
  it('creates uri tab panels from page navigation items', async () => {
89
297
  const el = await fixture<ZnPage>(html`
90
298
  <zn-page caption="Page Title">
@@ -142,6 +350,45 @@ describe('<zn-page>', () => {
142
350
  expect(innerDynamicPanel.hasAttribute('selected')).to.equal(true);
143
351
  });
144
352
 
353
+ it('keeps nested page tab selections scoped to the nested page', async () => {
354
+ const outerPage = await fixture<ZnPage>(html`
355
+ <zn-page caption="Outer Page">
356
+ <zn-tab caption="Outer One">
357
+ <zn-page caption="Inner Page" nested>
358
+ <zn-tab caption="Inner One">Inner One Content</zn-tab>
359
+ <zn-tab caption="Inner Two">Inner Two Content</zn-tab>
360
+ </zn-page>
361
+ </zn-tab>
362
+ <zn-tab caption="Outer Two">Outer Two Content</zn-tab>
363
+ </zn-page>
364
+ `);
365
+ await aTimeout(80);
366
+
367
+ const innerPage = outerPage.querySelector<ZnPage>('zn-page')!;
368
+ const outerNavbar = outerPage.shadowRoot!.querySelector('zn-navbar')!;
369
+ const innerNavbar = innerPage.shadowRoot!.querySelector('zn-navbar')!;
370
+ const innerSecondItem = innerNavbar.shadowRoot!.querySelectorAll<HTMLElement>('li:not(.more)')[1];
371
+
372
+ innerSecondItem.click();
373
+ await aTimeout(40);
374
+
375
+ expect(innerPage.getAttribute('active')).to.equal('inner-two');
376
+ expect(outerPage.getAttribute('active')).to.equal('outer-one');
377
+ expect(outerPage.shadowRoot!.querySelector('#outer-one')!.hasAttribute('selected')).to.equal(true);
378
+ expect(outerPage.shadowRoot!.querySelector('#outer-two')!.hasAttribute('selected')).to.equal(false);
379
+
380
+ // Even if a composed selection is delivered to an ancestor navbar, the
381
+ // ancestor page must ignore an item owned by the nested page.
382
+ outerNavbar.dispatchEvent(new CustomEvent('zn-select', {
383
+ bubbles: true,
384
+ composed: true,
385
+ detail: {item: innerSecondItem}
386
+ }));
387
+ await aTimeout(20);
388
+
389
+ expect(outerPage.getAttribute('active')).to.equal('outer-one');
390
+ });
391
+
145
392
  it('uses an explicit zn-tab for overview content', async () => {
146
393
  const el = await fixture<ZnPage>(html`
147
394
  <zn-page caption="Page Title">
@@ -1,4 +1,4 @@
1
- import {type CSSResultGroup, html, unsafeCSS} from 'lit';
1
+ import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
2
2
  import {MutationController} from '@lit-labs/observers/mutation-controller.js';
3
3
  import {property, query, state} from 'lit/decorators.js';
4
4
  import {styleMap} from 'lit/directives/style-map.js';
@@ -19,8 +19,10 @@ export type PreviewFrameDevice = keyof typeof DEVICE_WIDTHS;
19
19
  * protocol: answers the frame's ready handshake with a config payload fetched
20
20
  * from data-uri, auto-saves watched forms on change, refreshes the preview
21
21
  * after each save (its own, or a shell-driven save of a `refresh-on` form),
22
- * and accepts a theme payload via setTheme() that is retained and replayed
23
- * after every ready handshake.
22
+ * accepts a theme payload via setTheme() that is retained and replayed
23
+ * after every ready handshake, and grows the frame to a content height the
24
+ * embed reports so the panel scrolls an overflowing page.
25
+ *
24
26
  * @documentation https://zinc.style/components/preview-frame
25
27
  * @status experimental
26
28
  * @since 1.0
@@ -102,12 +104,34 @@ export default class ZnPreviewFrame extends ZincElement {
102
104
  /** Backdrop behind the stage: `dots` (default) is the canvas dot grid; `panel` is a plain `rgb(var(--zn-panel))` fill. */
103
105
  @property({reflect: true}) backdrop: 'dots' | 'panel' = 'dots';
104
106
 
107
+ /**
108
+ * Lets pointer input through to the embedded page. The preview is inert by
109
+ * default: clicks never reach the frame, so the previewed page can't be
110
+ * navigated or submitted from inside the preview. Cross-origin content can't
111
+ * be reached from here to cancel its own handlers, so this blocks pointer
112
+ * input entirely — hover goes with it. Scrolling doesn't: an overflowing
113
+ * page is scrolled by the panel rather than by the frame (see _contentHeight).
114
+ */
115
+ @property({type: Boolean, reflect: true}) interactive = false;
116
+
105
117
  @query('iframe') frame: HTMLIFrameElement;
106
118
 
107
119
  @state() private error = '';
108
120
 
121
+ /**
122
+ * Height of the embedded page's content, when it's known: reported by the
123
+ * embed (`height` on hp-preview:rendered, or an hp-preview:height message) or
124
+ * measured directly for a same-origin embed. The frame is laid out at this
125
+ * height rather than the panel's, so the page never scrolls inside the frame
126
+ * — the panel scrolls instead, which is what makes an overflowing preview
127
+ * reachable while pointer input to the frame is blocked. 0 = unknown, and the
128
+ * frame falls back to filling the panel.
129
+ */
130
+ @state() private _contentHeight = 0;
131
+
109
132
  private _generation = 0;
110
133
  private _theme: Record<string, unknown> | undefined;
134
+ private _contentObserver: ResizeObserver | undefined;
111
135
 
112
136
  private readonly _watchedForms = new Set<HTMLFormElement>();
113
137
  private readonly _refreshForms = new Set<HTMLFormElement>();
@@ -136,9 +160,15 @@ export default class ZnPreviewFrame extends ZincElement {
136
160
  }
137
161
  }
138
162
 
163
+ protected willUpdate(changed: PropertyValues<this>) {
164
+ if (changed.has('src')) this._resetContentHeight();
165
+ }
166
+
139
167
  disconnectedCallback() {
140
168
  super.disconnectedCallback();
141
169
  window.removeEventListener('message', this._onMessage);
170
+ this._contentObserver?.disconnect();
171
+ this._contentObserver = undefined;
142
172
  this._watchedForms.forEach(form => this._detachForm(form));
143
173
  this._watchedForms.clear();
144
174
  this._refreshForms.forEach(form => form.removeEventListener('complete', this._onShellSave));
@@ -150,7 +180,7 @@ export default class ZnPreviewFrame extends ZincElement {
150
180
  if (e.origin !== this.frameOrigin) return;
151
181
  if (!this.frame || e.source !== this.frame.contentWindow) return;
152
182
 
153
- const data = e.data as {type?: string; message?: string} | undefined;
183
+ const data = e.data as { type?: string; message?: string; height?: unknown } | undefined;
154
184
  switch (data?.type) {
155
185
  case 'hp-preview:ready':
156
186
  // config first: the embed applies the theme on top of a rendered page
@@ -158,6 +188,10 @@ export default class ZnPreviewFrame extends ZincElement {
158
188
  break;
159
189
  case 'hp-preview:rendered':
160
190
  this.error = '';
191
+ this._applyContentHeight(data.height);
192
+ break;
193
+ case 'hp-preview:height':
194
+ this._applyContentHeight(data.height);
161
195
  break;
162
196
  case 'hp-preview:error':
163
197
  this._fail(String(data.message ?? 'Preview failed to render'));
@@ -165,6 +199,41 @@ export default class ZnPreviewFrame extends ZincElement {
165
199
  }
166
200
  };
167
201
 
202
+ private _applyContentHeight(height: unknown) {
203
+ const value = Math.round(Number(height));
204
+ if (!Number.isFinite(value) || value <= 0) return;
205
+ this._contentHeight = value;
206
+ }
207
+
208
+ private _resetContentHeight() {
209
+ this._contentHeight = 0;
210
+ this._contentObserver?.disconnect();
211
+ this._contentObserver = undefined;
212
+ }
213
+
214
+ // Same-origin embeds don't need to implement the height half of the protocol —
215
+ // their document can be measured from here.
216
+ private readonly _onFrameLoad = () => {
217
+ this._contentObserver?.disconnect();
218
+ this._contentObserver = undefined;
219
+
220
+ let root: HTMLElement | null | undefined;
221
+ try {
222
+ root = this.frame?.contentDocument?.documentElement;
223
+ } catch {
224
+ return; // cross-origin: the embed has to report its own height
225
+ }
226
+ if (!root) return;
227
+
228
+ const measure = () => {
229
+ const measured = root.getBoundingClientRect().height;
230
+ if (measured > (this.frame?.clientHeight ?? 0)) this._applyContentHeight(measured);
231
+ };
232
+ measure();
233
+ this._contentObserver = new ResizeObserver(measure);
234
+ this._contentObserver.observe(root);
235
+ };
236
+
168
237
  /** Re-fetches the payload and pushes a fresh config to the preview. */
169
238
  refresh() {
170
239
  return this._sendConfig();
@@ -327,26 +396,33 @@ export default class ZnPreviewFrame extends ZincElement {
327
396
  // transformed back down, so the frame fills the panel while the content
328
397
  // renders smaller and more of the page is visible. Percentage width means
329
398
  // nothing is measured — no layout feedback loop.
399
+ const content = this.error ? 0 : this._contentHeight;
330
400
  const iframeStyles = this.fill
331
- ? {width: '100%', height: '100%'}
401
+ ? {width: '100%', height: content ? `max(${content}px, 100%)` : '100%'}
332
402
  : {
333
403
  width: `${100 / zoom}%`,
334
- height: `${this.minHeight / zoom}px`,
404
+ height: `${Math.max(content, this.minHeight / zoom)}px`,
335
405
  transform: `scale(${zoom})`,
336
406
  transformOrigin: '0 0',
337
407
  };
338
408
  const containerStyles = this.fill
339
409
  ? {height: '100%', minHeight: `${this.minHeight}px`}
340
410
  : {height: `${this.minHeight}px`};
411
+ const stageStyles: Record<string, string> = {
412
+ width: DEVICE_WIDTHS[this.device] ?? DEVICE_WIDTHS.desktop,
413
+ };
414
+ if (content) {
415
+ stageStyles.height = this.fill ? `max(${content}px, 100%)` : `${Math.max(content * zoom, this.minHeight)}px`;
416
+ }
341
417
 
342
418
  return html`
343
419
  <div part="base" class="preview" style="${styleMap(containerStyles)}">
344
- <div part="stage" class="preview__stage"
345
- style="${styleMap({width: DEVICE_WIDTHS[this.device] ?? DEVICE_WIDTHS.desktop})}">
420
+ <div part="stage" class="preview__stage" style="${styleMap(stageStyles)}">
346
421
  <iframe part="iframe"
347
422
  src="${this.src}"
348
423
  title="Payment form preview"
349
424
  allow="local-network-access"
425
+ @load="${this._onFrameLoad}"
350
426
  style="${styleMap(iframeStyles)}"></iframe>
351
427
  </div>
352
428
  ${this.error ? html`
@@ -11,7 +11,8 @@
11
11
  .preview {
12
12
  position: relative;
13
13
  width: 100%;
14
- overflow: hidden;
14
+ overflow-x: hidden;
15
+ overflow-y: auto;
15
16
 
16
17
  // Matches page-builder's canvas.
17
18
  background:
@@ -36,11 +37,16 @@ iframe {
36
37
  display: block;
37
38
  width: 100%;
38
39
  border: 0;
40
+ pointer-events: none;
39
41
  // Iframes are transparent by default, so without this the dot grid would show
40
42
  // through any embed that doesn't set its own background.
41
43
  background: rgb(var(--zn-body));
42
44
  }
43
45
 
46
+ :host([interactive]) iframe {
47
+ pointer-events: auto;
48
+ }
49
+
44
50
  .preview__error {
45
51
  position: absolute;
46
52
  inset: 0;