@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.
- package/dist/custom-elements.json +575 -91
- package/dist/vscode.html-custom-data.json +20 -15
- package/dist/web-types.json +37 -27
- package/dist/zn.d.ts +71 -5
- package/dist/zn.min.js +229 -229
- package/docs/data/preview-frame-payload-tall.json +7 -0
- package/docs/pages/components/preview-frame-demo.njk +45 -2
- package/docs/pages/components/preview-frame.md +79 -0
- package/docs/pages/components/tabs.md +9 -2
- package/package.json +1 -2
- package/scripts/build.js +5 -5
- package/src/components/defined-label/defined-label.component.ts +10 -17
- package/src/components/defined-label/defined-label.test.ts +107 -1
- package/src/components/expanding-action/expanding-action.component.ts +4 -2
- package/src/components/expanding-action/expanding-action.scss +14 -2
- package/src/components/expanding-action/expanding-action.test.ts +44 -1
- package/src/components/page/page.component.ts +71 -6
- package/src/components/page/page.test.ts +247 -0
- package/src/components/preview-frame/preview-frame.component.ts +84 -8
- package/src/components/preview-frame/preview-frame.scss +7 -1
- package/src/components/preview-frame/preview-frame.test.ts +184 -0
- package/src/components/tabs/tabs-navigation.ts +197 -0
- package/src/components/tabs/tabs.component.ts +188 -56
- package/src/components/tabs/tabs.test.ts +128 -0
|
@@ -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
|
-
*
|
|
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>`);
|