@kubex/zinc 2.0.0 → 2.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
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",
@@ -4,10 +4,11 @@ import {HasSlotController} from "../../internal/slot";
4
4
  import {ifDefined} from "lit/directives/if-defined.js";
5
5
  import {property, state} from 'lit/decorators.js';
6
6
  import {ref} from "lit/directives/ref.js";
7
- import {styleMap} from "lit/directives/style-map.js";
8
7
  import {renderSortIcon, sortKeyHandler} from "../../internal/table-sort";
9
8
  import {ReorderController} from "../../internal/reorder";
10
9
  import {ResizeController} from '@lit-labs/observers/resize-controller.js';
10
+ import {slideTabIndicator, squishTabIndicator} from "../../internal/tab-indicator";
11
+ import {styleMap} from "lit/directives/style-map.js";
11
12
  import {type TableTab, TabRowController} from "../../internal/table-tabs";
12
13
  import {Task} from "@lit/task";
13
14
  import {unsafeHTML} from "lit/directives/unsafe-html.js";
@@ -24,18 +25,18 @@ import ZnDataTableSearch from "../data-table-search";
24
25
  import ZnDropdown from "../dropdown";
25
26
  import ZnEmptyState from "../empty-state";
26
27
  import ZnHoverContainer from "../hover-container";
28
+ import ZnIcon from "../icon";
29
+ import ZnInput from "../input";
27
30
  import ZnMenu from "../menu";
28
31
  import ZnMenuItem from "../menu-item";
32
+ import ZnOptGroup from "../opt-group";
33
+ import ZnOption from "../option";
29
34
  import ZnPanel from "../panel";
35
+ import ZnSelect from "../select";
30
36
  import ZnSkeleton from "../skeleton";
31
37
  import ZnStyle from "../style";
32
38
  import type ZnDataSelect from "../data-select";
33
- import ZnIcon from "../icon";
34
- import ZnInput from "../input";
35
- import ZnOptGroup from "../opt-group";
36
- import ZnOption from "../option";
37
39
  import type ZnQueryBuilder from "../query-builder";
38
- import ZnSelect from "../select";
39
40
 
40
41
  // Tables load in such a way that the global sheet has to be re-applied inside the shadow root.
41
42
  import bootStyles from '../../../scss/boot.scss';
@@ -920,6 +921,34 @@ export default class ZnDataTable extends ZincElement {
920
921
  protected updated(changed: PropertyValues) {
921
922
  super.updated(changed);
922
923
  this.publishFilterSuggestions();
924
+ void this.syncPageIndicator();
925
+ }
926
+
927
+ // Slides the shared active-page pill to the current page the way navbar pill tabs do
928
+ private async syncPageIndicator() {
929
+ const indicator = this.shadowRoot?.querySelector<HTMLElement>('.table__footer__indicator');
930
+ const active = indicator?.parentElement?.querySelector<ZnButton>('.table__footer__page--active');
931
+ if (!indicator || !active) return;
932
+
933
+ // The button has no size until it has rendered
934
+ await active.updateComplete;
935
+ if (!active.isConnected || !active.classList.contains('table__footer__page--active')) return;
936
+
937
+ const origin = indicator.parentElement!.getBoundingClientRect().left;
938
+ const tab = active.getBoundingClientRect();
939
+ const target = {left: tab.left - origin, right: tab.right - origin};
940
+ const left = `${target.left}px`; const width = `${target.right - target.left}px`;
941
+ if (indicator.style.left === left && indicator.style.width === width) return;
942
+
943
+ // A freshly rendered indicator has no width yet and jumps straight into place
944
+ const placed = indicator.style.width !== '';
945
+ const drawn = indicator.getBoundingClientRect();
946
+ indicator.style.left = left;
947
+ indicator.style.width = width;
948
+ if (placed) {
949
+ squishTabIndicator(indicator);
950
+ slideTabIndicator(indicator, {left: drawn.left - origin, right: drawn.right - origin}, target);
951
+ }
923
952
  }
924
953
 
925
954
  // The filter bar can't see the data, so hand it the values already on screen to type against
@@ -1459,28 +1488,26 @@ export default class ZnDataTable extends ZincElement {
1459
1488
  </div>`;
1460
1489
  }
1461
1490
 
1462
- // A window of PAGE_WINDOW pages around the current one, plus the first and last pages;
1463
- // null marks a gap, except a gap of one page, which shows that page instead
1491
+ // A window of PAGE_WINDOW pages around the current one, plus the first and last pages; null marks a gap.
1492
+ // Always PAGE_WINDOW + 4 slots once there are that many pages, so the prev/next buttons never move.
1464
1493
  private getPageRange(): (number | null)[] {
1465
- const size = Math.min(PAGE_WINDOW, this.totalPages);
1466
- const start = Math.min(
1467
- Math.max(1, this.page - Math.floor(size / 2)),
1468
- this.totalPages - size + 1
1469
- );
1470
- const pages = [...new Set([1, ...Array.from({length: size}, (_, i) => start + i), this.totalPages])];
1471
-
1472
- return pages.flatMap((p, i) => {
1473
- const gap = i > 0 ? p - pages[i - 1] : 1;
1474
- if (gap === 2) return [p - 1, p];
1475
- return gap > 2 ? [null, p] : [p];
1476
- });
1494
+ const n = this.totalPages;
1495
+ const slots = PAGE_WINDOW + 4;
1496
+ const range = (from: number, count: number) => Array.from({length: count}, (_, i) => from + i);
1497
+ if (n <= slots) return range(1, n);
1498
+
1499
+ const half = Math.floor(PAGE_WINDOW / 2);
1500
+ if (this.page - half <= 3) return [...range(1, slots - 2), null, n];
1501
+ if (this.page + half >= n - 2) return [1, null, ...range(n - slots + 3, slots - 2)];
1502
+ return [1, null, ...range(this.page - half, PAGE_WINDOW), null, n];
1477
1503
  }
1478
1504
 
1479
1505
  getPagination() {
1480
1506
  if (this.hidePagination || this.totalPages <= 1) return null;
1481
1507
 
1482
1508
  return html`
1483
- <div class="table__footer__pages">
1509
+ <div class="table__footer__pages" style="--page-digits: ${String(this.totalPages).length}">
1510
+ <span class="table__footer__indicator"></span>
1484
1511
  <zn-button class="table__footer__page"
1485
1512
  color="transparent"
1486
1513
  size="small"
@@ -311,7 +311,9 @@ table {
311
311
  padding: 0;
312
312
  }
313
313
 
314
+ // Equal shares either side keep the page buttons centred however wide the range label gets
314
315
  &__left, &__rows-per-page {
316
+ flex: 1 1 0;
315
317
  display: flex;
316
318
  flex-direction: row;
317
319
  align-items: center;
@@ -320,14 +322,33 @@ table {
320
322
  }
321
323
 
322
324
  &__pages {
325
+ // Every slot sized for the widest page number, so buttons don't resize as the window slides
326
+ --page-slot: max(var(--zn-button-height-small, 28px), calc(var(--page-digits, 1) * 1ch + 12px));
327
+
328
+ position: relative;
329
+ isolation: isolate;
323
330
  display: flex;
324
331
  align-items: center;
325
332
  gap: 2px;
326
333
  margin-inline: auto;
327
334
  }
328
335
 
336
+ // Placed by left and width, slid with transform only
337
+ &__indicator {
338
+ @include wc.pill-tab-indicator-motion;
339
+
340
+ position: absolute;
341
+ inset-block: 0;
342
+ left: 0;
343
+ z-index: -1;
344
+ transform-origin: left center;
345
+ border-radius: var(--zn-border-radius);
346
+ background-color: var(--zn-table-page-active-background, var(--zn-color-neutral-300));
347
+ pointer-events: none;
348
+ }
349
+
329
350
  &__rows-per-page {
330
- margin-inline-start: auto;
351
+ justify-content: flex-end;
331
352
 
332
353
  zn-select[name="rowPerPage"] {
333
354
  --zn-input-background-color: rgb(var(--zn-panel));
@@ -346,13 +367,10 @@ table {
346
367
  }
347
368
  }
348
369
 
349
- &__pages + &__rows-per-page {
350
- margin-inline-start: 0;
351
- }
352
-
353
370
  &__page::part(base) {
354
- min-width: var(--zn-button-height-small, 28px);
371
+ min-width: var(--page-slot);
355
372
  padding: 0 5px;
373
+ font-variant-numeric: tabular-nums;
356
374
  justify-content: center;
357
375
  color: rgb(var(--zn-color-muted-text, var(--zn-text)));
358
376
  font-size: 12px;
@@ -360,14 +378,14 @@ table {
360
378
  }
361
379
 
362
380
  &__page--active::part(base) {
363
- background-color: var(--zn-table-page-active-background, var(--zn-color-neutral-300));
381
+ background-color: transparent;
364
382
  color: var(--zn-table-page-active-color, rgb(var(--zn-text)));
365
383
  }
366
384
 
367
385
  &__gap {
368
386
  display: inline-flex;
369
387
  justify-content: center;
370
- min-width: var(--zn-button-height-small, 28px);
388
+ min-width: var(--page-slot);
371
389
  font-weight: var(--zn-font-weight-semibold, 600);
372
390
  }
373
391
  }
@@ -529,6 +547,9 @@ zn-style.zn-muted {
529
547
 
530
548
  .table tr .table__head--reorderable {
531
549
  cursor: grab;
550
+ // A press here is a drag; starting a text selection would sweep it across the table
551
+ user-select: none;
552
+ -webkit-user-select: none;
532
553
 
533
554
  &:active {
534
555
  cursor: grabbing;
@@ -1,9 +1,17 @@
1
1
  import '../../../dist/zn.min.js';
2
+ import { centre, dragTo, drop, startDrag } from '../../internal/test-drag';
2
3
  import { expect, fixture, html, waitUntil } from '@open-wc/testing';
3
- import { centre, drop, dragTo, startDrag } from '../../internal/test-drag';
4
4
  import { render } from 'lit';
5
5
  import type ZnDataTable from './data-table.component';
6
6
 
7
+ // WebKit reports a benign "ResizeObserver loop" error when paging re-lays out the footer's zn-select
8
+ window.addEventListener('error', (e: ErrorEvent) => {
9
+ if (typeof e.message === 'string' && e.message.includes('ResizeObserver loop')) {
10
+ e.stopImmediatePropagation();
11
+ e.preventDefault();
12
+ }
13
+ }, true);
14
+
7
15
  describe('<zn-data-table>', () => {
8
16
  it('should render a component', async () => {
9
17
  const el = await fixture(html` <zn-data-table></zn-data-table> `);
@@ -67,6 +75,51 @@ describe('<zn-data-table>', () => {
67
75
  }
68
76
  });
69
77
 
78
+ it('renders the same number of page slots on every page', async () => {
79
+ const originalFetch = window.fetch;
80
+ window.fetch = (_url, init) => {
81
+ const {page} = JSON.parse(String(init!.body)) as {page: number};
82
+ return Promise.resolve(new Response(JSON.stringify({
83
+ rows: [{id: '1', cells: [{text: 'Row', column: 'name'}]}],
84
+ page,
85
+ perPage: 10,
86
+ total: 1234,
87
+ }), {status: 200, headers: {'Content-Type': 'application/json'}}));
88
+ };
89
+
90
+ try {
91
+ const el = await fixture<ZnDataTable>(html`
92
+ <zn-data-table data-uri="/test-data" method="POST" headers='{"name": {"key": "name", "label": "Name"}}'></zn-data-table>`);
93
+ const slots = () => [...el.shadowRoot!.querySelectorAll('.table__footer__page, .table__footer__gap')]
94
+ .slice(1, -1).map(s => s.textContent!.trim()).join(',');
95
+
96
+ const seen: Record<number, string> = {};
97
+ for (const page of [1, 4, 5, 60, 120, 121, 124]) {
98
+ (el as unknown as {goToPage: (p: number) => void}).goToPage(page);
99
+ await waitUntil(() => el.shadowRoot!.querySelector('[aria-current="page"]')?.textContent?.trim() === String(page));
100
+ seen[page] = slots();
101
+ }
102
+
103
+ expect(seen).to.deep.equal({
104
+ 1: '1,2,3,4,5,…,124',
105
+ 4: '1,2,3,4,5,…,124',
106
+ 5: '1,…,4,5,6,…,124',
107
+ 60: '1,…,59,60,61,…,124',
108
+ 120: '1,…,119,120,121,…,124',
109
+ 121: '1,…,120,121,122,123,124',
110
+ 124: '1,…,120,121,122,123,124',
111
+ });
112
+
113
+ const indicator = el.shadowRoot!.querySelector<HTMLElement>('.table__footer__indicator')!;
114
+ const active = el.shadowRoot!.querySelector<HTMLElement>('.table__footer__page--active')!;
115
+ await waitUntil(() => indicator.getAnimations().length === 0);
116
+ expect(Math.round(indicator.getBoundingClientRect().left)).to.equal(Math.round(active.getBoundingClientRect().left));
117
+ expect(Math.round(indicator.getBoundingClientRect().width)).to.equal(Math.round(active.getBoundingClientRect().width));
118
+ } finally {
119
+ window.fetch = originalFetch;
120
+ }
121
+ });
122
+
70
123
  it('hides the rows-per-page selector when the dataset fits the smallest page size', async () => {
71
124
  const originalFetch = window.fetch;
72
125
  window.fetch = () => Promise.resolve(new Response(JSON.stringify({
@@ -99,6 +99,7 @@ export class ReorderController implements ReactiveController {
99
99
  private shield: HTMLElement | null = null;
100
100
  private scroller: Element | null = null;
101
101
  private scrollFrame = 0;
102
+ private pressRoot: Node | null = null;
102
103
 
103
104
  constructor(host: ReorderHost, options: ReorderOptions = {}) {
104
105
  this.host = host;
@@ -204,7 +205,11 @@ export class ReorderController implements ReactiveController {
204
205
  window.addEventListener('pointercancel', this.onPointerCancel, true);
205
206
  window.addEventListener('touchmove', this.onTouchMove, {capture: true, passive: false});
206
207
  // A link or image in the row would otherwise start a native drag and take the pointer
207
- window.addEventListener('dragstart', this.onNativeDragStart, true);
208
+ window.addEventListener('dragstart', this.cancel, true);
209
+ // The press would otherwise also start a text selection that sweeps the page as it drags; selectstart isn't
210
+ // composed, so it's caught in the row's own tree
211
+ this.pressRoot = this.host.reorderRow(key)!.getRootNode();
212
+ this.pressRoot.addEventListener('selectstart', this.cancel, true);
208
213
  }
209
214
 
210
215
  private onPointerMove = (event: PointerEvent) => {
@@ -246,7 +251,7 @@ export class ReorderController implements ReactiveController {
246
251
  if (this.isDragging) event.preventDefault();
247
252
  };
248
253
 
249
- private onNativeDragStart = (event: DragEvent) => {
254
+ private cancel = (event: Event) => {
250
255
  event.preventDefault();
251
256
  };
252
257
 
@@ -463,7 +468,9 @@ export class ReorderController implements ReactiveController {
463
468
  window.removeEventListener('pointerup', this.onPointerUp, true);
464
469
  window.removeEventListener('pointercancel', this.onPointerCancel, true);
465
470
  window.removeEventListener('touchmove', this.onTouchMove, true);
466
- window.removeEventListener('dragstart', this.onNativeDragStart, true);
471
+ window.removeEventListener('dragstart', this.cancel, true);
472
+ this.pressRoot?.removeEventListener('selectstart', this.cancel, true);
473
+ this.pressRoot = null;
467
474
  }
468
475
 
469
476
  /** Shows the new order, sliding each row from its old position. */