@kubex/zinc 1.1.73 → 1.1.75

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.
@@ -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;
@@ -51,6 +51,13 @@ describe('<zn-preview-frame>', () => {
51
51
  window.fetch = realFetch;
52
52
  });
53
53
 
54
+ // ShadowRoot.elementFromPoint resolves inside the shadow tree, where
55
+ // document.elementFromPoint would only ever hand back the host.
56
+ function hitTest(el: Element, target: Element) {
57
+ const {left, top, width, height} = target.getBoundingClientRect();
58
+ return el.shadowRoot!.elementFromPoint(left + width / 2, top + height / 2);
59
+ }
60
+
54
61
  function ready(el: Element, origin = 'https://site.example') {
55
62
  const iframe = el.shadowRoot!.querySelector('iframe')!;
56
63
  window.dispatchEvent(new MessageEvent('message', {
@@ -501,6 +508,183 @@ describe('<zn-preview-frame>', () => {
501
508
  expect(getComputedStyle(preview).backgroundImage).to.equal('none');
502
509
  });
503
510
 
511
+ it('takes no pointer input by default, so nothing clicks through to the embed', async () => {
512
+ const el = await fixture(FIXTURE);
513
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
514
+ expect(getComputedStyle(iframe).pointerEvents).to.equal('none');
515
+ // hit-testing inside the shadow root: a click over the frame lands on the
516
+ // stage behind it, never the iframe
517
+ expect(hitTest(el, iframe)).to.not.equal(iframe);
518
+ });
519
+
520
+ it('lets pointer input through when interactive is set', async () => {
521
+ const el = await fixture(html`
522
+ <zn-preview-frame
523
+ src="about:blank"
524
+ frame-origin="https://site.example"
525
+ data-uri="/payload"
526
+ interactive></zn-preview-frame>`);
527
+
528
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
529
+ expect(getComputedStyle(iframe).pointerEvents).to.equal('auto');
530
+ expect(hitTest(el, iframe)).to.equal(iframe);
531
+ });
532
+
533
+ describe('overflowing content', () => {
534
+ function reportHeight(el: Element, height: unknown, type = 'hp-preview:rendered') {
535
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
536
+ window.dispatchEvent(new MessageEvent('message', {
537
+ data: {type, height},
538
+ origin: 'https://site.example',
539
+ source: iframe.contentWindow
540
+ }));
541
+ }
542
+
543
+ it('scrolls the panel rather than the frame when the embed reports a taller page', async () => {
544
+ const el = await fixture(FIXTURE);
545
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
546
+ const panel = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
547
+ const stage = el.shadowRoot!.querySelector<HTMLDivElement>('.preview__stage')!;
548
+
549
+ reportHeight(el, 1400);
550
+ await waitUntil(() => iframe.style.height === '1400px');
551
+
552
+ // the frame is laid out at full content height, so it never scrolls itself
553
+ expect(stage.style.height).to.equal('1400px');
554
+ expect(panel.style.height).to.equal('480px');
555
+ expect(panel.scrollHeight).to.be.greaterThan(panel.clientHeight);
556
+
557
+ // a wheel over the frame hit-tests to the stage, whose only user-scrollable
558
+ // ancestor is the panel — so the gesture scrolls the panel
559
+ const hit = hitTest(el, panel);
560
+ expect(hit).to.not.equal(iframe);
561
+ expect(panel.contains(hit!)).to.equal(true);
562
+ expect(getComputedStyle(stage).overflowY).to.equal('hidden');
563
+
564
+ panel.scrollTop = 200;
565
+ expect(panel.scrollTop).to.equal(200);
566
+ });
567
+
568
+ it('scrolls vertically only, so a zoomed-out frame gets no horizontal bar', async () => {
569
+ const el = await fixture(html`
570
+ <zn-preview-frame
571
+ src="about:blank"
572
+ frame-origin="https://site.example"
573
+ data-uri="/payload"
574
+ zoom="0.5"
575
+ min-height="400"></zn-preview-frame>`);
576
+
577
+ const panel = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
578
+ expect(getComputedStyle(panel).overflowX).to.equal('hidden');
579
+ expect(getComputedStyle(panel).overflowY).to.equal('auto');
580
+
581
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
582
+ reportHeight(el, 1600);
583
+ await waitUntil(() => iframe.style.height === '1600px');
584
+
585
+ // the stage tracks the frame's *visible* height (1600 × 0.5), so scrolling
586
+ // stops at the end of the page instead of at the oversized layout box
587
+ const stage = el.shadowRoot!.querySelector<HTMLDivElement>('.preview__stage')!;
588
+ expect(stage.style.height).to.equal('800px');
589
+ expect(panel.scrollWidth).to.equal(panel.clientWidth);
590
+ });
591
+
592
+ it('accepts a later hp-preview:height message when the page grows', async () => {
593
+ const el = await fixture(FIXTURE);
594
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
595
+
596
+ reportHeight(el, 900);
597
+ await waitUntil(() => iframe.style.height === '900px');
598
+
599
+ reportHeight(el, 1300, 'hp-preview:height');
600
+ await waitUntil(() => iframe.style.height === '1300px');
601
+ });
602
+
603
+ it('keeps filling the panel for a page shorter than it', async () => {
604
+ const el = await fixture(FIXTURE);
605
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
606
+ const panel = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
607
+
608
+ reportHeight(el, 120);
609
+ await new Promise(resolve => setTimeout(resolve, 50));
610
+
611
+ expect(iframe.style.height).to.equal('480px');
612
+ expect(panel.scrollHeight).to.equal(panel.clientHeight);
613
+ });
614
+
615
+ it('ignores a height that is missing, zero, negative or not a number', async () => {
616
+ const el = await fixture(FIXTURE);
617
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
618
+
619
+ for (const height of [undefined, 0, -400, 'tall', NaN, Infinity]) {
620
+ reportHeight(el, height);
621
+ await new Promise(resolve => setTimeout(resolve, 20));
622
+ expect(iframe.style.height, `height: ${String(height)}`).to.equal('480px');
623
+ }
624
+ });
625
+
626
+ it('ignores the reported height while the error overlay is up', async () => {
627
+ const el = await fixture(FIXTURE);
628
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
629
+
630
+ reportHeight(el, 1400);
631
+ await waitUntil(() => iframe.style.height === '1400px');
632
+
633
+ reportHeight(el, 1400, 'hp-preview:error');
634
+ await waitUntil(() => el.shadowRoot!.querySelector('[part="error"]'));
635
+ expect(iframe.style.height).to.equal('480px');
636
+
637
+ // clears again once the embed reports a good render
638
+ reportHeight(el, 1400);
639
+ await waitUntil(() => iframe.style.height === '1400px');
640
+ });
641
+
642
+ it('drops the reported height when src changes', async () => {
643
+ const el = await fixture(FIXTURE);
644
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
645
+
646
+ reportHeight(el, 1400);
647
+ await waitUntil(() => iframe.style.height === '1400px');
648
+
649
+ (el as HTMLElement & {src: string}).src = 'about:blank?next';
650
+ await waitUntil(() => iframe.style.height === '480px');
651
+ });
652
+
653
+ it('measures a same-origin embed without it reporting anything', async () => {
654
+ const src = URL.createObjectURL(new Blob([
655
+ '<!doctype html><html><body style="margin:0"><div style="height:1200px"></div></body></html>'
656
+ ], {type: 'text/html'}));
657
+
658
+ const el = await fixture(html`
659
+ <zn-preview-frame src="${src}" frame-origin="https://site.example"></zn-preview-frame>`);
660
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
661
+
662
+ await waitUntil(() => parseInt(iframe.style.height, 10) >= 1200, 'never measured the embed');
663
+ const panel = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
664
+ expect(panel.scrollHeight).to.be.greaterThan(panel.clientHeight);
665
+ URL.revokeObjectURL(src);
666
+ });
667
+
668
+ it('fills the panel when a fill frame has no known content height', async () => {
669
+ const el = await fixture(html`
670
+ <zn-preview-frame
671
+ src="about:blank"
672
+ frame-origin="https://site.example"
673
+ data-uri="/payload"
674
+ fill></zn-preview-frame>`);
675
+
676
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
677
+ expect(iframe.style.height).to.equal('100%');
678
+
679
+ reportHeight(el, 1500);
680
+ await waitUntil(() => iframe.style.height !== '100%');
681
+ // a floor of the panel height, so a short page still fills a stretched column
682
+ expect(iframe.style.height).to.equal('max(1500px, 100%)');
683
+ const stage = el.shadowRoot!.querySelector<HTMLDivElement>('.preview__stage')!;
684
+ expect(stage.style.height).to.equal('max(1500px, 100%)');
685
+ });
686
+ });
687
+
504
688
  it('skips the config fetch when data-uri is empty', async () => {
505
689
  const el = await fixture(html`
506
690
  <zn-preview-frame src="about:blank" frame-origin="https://site.example"></zn-preview-frame>`);