@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.
@@ -1,11 +1,20 @@
1
- import { type CSSResultGroup, html, type PropertyValues, unsafeCSS } from 'lit';
2
- import { deepQuerySelectorAll } from "../../utilities/query";
3
- import { HasSlotController } from "../../internal/slot";
4
- import { ifDefined } from "lit/directives/if-defined.js";
5
- import { md5 } from "../../utilities/md5";
6
- import { MutationController } from '@lit-labs/observers/mutation-controller.js';
7
- import { property } from 'lit/decorators.js';
8
- import { Store } from "../../internal/storage";
1
+ import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
2
+ import {deepQuerySelectorAll} from "../../utilities/query";
3
+ import {
4
+ endVisitsToOtherLocations,
5
+ getHistoryTab,
6
+ isRestorableLocation,
7
+ locationScopedKey,
8
+ pushHistoryTab,
9
+ replaceHistoryTab,
10
+ TAB_STORE_PREFIX
11
+ } from './tabs-navigation';
12
+ import {HasSlotController} from "../../internal/slot";
13
+ import {ifDefined} from "lit/directives/if-defined.js";
14
+ import {md5} from "../../utilities/md5";
15
+ import {MutationController} from '@lit-labs/observers/mutation-controller.js';
16
+ import {property} from 'lit/decorators.js';
17
+ import {Store} from "../../internal/storage";
9
18
  import ZincElement from '../../internal/zinc-element';
10
19
 
11
20
  import styles from './tabs.scss';
@@ -34,25 +43,25 @@ const tabContainerSelector = 'zn-tabs, zn-page, zn-page-nav';
34
43
  */
35
44
  export default class ZnTabs extends ZincElement {
36
45
  static styles: CSSResultGroup = unsafeCSS(styles);
37
- @property({ attribute: 'master-id', reflect: true }) masterId: string;
38
- @property({ attribute: 'default-uri', reflect: true }) defaultUri = '';
39
- @property({ attribute: 'active', reflect: true }) _current = '';
40
- @property({ attribute: 'split', type: Number, reflect: true }) _split: number;
41
- @property({ attribute: 'split-min', type: Number, reflect: true }) _splitMin = 60;
42
- @property({ attribute: 'split-min-secondary', type: Number, reflect: true }) _splitMinSecondary: number;
43
- @property({ attribute: 'split-max', type: Number, reflect: true }) _splitMax: number;
44
- @property({ attribute: 'primary-caption', reflect: true }) primaryCaption = 'Navigation';
45
- @property({ attribute: 'secondary-caption', reflect: true }) secondaryCaption = 'Content';
46
- @property({ attribute: 'no-prefetch', type: Boolean, reflect: true }) noPrefetch = false;
47
- @property({ attribute: 'no-cache', type: Boolean, reflect: true }) noCache = false;
46
+ @property({attribute: 'master-id', reflect: true}) masterId: string;
47
+ @property({attribute: 'default-uri', reflect: true}) defaultUri = '';
48
+ @property({attribute: 'active', reflect: true}) _current = '';
49
+ @property({attribute: 'split', type: Number, reflect: true}) _split: number;
50
+ @property({attribute: 'split-min', type: Number, reflect: true}) _splitMin = 60;
51
+ @property({attribute: 'split-min-secondary', type: Number, reflect: true}) _splitMinSecondary: number;
52
+ @property({attribute: 'split-max', type: Number, reflect: true}) _splitMax: number;
53
+ @property({attribute: 'primary-caption', reflect: true}) primaryCaption = 'Navigation';
54
+ @property({attribute: 'secondary-caption', reflect: true}) secondaryCaption = 'Content';
55
+ @property({attribute: 'no-prefetch', type: Boolean, reflect: true}) noPrefetch = false;
56
+ @property({attribute: 'no-cache', type: Boolean, reflect: true}) noCache = false;
48
57
  // session storage if not local
49
- @property({ attribute: 'local-storage', type: Boolean, reflect: true }) localStorage: boolean;
50
- @property({ attribute: 'store-key' }) storeKey: string;
51
- @property({ attribute: 'store-ttl', type: Number, reflect: true }) storeTtl = 0;
52
- @property({ attribute: 'padded', type: Boolean, reflect: true }) padded = false;
53
- @property({ attribute: 'fetch-style', type: String, reflect: true }) fetchStyle = "";
54
- @property({ attribute: 'full-width', type: Boolean, reflect: true }) fullWidth = false;
55
- @property({ attribute: 'padded-right', type: Boolean, reflect: true }) paddedRight = false;
58
+ @property({attribute: 'local-storage', type: Boolean, reflect: true}) localStorage: boolean;
59
+ @property({attribute: 'store-key'}) storeKey: string;
60
+ @property({attribute: 'store-ttl', type: Number, reflect: true}) storeTtl = 0;
61
+ @property({attribute: 'padded', type: Boolean, reflect: true}) padded = false;
62
+ @property({attribute: 'fetch-style', type: String, reflect: true}) fetchStyle = "";
63
+ @property({attribute: 'full-width', type: Boolean, reflect: true}) fullWidth = false;
64
+ @property({attribute: 'padded-right', type: Boolean, reflect: true}) paddedRight = false;
56
65
  @property() monitor: string;
57
66
  // Creating a header
58
67
  @property() caption: string;
@@ -66,11 +75,12 @@ export default class ZnTabs extends ZincElement {
66
75
  private _tabs: HTMLElement[] = [];
67
76
  private _actions: HTMLElement[] = [];
68
77
  private _knownUri: Map<string, string> = new Map<string, string>();
78
+ private _defaultTab = '';
69
79
  private readonly hasSlotController = new HasSlotController(this, '[default]', 'bottom', 'right', 'left', 'top', 'actions');
70
80
 
71
81
  private readonly _domObserver = new MutationController(this, {
72
82
  target: null,
73
- config: { childList: true, subtree: true },
83
+ config: {childList: true, subtree: true},
74
84
  callback: mutations => {
75
85
  mutations.forEach(mutation => {
76
86
  if (mutation.type !== 'childList') return;
@@ -89,17 +99,16 @@ export default class ZnTabs extends ZincElement {
89
99
 
90
100
  private readonly _monitorObserver = new MutationController(this, {
91
101
  target: null,
92
- config: { childList: true, subtree: true },
102
+ config: {childList: true, subtree: true},
93
103
  callback: mutations => {
94
104
  mutations.forEach(mutation => {
95
105
  if (mutation.type !== 'childList') return;
96
106
  mutation.addedNodes.forEach(node => {
97
107
  if (node instanceof HTMLElement && node.id === this.monitor) {
98
108
  this.reRegisterTabs();
99
- const storedValue = this._store.get(this.storeKey);
109
+ const storedValue = this.getStoredTab();
100
110
  if (storedValue !== null) {
101
- this._prepareTab(storedValue);
102
- this.setActiveTab(storedValue, false, false);
111
+ this.restoreStoredTab(storedValue);
103
112
  }
104
113
  }
105
114
  });
@@ -133,7 +142,7 @@ export default class ZnTabs extends ZincElement {
133
142
 
134
143
  const defaultID = this.defaultUri ? this._uriToId(this.defaultUri) : '';
135
144
 
136
- this._store = new Store(this.localStorage ? window.localStorage : window.sessionStorage, "zntab:", this.storeTtl);
145
+ this._store = new Store(this.localStorage ? window.localStorage : window.sessionStorage, TAB_STORE_PREFIX, this.storeTtl);
137
146
  Array.from(this.children).forEach((element) => {
138
147
  if (element.slot === '') {
139
148
  this._panels.set(element.getAttribute('id') || defaultID, [element]);
@@ -142,6 +151,21 @@ export default class ZnTabs extends ZincElement {
142
151
 
143
152
  this.observerDom();
144
153
  this.monitorDom();
154
+ window.addEventListener('popstate', this.handlePopState, {passive: true});
155
+ }
156
+
157
+ disconnectedCallback() {
158
+ super.disconnectedCallback();
159
+ window.removeEventListener('popstate', this.handlePopState);
160
+
161
+ // The container is torn down before the url it is being replaced by is
162
+ // pushed, so the check waits a task: by then a navigation has changed the
163
+ // location and this visit is over, while a re-render is back on screen.
164
+ setTimeout(() => {
165
+ if (!this.isConnected) {
166
+ endVisitsToOtherLocations();
167
+ }
168
+ });
145
169
  }
146
170
 
147
171
  monitorDom() {
@@ -150,6 +174,116 @@ export default class ZnTabs extends ZincElement {
150
174
  }
151
175
  }
152
176
 
177
+ /** The key the active tab is persisted under. Null disables persistence. */
178
+ protected getTabStoreKey(): string | null {
179
+ return this.storeKey || null;
180
+ }
181
+
182
+ protected getStoredTab(): string | null {
183
+ const key = this.getTabStoreKey();
184
+ if (key === null || !this._store) {
185
+ return null;
186
+ }
187
+
188
+ // Local storage persists across fresh navigation and browser sessions.
189
+ if (this.localStorage) {
190
+ return this._store.get(key);
191
+ }
192
+
193
+ // Nothing is remembered for a location that has been navigated away from:
194
+ // its stored tab went with the visit, and so did the tabs on its entries.
195
+ const stored = this._store.get(locationScopedKey(key));
196
+ if (stored === null) {
197
+ return null;
198
+ }
199
+
200
+ // The history entry is authoritative where it survived; a reload wipes it,
201
+ // leaving the selection the location was last left on.
202
+ const fromHistory = getHistoryTab(key);
203
+ if (fromHistory !== null) {
204
+ return fromHistory;
205
+ }
206
+
207
+ return isRestorableLocation() ? stored : null;
208
+ }
209
+
210
+ protected storeActiveTab(tabName: string) {
211
+ const key = this.getTabStoreKey();
212
+ if (key === null || !this._store) {
213
+ return;
214
+ }
215
+
216
+ // Uri tab ids are derived from the master id, so store the uri itself.
217
+ const value = this.getUriForTabId(tabName) ?? tabName;
218
+ this._store.set(this.localStorage ? key : locationScopedKey(key), value);
219
+ if (!this.localStorage) {
220
+ replaceHistoryTab(key, value);
221
+ }
222
+ }
223
+
224
+ protected restoreStoredTab(stored: string) {
225
+ const uriTab = this._tabs.find(tab => tab.getAttribute('tab-uri') === stored);
226
+ if (uriTab) {
227
+ this.clickTab(uriTab, false);
228
+ return;
229
+ }
230
+
231
+ this._prepareTab(stored);
232
+ this.setActiveTab(stored, true, false);
233
+ }
234
+
235
+ /** Activates the tab the container starts on, ignoring any stored selection. */
236
+ protected activateDefaultTab() {
237
+ if (!this._panels.has(this._defaultTab) && this._tabs.length > 0) {
238
+ const tabUri = this._tabs[0].getAttribute('tab-uri');
239
+ if (tabUri) {
240
+ this.clickTab(this._tabs[0], false);
241
+ return;
242
+ }
243
+ }
244
+
245
+ this.setActiveTab(this._defaultTab, true, false);
246
+ }
247
+
248
+ // Back and forward move between recorded tabs.
249
+ private readonly handlePopState = () => {
250
+ const key = this.getTabStoreKey();
251
+ if (key === null) {
252
+ return;
253
+ }
254
+
255
+ // Only entries that name a tab move the container. Entries that say nothing
256
+ // about it - the extra one the console pushes for every document load, one
257
+ // pushed before this container was on screen, one belonging to another page,
258
+ // one left by a visit that has ended - leave the open tab where it is.
259
+ // Overwriting it with the default tab would show the first tab on the way
260
+ // through, in place of the tab the entry being stepped onto stands for.
261
+ const tab = getHistoryTab(key);
262
+ if (tab === null) {
263
+ return;
264
+ }
265
+
266
+ this.restoreStoredTab(tab);
267
+ };
268
+
269
+ private pushTabHistory(tabName: string) {
270
+ const key = this.getTabStoreKey();
271
+ if (key === null || this.localStorage) {
272
+ return;
273
+ }
274
+
275
+ pushHistoryTab(key, this.getUriForTabId(tabName) ?? tabName);
276
+ }
277
+
278
+ private getUriForTabId(tabId: string): string | null {
279
+ for (const [uri, id] of this._knownUri) {
280
+ if (id === tabId) {
281
+ return uri;
282
+ }
283
+ }
284
+ return null;
285
+ }
286
+
153
287
  _addPanel(panel: HTMLElement) {
154
288
  if (this._panels.has(panel.getAttribute('id')!)) {
155
289
  return;
@@ -176,29 +310,20 @@ export default class ZnTabs extends ZincElement {
176
310
  super.firstUpdated(_changedProperties);
177
311
  setTimeout(() => {
178
312
  this._registerTabs();
313
+ this._defaultTab = this._current || '';
179
314
 
180
- const storedValue = this._store.get(this.storeKey);
315
+ const storedValue = this.getStoredTab();
181
316
  if (storedValue !== null) {
182
- this._prepareTab(storedValue);
183
- this.setActiveTab(storedValue, false, false);
317
+ this.restoreStoredTab(storedValue);
184
318
  return;
185
319
  }
186
320
 
187
- const defaultTab = this._current || '';
188
- if (!this._panels.has(defaultTab) && this._tabs.length > 0) {
189
- const tabUri = this._tabs[0].getAttribute('tab-uri');
190
- if (tabUri) {
191
- this.clickTab(this._tabs[0], false);
192
- return;
193
- }
194
- }
195
-
196
- this.setActiveTab(defaultTab, false, false);
321
+ this.activateDefaultTab();
197
322
  }, 10);
198
323
 
199
324
  this.addEventListener('zn-menu-select', () => {
200
325
  setTimeout(this.reRegisterTabs, 200);
201
- }, { passive: true });
326
+ }, {passive: true});
202
327
  }
203
328
 
204
329
  switchTab(inc: number) {
@@ -215,7 +340,7 @@ export default class ZnTabs extends ZincElement {
215
340
  nextIndex = 0; // wrap around to the first tab
216
341
  }
217
342
  const nextTabId = Array.from(this._panels.keys())[nextIndex];
218
- this.setActiveTab(nextTabId, true, false);
343
+ this.setActiveTab(nextTabId, true, false, null, true);
219
344
  }
220
345
 
221
346
  nextTab() {
@@ -277,7 +402,7 @@ export default class ZnTabs extends ZincElement {
277
402
  }
278
403
 
279
404
  document.dispatchEvent(new CustomEvent('zn-new-element', {
280
- detail: { element: tabNode, source: tabEle }
405
+ detail: {element: tabNode, source: tabEle}
281
406
  }));
282
407
  return tabNode;
283
408
  }
@@ -288,9 +413,9 @@ export default class ZnTabs extends ZincElement {
288
413
  if (target) {
289
414
  if ('startViewTransition' in document) {
290
415
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
291
- (document as any).startViewTransition(() => this.clickTab(target, event.altKey));
416
+ (document as any).startViewTransition(() => this.clickTab(target, event.altKey, true));
292
417
  } else {
293
- this.clickTab(target, event.altKey)
418
+ this.clickTab(target, event.altKey, true)
294
419
  }
295
420
  }
296
421
  }
@@ -303,7 +428,7 @@ export default class ZnTabs extends ZincElement {
303
428
  }
304
429
  }
305
430
 
306
- clickTab(target: HTMLElement, refresh: boolean) {
431
+ clickTab(target: HTMLElement, refresh: boolean, pushHistory = false) {
307
432
  const tabUri = target.getAttribute('tab-uri');
308
433
  const wasCached = !!tabUri && this._panels.has(this._uriToId(tabUri));
309
434
  this.fetchUriTab(target);
@@ -311,7 +436,7 @@ export default class ZnTabs extends ZincElement {
311
436
  if (target.hasAttribute('tab')) {
312
437
  const forceRefresh = refresh || (this.noCache && wasCached);
313
438
  setTimeout(() => {
314
- this.setActiveTab(target.getAttribute('tab') || '', true, forceRefresh, this.getRefTab(target));
439
+ this.setActiveTab(target.getAttribute('tab') || '', true, forceRefresh, this.getRefTab(target), pushHistory);
315
440
  }, 10);
316
441
  }
317
442
  }
@@ -331,7 +456,8 @@ export default class ZnTabs extends ZincElement {
331
456
  return null;
332
457
  }
333
458
 
334
- setActiveTab(tabName: string, store: boolean, refresh: boolean, refTab: string | null = null) {
459
+ setActiveTab(tabName: string, store: boolean, refresh: boolean, refTab: string | null = null, pushHistory = false) {
460
+ const previous = this._current;
335
461
  let hasActive = false;
336
462
  this._tabs.forEach(tab => {
337
463
 
@@ -355,8 +481,14 @@ export default class ZnTabs extends ZincElement {
355
481
  //Set on the element as a failsafe before TabPanel is loaded
356
482
  //This must be done AFTER selectTab to avoid panel bugs
357
483
 
358
- if (store && this._store !== null) {
359
- this._store.set(this.storeKey, tabName);
484
+ // The entry must be pushed before the tab is recorded, so the entry being
485
+ // left keeps the tab it was showing.
486
+ if (pushHistory && this._current === tabName && previous !== tabName) {
487
+ this.pushTabHistory(tabName);
488
+ }
489
+
490
+ if (store) {
491
+ this.storeActiveTab(tabName);
360
492
  }
361
493
  }
362
494
 
@@ -402,7 +534,7 @@ export default class ZnTabs extends ZincElement {
402
534
  gaid = this._activeTab.getAttribute('gaid')!;
403
535
  }
404
536
  document.dispatchEvent(new CustomEvent('zn-refresh-element', {
405
- detail: { element: element, uri: uri, gaid: gaid }
537
+ detail: {element: element, uri: uri, gaid: gaid}
406
538
  }));
407
539
  }
408
540
  });
@@ -1,5 +1,6 @@
1
1
  import '../../../dist/zn.min.js';
2
2
  import { aTimeout, expect, fixture, html } from '@open-wc/testing';
3
+ import { locationScopedKey } from './tabs-navigation';
3
4
  import type ZnTabs from './tabs.component';
4
5
 
5
6
  describe('<zn-tabs>', () => {
@@ -48,4 +49,131 @@ describe('<zn-tabs>', () => {
48
49
  const selectedPagePanel = page.shadowRoot!.querySelector<HTMLElement>('#content > div[selected]')!;
49
50
  expect(selectedPagePanel.id).to.equal('');
50
51
  });
52
+
53
+ describe('selection persistence', () => {
54
+ const storeKey = 'tabs-navigation-test';
55
+ const originalHref = window.location.href;
56
+ let locationCount = 0;
57
+
58
+ const renderTabs = () => fixture<ZnTabs>(html`
59
+ <zn-tabs store-key=${storeKey} active="first">
60
+ <zn-navbar slot="top">
61
+ <li tab="first">First</li>
62
+ <li tab="second">Second</li>
63
+ </zn-navbar>
64
+ <div id="first">First panel</div>
65
+ <div id="second">Second panel</div>
66
+ </zn-tabs>
67
+ `);
68
+
69
+ // Each test runs on its own location so a stored selection, and whether that
70
+ // location may restore one, cannot leak between them.
71
+ const navigateTo = (name: string) => window.history.pushState({}, '', `?tabs-test=${name}`);
72
+ const returnWithHistory = () => window.dispatchEvent(new PopStateEvent('popstate'));
73
+
74
+ beforeEach(() => {
75
+ locationCount += 1;
76
+ navigateTo(`case-${locationCount}`);
77
+ sessionStorage.removeItem(`zntab:${storeKey}`);
78
+ });
79
+
80
+ afterEach(() => window.history.replaceState({}, '', originalHref));
81
+
82
+ it('restores the tab a location was left on when returning to it', async () => {
83
+ const el = await renderTabs();
84
+ await aTimeout(40);
85
+ el.querySelector('zn-navbar')!.querySelectorAll<HTMLElement>('li')[1].click();
86
+ await aTimeout(40);
87
+ expect(el.getAttribute('active')).to.equal('second');
88
+ el.remove();
89
+
90
+ returnWithHistory();
91
+ const restored = await renderTabs();
92
+ await aTimeout(40);
93
+
94
+ expect(restored.getAttribute('active')).to.equal('second');
95
+ });
96
+
97
+ it('starts from the default tab when navigating to the location', async () => {
98
+ const first = await renderTabs();
99
+ await aTimeout(40);
100
+ first.querySelector('zn-navbar')!.querySelectorAll<HTMLElement>('li')[1].click();
101
+ await aTimeout(40);
102
+ first.remove();
103
+
104
+ // Leave and navigate back, rather than returning through history.
105
+ const stored = window.location.search;
106
+ navigateTo('elsewhere');
107
+ window.history.pushState({}, '', stored);
108
+
109
+ const el = await renderTabs();
110
+ await aTimeout(40);
111
+
112
+ expect(el.getAttribute('active')).to.equal('first');
113
+ });
114
+
115
+ it('stores tab changes made outside the navigation', async () => {
116
+ const el = await renderTabs();
117
+ await aTimeout(40);
118
+
119
+ el.nextTab();
120
+ await aTimeout(40);
121
+ expect(el.getAttribute('active')).to.equal('second');
122
+ el.remove();
123
+
124
+ returnWithHistory();
125
+ const restored = await renderTabs();
126
+ await aTimeout(40);
127
+
128
+ expect(restored.getAttribute('active')).to.equal('second');
129
+ });
130
+
131
+ it('steps back through each tab that was opened', async () => {
132
+ const el = await renderTabs();
133
+ await aTimeout(40);
134
+ const items = el.querySelector('zn-navbar')!.querySelectorAll<HTMLElement>('li');
135
+
136
+ items[1].click();
137
+ await aTimeout(40);
138
+ expect(el.getAttribute('active')).to.equal('second');
139
+
140
+ await new Promise<void>(resolve => {
141
+ window.addEventListener('popstate', () => resolve(), {once: true});
142
+ window.history.back();
143
+ });
144
+ await aTimeout(40);
145
+
146
+ expect(el.getAttribute('active')).to.equal('first');
147
+ });
148
+
149
+ it('forgets the selection once the location is navigated away from', async () => {
150
+ const el = await renderTabs();
151
+ await aTimeout(40);
152
+ el.querySelector('zn-navbar')!.querySelectorAll<HTMLElement>('li')[1].click();
153
+ await aTimeout(40);
154
+ const stored = window.location.search;
155
+ el.remove();
156
+
157
+ navigateTo('departed');
158
+ await aTimeout(40);
159
+ expect(sessionStorage.getItem(`zntab:${storeKey}@${window.location.pathname}${stored}`)).to.equal(null);
160
+
161
+ window.history.replaceState({}, '', stored);
162
+ returnWithHistory();
163
+ const returned = await renderTabs();
164
+ await aTimeout(40);
165
+
166
+ expect(returned.getAttribute('active')).to.equal('first');
167
+ });
168
+
169
+ it('keeps a session selection out of local storage', async () => {
170
+ const el = await renderTabs();
171
+ await aTimeout(40);
172
+ el.querySelector('zn-navbar')!.querySelectorAll<HTMLElement>('li')[1].click();
173
+ await aTimeout(40);
174
+
175
+ expect(localStorage.getItem(`zntab:${storeKey}`)).to.equal(null);
176
+ expect(sessionStorage.getItem(`zntab:${locationScopedKey(storeKey)}`)).to.contain('second');
177
+ });
178
+ });
51
179
  });