@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.
@@ -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>`);
@@ -0,0 +1,197 @@
1
+ // A tab selection belongs to a visit: one starts the first time a tab is
2
+ // recorded at a location and ends when that location is left. A reload continues
3
+ // the visit it interrupted, so the tab stays open and Back still steps through
4
+ // the tabs the visit opened; navigating away ends it, so returning starts from
5
+ // the default tab with no tab history behind it.
6
+ //
7
+ // Selections are remembered in two places, because neither is sufficient alone:
8
+ //
9
+ // - Every history entry carries the tab each container was showing when the
10
+ // entry was created, so a tab change is its own Back step. Entries are always
11
+ // merged into, never replaced, so nested pages keep their own records.
12
+ // - Session storage keyed by location survives a reload, which history state
13
+ // does not: the console pushes a fresh `{uri}` state on every document load,
14
+ // discarding whatever the reloaded entry held before a page can read it.
15
+ //
16
+ // Both name the visit they were written for. Ending a visit deletes its stored
17
+ // tabs outright; the tabs left on its history entries cannot be rewritten, so
18
+ // they are retired instead - the visit they name no longer exists.
19
+
20
+ export const TAB_STORE_PREFIX = 'zntab:';
21
+
22
+ const RESTORING_NAVIGATION_TYPES = ['reload', 'back_forward'];
23
+
24
+ const VISIT_STORE_KEY = '__znTabsVisit';
25
+
26
+ const TABS_HISTORY_KEY = '__znTabs';
27
+
28
+ interface TabsHistoryRecord {
29
+ visit: string;
30
+ tabs: Record<string, string>;
31
+ }
32
+
33
+ interface TabsHistoryState {
34
+ [TABS_HISTORY_KEY]?: TabsHistoryRecord;
35
+ }
36
+
37
+ const restorableLocations = new Set<string>();
38
+
39
+ let visitCount = 0;
40
+
41
+ function sessionStore(): Storage | null {
42
+ try {
43
+ return window.sessionStorage;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function locationKey(): string {
50
+ return window.location.pathname + window.location.search;
51
+ }
52
+
53
+ function documentNavigationType(): string {
54
+ const entries = window.performance?.getEntriesByType('navigation') as PerformanceNavigationTiming[] | undefined;
55
+ return entries?.[0]?.type ?? '';
56
+ }
57
+
58
+ /** Scopes a store key to the current location, so each page keeps its own tab. */
59
+ export function locationScopedKey(key: string): string {
60
+ return `${key}@${locationKey()}`;
61
+ }
62
+
63
+ function visitStoreKey(): string {
64
+ return TAB_STORE_PREFIX + locationScopedKey(VISIT_STORE_KEY);
65
+ }
66
+
67
+ /** The visit the current location is on, or an empty string before one starts. */
68
+ function currentVisit(): string {
69
+ return sessionStore()?.getItem(visitStoreKey()) ?? '';
70
+ }
71
+
72
+ // Starts a visit for the current location, unless one is already under way - a
73
+ // reload lands mid visit and must continue it rather than begin a new one.
74
+ function startLocationVisit(): void {
75
+ const store = sessionStore();
76
+ if (store === null || currentVisit() !== '') {
77
+ return;
78
+ }
79
+
80
+ visitCount += 1;
81
+ store.setItem(visitStoreKey(), `${Date.now()}-${visitCount}`);
82
+ }
83
+
84
+ /**
85
+ * Ends the visits to every location other than the one on screen, discarding
86
+ * the tabs they were left showing. Called whenever the location may have
87
+ * changed, so the only tabs ever remembered are the current page's.
88
+ */
89
+ export function endVisitsToOtherLocations(): void {
90
+ const store = sessionStore();
91
+ if (store === null) {
92
+ return;
93
+ }
94
+
95
+ const suffix = `@${locationKey()}`;
96
+ for (let index = store.length - 1; index >= 0; index--) {
97
+ const key = store.key(index);
98
+ if (key !== null && key.startsWith(TAB_STORE_PREFIX) && !key.endsWith(suffix)) {
99
+ store.removeItem(key);
100
+ }
101
+ }
102
+ }
103
+
104
+ if (RESTORING_NAVIGATION_TYPES.includes(documentNavigationType())) {
105
+ restorableLocations.add(locationKey());
106
+ }
107
+
108
+ // A document load lands on the only location still worth remembering: every
109
+ // other one was navigated away from, whether or not a page was around to see it.
110
+ endVisitsToOtherLocations();
111
+
112
+ window.addEventListener('popstate', () => {
113
+ restorableLocations.add(locationKey());
114
+ endVisitsToOtherLocations();
115
+ }, {passive: true});
116
+
117
+ /**
118
+ * Whether the current location was reached in a way that should replay the tab
119
+ * it was last left on: a reload, or a history traversal. A fresh navigation -
120
+ * including a client side one to a location visited earlier - returns false.
121
+ */
122
+ export function isRestorableLocation(): boolean {
123
+ return restorableLocations.has(locationKey());
124
+ }
125
+
126
+ function asRecord(value: unknown): Record<string, unknown> | null {
127
+ return value !== null && typeof value === 'object' ? value as Record<string, unknown> : null;
128
+ }
129
+
130
+ function historyRecord(): TabsHistoryRecord | null {
131
+ const state = asRecord(window.history.state);
132
+ const record = state === null ? null : asRecord((state as TabsHistoryState)[TABS_HISTORY_KEY]);
133
+ const tabs = record === null ? null : asRecord(record.tabs);
134
+
135
+ if (record === null || tabs === null || typeof record.visit !== 'string') {
136
+ return null;
137
+ }
138
+
139
+ return {visit: record.visit, tabs: tabs as Record<string, string>};
140
+ }
141
+
142
+ /** The tab the current history entry was left showing, if it recorded one for the visit under way. */
143
+ export function getHistoryTab(key: string): string | null {
144
+ if (!key) {
145
+ return null;
146
+ }
147
+
148
+ const visit = currentVisit();
149
+ const record = historyRecord();
150
+ if (visit === '' || record === null || record.visit !== visit) {
151
+ return null;
152
+ }
153
+
154
+ const tab = record.tabs[key];
155
+ return typeof tab === 'string' ? tab : null;
156
+ }
157
+
158
+ // The host's own state is carried over so its entry stays intact - the console
159
+ // reads `state.uri` on popstate - and the url is left exactly as it is.
160
+ function writeHistoryTab(key: string, tab: string, push: boolean): void {
161
+ startLocationVisit();
162
+
163
+ const visit = currentVisit();
164
+ if (!key || visit === '') {
165
+ return;
166
+ }
167
+
168
+ const record = historyRecord();
169
+ const tabs = record !== null && record.visit === visit ? record.tabs : {};
170
+
171
+ // Re-recording what the entry already says would be a wasted history write,
172
+ // and browsers cap how many of those a page may make.
173
+ if (!push && tabs[key] === tab) {
174
+ return;
175
+ }
176
+
177
+ const state = {
178
+ ...asRecord(window.history.state),
179
+ [TABS_HISTORY_KEY]: {visit, tabs: {...tabs, [key]: tab}}
180
+ };
181
+
182
+ if (push) {
183
+ window.history.pushState(state, '', window.location.href);
184
+ } else {
185
+ window.history.replaceState(state, '');
186
+ }
187
+ }
188
+
189
+ /** Adds a history entry for a tab change, making it its own Back step. */
190
+ export function pushHistoryTab(key: string, tab: string): void {
191
+ writeHistoryTab(key, tab, true);
192
+ }
193
+
194
+ /** Records the tab on the current entry without adding a Back step. */
195
+ export function replaceHistoryTab(key: string, tab: string): void {
196
+ writeHistoryTab(key, tab, false);
197
+ }