@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.
- package/dist/custom-elements.json +671 -174
- package/dist/vscode.html-custom-data.json +31 -26
- package/dist/web-types.json +55 -45
- package/dist/zn.d.ts +72 -5
- package/dist/zn.min.js +269 -247
- 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/icon-picker/icon-picker.component.ts +51 -20
- package/src/components/icon-picker/icon-picker.scss +71 -0
- 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
|
@@ -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
|
|
299
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
*
|
|
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;
|