@kubex/zinc 1.1.158 → 1.1.160

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.
@@ -133,6 +133,21 @@ Use `priority` to control tab order. Lower numbers appear first after the empty-
133
133
  </zn-page>
134
134
  ```
135
135
 
136
+ ### Linking to a Tab
137
+
138
+ Give any element in the page content a `tab` attribute holding a tab's `id` to open that tab when it is clicked. Links inside a nested `zn-page` or `zn-tabs` open tabs in that nested container.
139
+
140
+ ```html:preview
141
+ <zn-page caption="Scorecard">
142
+ <zn-tab caption="Details" id="details">
143
+ <zn-button tab="section-general" icon="edit">Edit General section</zn-button>
144
+ </zn-tab>
145
+ <zn-tab caption="General" id="section-general">
146
+ General section settings
147
+ </zn-tab>
148
+ </zn-page>
149
+ ```
150
+
136
151
  ### Dynamic Tabs
137
152
 
138
153
  Set `uri` on `zn-tab` to create a dynamic tab. The generated navigation item receives `tab-uri`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.158",
3
+ "version": "1.1.160",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -26,62 +26,41 @@ export default class ZnFilterContainer extends ZincElement {
26
26
 
27
27
  @property() attr = 'filter';
28
28
 
29
+ private _filterFrame = 0;
30
+
29
31
  public handleSearchChange(event: Event) {
30
- // get all element that have data-'this.attr' attribute
31
- const filterableElements = this.querySelectorAll(`[data-${this.attr}]`);
32
- const input = event.target as HTMLInputElement;
33
- const searchTerm = input.value.toLowerCase();
32
+ const searchTerm = (event.target as HTMLInputElement).value.toLowerCase();
33
+ cancelAnimationFrame(this._filterFrame);
34
+ this._filterFrame = requestAnimationFrame(() => this.applyFilter(searchTerm));
35
+ }
34
36
 
35
- filterableElements.forEach((el) => {
36
- const filterValue = el.getAttribute(`data-${this.attr}`)?.toLowerCase() || '';
37
- if (filterValue.includes(searchTerm)) {
38
- (el as HTMLElement).style.display = '';
39
- } else {
40
- (el as HTMLElement).style.display = 'none';
41
- }
37
+ disconnectedCallback() {
38
+ super.disconnectedCallback();
39
+ cancelAnimationFrame(this._filterFrame);
40
+ }
42
41
 
43
- const panel = el.closest('zn-panel');
44
- if (panel) {
45
- const panelItems = panel.querySelectorAll(`[data-${this.attr}]`);
46
- let anyVisible = false;
47
- panelItems.forEach((item) => {
48
- if ((item as HTMLElement).style.display !== 'none') {
49
- anyVisible = true;
50
- }
42
+ private applyFilter(searchTerm: string) {
43
+ const attr = `data-${this.attr}`;
44
+ const filterableElements = this.querySelectorAll<HTMLElement>(`[${attr}]`);
45
+ const panels = new Map<HTMLElement, boolean>();
46
+ const accordions = new Map<HTMLElement, boolean>();
47
+ let anyVisibleElements = false;
51
48
 
52
- });
53
- if (anyVisible) {
54
- (panel as HTMLElement).style.display = '';
55
- } else {
56
- (panel as HTMLElement).style.display = 'none';
57
- }
58
- }
49
+ filterableElements.forEach((el) => {
50
+ const visible = searchTerm === '' || (el.getAttribute(attr)?.toLowerCase() || '').includes(searchTerm);
51
+ setDisplay(el, visible);
52
+ if (visible) anyVisibleElements = true;
59
53
 
60
- const accordionItem = el.closest('zn-accordion');
61
- if (accordionItem) {
62
- const itemElements = accordionItem.querySelectorAll(`[data-${this.attr}]`);
63
- let anyVisible = false;
64
- itemElements.forEach((item) => {
65
- if ((item as HTMLElement).style.display !== 'none') {
66
- anyVisible = true;
67
- }
68
- });
54
+ const panel = el.closest<HTMLElement>('zn-panel');
55
+ if (panel) panels.set(panel, (panels.get(panel) ?? false) || visible);
69
56
 
70
- if (anyVisible) {
71
- (accordionItem as HTMLElement).style.display = '';
72
- }
73
- }
57
+ const accordion = el.closest<HTMLElement>('zn-accordion');
58
+ if (accordion) accordions.set(accordion, (accordions.get(accordion) ?? false) || visible);
74
59
  });
75
60
 
76
- if (searchTerm === '') {
77
- filterableElements.forEach((el) => {
78
- (el as HTMLElement).style.display = '';
79
- });
80
- }
81
-
82
- // if nothing is visible, show a "no results found" message
83
- const anyVisibleElements = Array.from(filterableElements).some((el) => {
84
- return (el as HTMLElement).style.display !== 'none';
61
+ panels.forEach((visible, panel) => setDisplay(panel, visible));
62
+ accordions.forEach((visible, accordion) => {
63
+ if (visible) setDisplay(accordion, true);
85
64
  });
86
65
 
87
66
  let noResultsMessage = this.querySelector<HTMLElement>('.no-results-message');
@@ -107,3 +86,8 @@ export default class ZnFilterContainer extends ZincElement {
107
86
  <slot></slot> `;
108
87
  }
109
88
  }
89
+
90
+ function setDisplay(el: HTMLElement, visible: boolean) {
91
+ const display = visible ? '' : 'none';
92
+ if (el.style.display !== display) el.style.display = display;
93
+ }
@@ -1,5 +1,8 @@
1
1
  import '../../../dist/zn.min.js';
2
2
  import { expect, fixture, html } from '@open-wc/testing';
3
+ import type ZnFilterContainer from './filter-container.component';
4
+
5
+ const nextFrame = () => new Promise(requestAnimationFrame);
3
6
 
4
7
  describe('<zn-filter-container>', () => {
5
8
  it('should render a component', async () => {
@@ -7,4 +10,26 @@ describe('<zn-filter-container>', () => {
7
10
 
8
11
  expect(el).to.exist;
9
12
  });
13
+
14
+ it('hides non-matching items and empty panels', async () => {
15
+ const el = await fixture<ZnFilterContainer>(html`
16
+ <zn-filter-container>
17
+ <zn-panel id="fruit"><div data-filter="Apple"></div><div data-filter="Banana"></div></zn-panel>
18
+ <zn-panel id="veg"><div data-filter="Carrot"></div></zn-panel>
19
+ </zn-filter-container>
20
+ `);
21
+
22
+ el.handleSearchChange({target: {value: 'app'}} as unknown as Event);
23
+ await nextFrame();
24
+
25
+ expect(el.querySelector<HTMLElement>('[data-filter="Apple"]')!.style.display).to.equal('');
26
+ expect(el.querySelector<HTMLElement>('[data-filter="Banana"]')!.style.display).to.equal('none');
27
+ expect(el.querySelector<HTMLElement>('#fruit')!.style.display).to.equal('');
28
+ expect(el.querySelector<HTMLElement>('#veg')!.style.display).to.equal('none');
29
+
30
+ el.handleSearchChange({target: {value: ''}} as unknown as Event);
31
+ await nextFrame();
32
+
33
+ expect(el.querySelector<HTMLElement>('#veg')!.style.display).to.equal('');
34
+ });
10
35
  });
@@ -2,6 +2,7 @@ import {classMap} from 'lit/directives/class-map.js';
2
2
  import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
3
3
  import {HasSlotController} from '../../internal/slot';
4
4
  import {property, state} from 'lit/decorators.js';
5
+ import {tabContainerSelector} from '../tabs/tabs.component';
5
6
  import ZnButton from '../button';
6
7
  import ZnCopyButton from '../copy-button';
7
8
  import ZnExpandingAction from '../expanding-action';
@@ -30,6 +31,7 @@ interface TabDefinition {
30
31
  * @since 1.0
31
32
  *
32
33
  * @slot - Page content. Use zn-tab for named tabs and header-action/header-actions for header actions.
34
+ * Clicking an element in the content with `tab="<tab id>"` opens that tab.
33
35
  * @slot description - Rich subtitle/description content. Falls back to the `summary` attribute when empty.
34
36
  * @slot bottom - Content rendered below the navbar row (e.g. chips, filters). Forwarded to the navbar's bottom slot.
35
37
  */
@@ -68,6 +70,7 @@ export default class ZnPage extends ZnTabs {
68
70
  const connected = super.connectedCallback();
69
71
  window.addEventListener('alt-press', this.handleAltPress);
70
72
  window.addEventListener('alt-up', this.handleAltUp);
73
+ this.addEventListener('click', this.handleContentTabClick);
71
74
  this.prepareTabs();
72
75
  this.refreshExpandingActionsState();
73
76
  this.tabObserver = new MutationObserver((mutations) => {
@@ -90,6 +93,7 @@ export default class ZnPage extends ZnTabs {
90
93
  super.disconnectedCallback();
91
94
  window.removeEventListener('alt-press', this.handleAltPress);
92
95
  window.removeEventListener('alt-up', this.handleAltUp);
96
+ this.removeEventListener('click', this.handleContentTabClick);
93
97
  this.tabObserver?.disconnect();
94
98
  this.tabObserver = null;
95
99
  this.actionObserver?.disconnect();
@@ -280,6 +284,23 @@ export default class ZnPage extends ZnTabs {
280
284
  }
281
285
  }
282
286
 
287
+ // Only elements whose nearest tab container is this page count, so links inside a nested
288
+ // zn-tabs or zn-page drive that container instead.
289
+ private handleContentTabClick = (event: MouseEvent) => {
290
+ const link = event.composedPath().find((node): node is HTMLElement =>
291
+ node instanceof HTMLElement && node.hasAttribute('tab') && node.closest(tabContainerSelector) === this
292
+ );
293
+ if (!link || link.hasAttribute('disabled')) {
294
+ return;
295
+ }
296
+
297
+ const definition = this.tabDefinitions.find(tab => tab.id === link.getAttribute('tab'));
298
+ if (definition) {
299
+ event.preventDefault();
300
+ this.activateTabDefinition(definition, true, true);
301
+ }
302
+ };
303
+
283
304
  private handleNavigationSelect(event: ZnSelectEvent) {
284
305
  const item = event.detail.item as HTMLElement;
285
306
  if (this.getNavigationItemPage(item) !== this) {
@@ -383,16 +404,16 @@ export default class ZnPage extends ZnTabs {
383
404
  return this.pageHistoryKey;
384
405
  }
385
406
 
386
- private activateTabDefinition(tab: TabDefinition, store = false) {
407
+ private activateTabDefinition(tab: TabDefinition, store = false, pushHistory = false) {
387
408
  if (tab.uri) {
388
409
  const navItem = this.findNavItemForUri(tab.uri);
389
410
  if (navItem) {
390
- this.clickTab(navItem, false);
411
+ this.clickTab(navItem, false, pushHistory);
391
412
  this.syncNavigationActive(navItem);
392
413
  return;
393
414
  }
394
415
  }
395
- this.activateTab(tab.id, store);
416
+ this.activateTab(tab.id, store, pushHistory);
396
417
  }
397
418
 
398
419
  private findNavItemForUri(uri: string): HTMLElement | null {
@@ -350,6 +350,58 @@ describe('<zn-page>', () => {
350
350
  expect(innerDynamicPanel.hasAttribute('selected')).to.equal(true);
351
351
  });
352
352
 
353
+ it('opens a tab when an element in the content with a tab attribute is clicked', async () => {
354
+ const el = await fixture<ZnPage>(html`
355
+ <zn-page caption="Content Tab Link">
356
+ <zn-tab caption="Overview" id="overview">
357
+ <zn-button tab="details">Open details</zn-button>
358
+ <zn-button tab="details" disabled>Disabled link</zn-button>
359
+ </zn-tab>
360
+ <zn-tab caption="Details" id="details">Details Content</zn-tab>
361
+ </zn-page>
362
+ `);
363
+ await aTimeout(40);
364
+
365
+ // zn-button.click() skips event dispatch, so click the rendered button as a user would.
366
+ const [link, disabledLink] = Array.from(el.querySelectorAll('zn-button'))
367
+ .map(button => button.shadowRoot!.querySelector<HTMLElement>('button')!);
368
+
369
+ disabledLink.click();
370
+ await aTimeout(40);
371
+ expect(el.getAttribute('active')).to.equal('overview');
372
+
373
+ link.click();
374
+ await aTimeout(40);
375
+ expect(el.getAttribute('active')).to.equal('details');
376
+ expect(el.shadowRoot!.querySelector('#details')!.hasAttribute('selected')).to.equal(true);
377
+ const activeItem = el.shadowRoot!.querySelector<HTMLElement>('zn-navbar li.active');
378
+ expect(activeItem?.getAttribute('tab')).to.equal('details');
379
+ });
380
+
381
+ it('leaves content tab links inside a nested page to that page', async () => {
382
+ const outerPage = await fixture<ZnPage>(html`
383
+ <zn-page caption="Nested Content Tab Link">
384
+ <zn-tab caption="Outer One" id="outer-one">
385
+ <zn-page caption="Nested Content Tab Link Inner" nested>
386
+ <zn-tab caption="Inner One" id="inner-one">
387
+ <zn-button tab="inner-two">Inner link</zn-button>
388
+ </zn-tab>
389
+ <zn-tab caption="Inner Two" id="inner-two">Inner Two Content</zn-tab>
390
+ </zn-page>
391
+ </zn-tab>
392
+ <zn-tab caption="Outer Two" id="inner-two">Outer Two Content</zn-tab>
393
+ </zn-page>
394
+ `);
395
+ await aTimeout(80);
396
+
397
+ const innerPage = outerPage.querySelector<ZnPage>('zn-page')!;
398
+ innerPage.querySelector('zn-button')!.shadowRoot!.querySelector<HTMLElement>('button')!.click();
399
+ await aTimeout(40);
400
+
401
+ expect(innerPage.getAttribute('active')).to.equal('inner-two');
402
+ expect(outerPage.getAttribute('active')).to.equal('outer-one');
403
+ });
404
+
353
405
  it('keeps nested page tab selections scoped to the nested page', async () => {
354
406
  const outerPage = await fixture<ZnPage>(html`
355
407
  <zn-page caption="Outer Page">
@@ -25,7 +25,7 @@ import styles from './tabs.scss';
25
25
  // Every element that manages its own tabs (ZnTabs and its subclasses). Tab
26
26
  // registration must stop at these so an outer container never claims a nested
27
27
  // container's tabs.
28
- const tabContainerSelector = 'zn-tabs, zn-page, zn-page-nav';
28
+ export const tabContainerSelector = 'zn-tabs, zn-page, zn-page-nav';
29
29
 
30
30
  /**
31
31
  * @summary Short summary of the component's intended use.