@kubex/zinc 1.1.120 → 1.1.122

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.
Files changed (41) hide show
  1. package/dist/custom-elements.json +1952 -921
  2. package/dist/vscode.html-custom-data.json +80 -58
  3. package/dist/web-types.json +208 -142
  4. package/dist/zn.d.ts +403 -298
  5. package/dist/zn.min.js +1511 -1370
  6. package/docs/pages/components/button.md +9 -0
  7. package/docs/pages/components/data-table-filter.md +60 -4
  8. package/docs/pages/components/data-table-search.md +3 -3
  9. package/docs/pages/components/data-table.md +78 -9
  10. package/docs/pages/components/datepicker.md +10 -0
  11. package/docs/pages/components/menu-item.md +16 -2
  12. package/docs/pages/components/translation-group.md +2 -2
  13. package/package.json +1 -1
  14. package/src/components/button/button.component.ts +11 -4
  15. package/src/components/button/button.scss +12 -1
  16. package/src/components/data-table/data-table.component.ts +341 -139
  17. package/src/components/data-table/data-table.scss +105 -42
  18. package/src/components/data-table/data-table.test.ts +110 -0
  19. package/src/components/data-table-filter/data-table-filter.component.ts +487 -49
  20. package/src/components/data-table-filter/data-table-filter.scss +163 -9
  21. package/src/components/data-table-filter/data-table-filter.test.ts +328 -2
  22. package/src/components/data-table-search/data-table-search.component.ts +1 -1
  23. package/src/components/datepicker/datepicker.component.ts +23 -2
  24. package/src/components/datepicker/datepicker.scss +25 -0
  25. package/src/components/datepicker/datepicker.test.ts +28 -1
  26. package/src/components/input/input.component.ts +1 -0
  27. package/src/components/input/input.scss +1 -1
  28. package/src/components/menu/menu.component.ts +2 -0
  29. package/src/components/menu/menu.scss +23 -2
  30. package/src/components/menu-item/menu-item.component.ts +7 -1
  31. package/src/components/menu-item/menu-item.scss +4 -4
  32. package/src/components/menu-item/menu-item.test.ts +39 -1
  33. package/src/components/panel/panel.component.ts +12 -5
  34. package/src/components/panel/panel.scss +3 -3
  35. package/src/components/query-builder/query-builder.component.ts +1 -1
  36. package/src/components/tooltip/tooltip.component.ts +2 -2
  37. package/src/components/tooltip/tooltip.test.ts +70 -2
  38. package/src/components/translation-group/translation-group.component.ts +5 -7
  39. package/src/components/translation-group/translation-group.test.ts +8 -8
  40. package/src/components/translations/translations.component.ts +3 -4
  41. package/src/components/translations/translations.test.ts +1 -1
@@ -1,8 +1,8 @@
1
1
  import {classMap} from "lit/directives/class-map.js";
2
- import {type CSSResultGroup, html, nothing, type TemplateResult, unsafeCSS} from 'lit';
2
+ import {type CSSResultGroup, html, nothing, type PropertyValues, type TemplateResult, unsafeCSS} from 'lit';
3
3
  import {HasSlotController} from "../../internal/slot";
4
4
  import {ifDefined} from "lit/directives/if-defined.js";
5
- import {property, query} from 'lit/decorators.js';
5
+ import {property, query, state} from 'lit/decorators.js';
6
6
  import {ref} from "lit/directives/ref.js";
7
7
  import {ResizeController} from '@lit-labs/observers/resize-controller.js';
8
8
  import {Task} from "@lit/task";
@@ -22,6 +22,7 @@ import ZnEmptyState from "../empty-state";
22
22
  import ZnHoverContainer from "../hover-container";
23
23
  import ZnMenu from "../menu";
24
24
  import ZnMenuItem from "../menu-item";
25
+ import ZnPanel from "../panel";
25
26
  import ZnSkeleton from "../skeleton";
26
27
  import ZnStyle from "../style";
27
28
  import type ZnDataSelect from "../data-select";
@@ -35,6 +36,9 @@ import styles from './data-table.scss';
35
36
 
36
37
  const DEFAULT_PAGE = 1;
37
38
  const DEFAULT_PER_PAGE = 10;
39
+ // Numbered page buttons shown at once; the window slides so the control never grows
40
+ const PAGE_WINDOW = 3;
41
+ const SUGGESTION_LIMIT = 25;
38
42
 
39
43
  interface Cell {
40
44
  text: string;
@@ -57,6 +61,11 @@ interface Cell {
57
61
  title?: string;
58
62
  }
59
63
 
64
+ interface RowGroup {
65
+ label: string;
66
+ rows: Row[];
67
+ }
68
+
60
69
  interface Row {
61
70
  id: string;
62
71
  uri?: string;
@@ -167,19 +176,20 @@ type AllowedInputElement =
167
176
  * @dependency zn-dropdown
168
177
  * @dependency zn-menu
169
178
  * @dependency zn-menu-item
179
+ * @dependency zn-panel
170
180
  * @dependency zn-button-group
171
181
  * @dependency zn-confirm
172
182
  * @dependency zn-skeleton
173
183
  * @dependency zn-data-table-search
174
184
  *
175
185
  * @slot - The default slot.
176
- * @slot search - Slot for search component.
177
- * @slot sort - Slot for sort component.
178
- * @slot filter - Slot for filter component.
179
- * @slot filter-top - Slot for top-level filter component.
180
- * @slot delete-action - Slot for delete action button.
181
- * @slot modify-action - Slot for modify action button.
182
- * @slot create-action - Slot for create action button.
186
+ * @slot search - Slot for search component, in the header's right-hand group.
187
+ * @slot sort - Slot for sort component, in the header's right-hand group.
188
+ * @slot filter - Slot for filter component, in the header's right-hand group.
189
+ * @slot filter-top - Slot for a top-level filter component, rendered above the table's panel.
190
+ * @slot delete-action - Slot for delete action button, shown beside the caption once rows are selected.
191
+ * @slot modify-action - Slot for modify action button, shown beside the caption once rows are selected.
192
+ * @slot create-action - Slot for create action button, at the end of the header's right-hand group.
183
193
  * @slot inputs - Slot for additional input controls.
184
194
  * @slot empty-state - Slot for custom empty state.
185
195
  * @slot no-results - Slot for a custom no-results state, shown when a search on a no-initial-load table returns no rows.
@@ -199,6 +209,7 @@ export default class ZnDataTable extends ZincElement {
199
209
  'zn-dropdown': ZnDropdown,
200
210
  'zn-menu': ZnMenu,
201
211
  'zn-menu-item': ZnMenuItem,
212
+ 'zn-panel': ZnPanel,
202
213
  'zn-button-group': ZnButtonGroup,
203
214
  'zn-confirm': ZnConfirm,
204
215
  'zn-skeleton': ZnSkeleton,
@@ -234,6 +245,13 @@ export default class ZnDataTable extends ZincElement {
234
245
  // Hide pagination
235
246
  @property({attribute: 'hide-pagination', type: Boolean}) hidePagination: boolean;
236
247
 
248
+ // Hide the column select dropdown in the table header
249
+ @property({attribute: 'hide-column-select', type: Boolean}) hideColumnSelect: boolean = false;
250
+
251
+ // Hide the refresh button in the table header
252
+ @property({attribute: 'hide-refresh', type: Boolean}) hideRefresh: boolean = false;
253
+
254
+ // Wraps the caption, header controls, rows and pagination in a zn-panel
237
255
  @property({type: Boolean}) standalone: boolean = false;
238
256
 
239
257
  @property() caption: string;
@@ -276,8 +294,11 @@ export default class ZnDataTable extends ZincElement {
276
294
  // private itemsPerPage: number = DEFAULT_PER_PAGE;
277
295
  private page: number = DEFAULT_PAGE;
278
296
  private totalPages: number;
297
+ private _totalRows = 0;
279
298
 
280
- private _rows: any[] = [];
299
+ private _rows: Row[] = [];
300
+
301
+ private _suggestionsKey = '';
281
302
 
282
303
  private numberOfRowsSelected: number = 0;
283
304
  private selectedRows: any[] = [];
@@ -361,6 +382,9 @@ export default class ZnDataTable extends ZincElement {
361
382
  private _expandedRows: Set<string> = new Set();
362
383
  private _hiddenCells: Map<string, Cell[]> = new Map();
363
384
  private _secondaryHeaders: HeaderConfig[];
385
+ @state() private _deselectedColumns: Set<string> = new Set();
386
+ @state() private _filtersOpen = false;
387
+ private _columnDefaultsApplied = false;
364
388
  private _formatTemplates: Record<string, DisplayTemplate> = defaultTemplates;
365
389
 
366
390
  requestParams: Record<string, any> = {};
@@ -371,13 +395,60 @@ export default class ZnDataTable extends ZincElement {
371
395
  this._dataTask.run().then(r => r);
372
396
  }
373
397
 
398
+ // Headers that make up the visible columns, in header order
399
+ private visibleHeaders(): HeaderConfig[] {
400
+ return this.selectableHeaders().filter((header: HeaderConfig) => this.isColumnVisible(header));
401
+ }
402
+
403
+ // Headers the column select can toggle - everything but the permanently hidden and secondary ones
404
+ private selectableHeaders(): HeaderConfig[] {
405
+ return (Object.values(this.headers) as HeaderConfig[]).filter((header: HeaderConfig) => {
406
+ if (header.hideHeader || header.hideColumn || header.secondary) return false;
407
+
408
+ return !Object.values(this.hiddenColumns).includes(header.key);
409
+ });
410
+ }
411
+
412
+ private isColumnVisible(header: HeaderConfig): boolean {
413
+ return header.required === true || !this._deselectedColumns.has(header.key);
414
+ }
415
+
416
+ private applyColumnDefaults() {
417
+ if (this._columnDefaultsApplied) return;
418
+
419
+ const headers = Object.values(this.headers) as HeaderConfig[];
420
+ if (headers.length === 0) return;
421
+
422
+ this._columnDefaultsApplied = true;
423
+ const deselected = new Set(this._deselectedColumns);
424
+ headers.forEach((header: HeaderConfig) => {
425
+ if (header.default === false && header.required !== true) deselected.add(header.key);
426
+ });
427
+ this._deselectedColumns = deselected;
428
+ }
429
+
430
+ private toggleColumn(header: HeaderConfig) {
431
+ if (header.required === true) return;
432
+
433
+ const deselected = new Set(this._deselectedColumns);
434
+ if (!deselected.delete(header.key)) {
435
+ // Never let the last visible column be turned off
436
+ if (this.visibleHeaders().length <= 1) return;
437
+ deselected.add(header.key);
438
+ }
439
+ this._deselectedColumns = deselected;
440
+ }
441
+
374
442
  render() {
443
+ this.applyColumnDefaults();
375
444
 
376
445
  // If no-initial-load is set, do not invoke the Task on the first render
377
446
  let tableBody: TemplateResult = html``;
447
+ let emptyBody = false;
378
448
  if (this.noInitialLoad && this._initialLoad) {
379
449
  tableBody = html`
380
450
  <slot name="empty-state"></slot>`;
451
+ emptyBody = true;
381
452
  } else if (this.dataUri) {
382
453
  tableBody = this._dataTask.render({
383
454
  pending: () => {
@@ -396,6 +467,7 @@ export default class ZnDataTable extends ZincElement {
396
467
  this._initialLoad = false;
397
468
  this._hasLoadedData = true;
398
469
  this._lastLoadHadRows = ((data as Response)?.rows?.length ?? 0) > 0;
470
+ emptyBody = !this._lastLoadHadRows;
399
471
  this._lastTableContent = html`
400
472
  <div>${this.renderTable(data as Response)}</div>`;
401
473
  return this._lastTableContent;
@@ -436,19 +508,46 @@ export default class ZnDataTable extends ZincElement {
436
508
  || this.hasSlotController.test(ActionSlots.modify.valueOf())
437
509
  || this.hasSlotController.test(ActionSlots.create.valueOf())
438
510
  || this.hasSlotController.test(ActionSlots.sort.valueOf())
439
- || this.hasSlotController.test(ActionSlots.filter.valueOf())
440
- || this.hasSlotController.test(ActionSlots.filter_top.valueOf())
441
511
  || this.hasSlotController.test(ActionSlots.search.valueOf());
442
512
 
443
513
  const hasInputs = this.hasSlotController.test(ActionSlots.inputs.valueOf());
514
+ const hasControls = hasActions || this.hasColumnSelect() || this.hasRefresh();
515
+
516
+ // An empty state with no header has nothing to hang off the panel, and consumers
517
+ // often bring their own panel for it, so let it stand on its own
518
+ const bareEmptyState = emptyBody && !hasControls && !this.caption;
519
+
520
+ // The filter bar is inline, so it gets its own row rather than a slot in the header
521
+ const filters = this.hasSlotController.test(ActionSlots.filter.valueOf())
522
+ ? html`
523
+ <div class="table__filters" ?hidden="${!this._filtersOpen}">
524
+ <slot name="${ActionSlots.filter.valueOf()}"></slot>
525
+ </div>`
526
+ : nothing;
444
527
 
445
528
  // Headers do not need to be re-rendered with new data
446
529
  return html`
447
530
  <div class="table-container" ${ref((el) => (this.tableContainer = el))}>
448
531
  ${hasInputs ? html`
449
532
  <slot name="${ActionSlots.inputs.valueOf()}" style="display: none"></slot>` : null}
450
- ${hasActions ? this.getTableHeader() : html``}
451
- ${tableBody}
533
+ <slot name="${ActionSlots.filter_top.valueOf()}"></slot>
534
+ ${this.standalone && !bareEmptyState
535
+ ? html`
536
+ <zn-panel caption="${ifDefined(this.caption || undefined)}" flush>
537
+ ${hasControls ? html`
538
+ <div slot="actions" class="table__header__right">
539
+ ${this.getActions()}
540
+ ${this.getHeaderControls()}
541
+ </div>` : nothing}
542
+ ${filters}
543
+ ${tableBody}
544
+ ${this.getTableFooter('footer')}
545
+ </zn-panel>`
546
+ : html`
547
+ ${hasControls || this.caption ? this.getTableHeader() : nothing}
548
+ ${filters}
549
+ ${tableBody}
550
+ ${this.getTableFooter()}`}
452
551
  </div>
453
552
  `;
454
553
  }
@@ -456,9 +555,37 @@ export default class ZnDataTable extends ZincElement {
456
555
  connectedCallback() {
457
556
  super.connectedCallback();
458
557
  this.addEventListener('zn-filter-change', this.filterChangeListener);
558
+ this.addEventListener('zn-clear', this.filterClearListener);
459
559
  this.addEventListener('zn-search-change', this.searchChangeListener);
460
560
  }
461
561
 
562
+ protected updated(changed: PropertyValues) {
563
+ super.updated(changed);
564
+ this.publishFilterSuggestions();
565
+ }
566
+
567
+ // The filter bar can't see the data, so hand it the values already on screen to type against
568
+ private publishFilterSuggestions() {
569
+ const bar = this.querySelector('zn-data-table-filter');
570
+ if (!bar || this._rows.length === 0) return;
571
+
572
+ const suggestions: Record<string, string[]> = {};
573
+
574
+ this._rows.forEach((row: Row) => row.cells.forEach((cell: Cell) => {
575
+ const text = cell.text?.trim();
576
+ if (!text) return;
577
+
578
+ const values = suggestions[cell.column] ?? (suggestions[cell.column] = []);
579
+ if (values.length < SUGGESTION_LIMIT && !values.includes(text)) values.push(text);
580
+ }));
581
+
582
+ const key = JSON.stringify(suggestions);
583
+ if (key === this._suggestionsKey) return;
584
+
585
+ this._suggestionsKey = key;
586
+ bar.suggestions = suggestions;
587
+ }
588
+
462
589
  private getTemplate(name: string): DisplayTemplate | undefined {
463
590
  return this.displayTemplates[name] ?? this._formatTemplates[name];
464
591
  }
@@ -466,9 +593,17 @@ export default class ZnDataTable extends ZincElement {
466
593
  disconnectedCallback() {
467
594
  super.disconnectedCallback();
468
595
  this.removeEventListener('zn-filter-change', this.filterChangeListener);
596
+ this.removeEventListener('zn-clear', this.filterClearListener);
469
597
  this.removeEventListener('zn-search-change', this.searchChangeListener);
470
598
  }
471
599
 
600
+ // Clearing the filters leaves an empty row behind, so fold it away
601
+ filterClearListener = (e: Event) => {
602
+ if (e.target instanceof ZnDataTableFilter) {
603
+ this._filtersOpen = false;
604
+ }
605
+ }
606
+
472
607
  filterChangeListener = (e: ZnFilterChangeEvent) => {
473
608
  if (e.target instanceof ZnDataTableFilter) {
474
609
  this.filter = (e.target as ZnDataTableFilter).value as string;
@@ -542,6 +677,7 @@ export default class ZnDataTable extends ZincElement {
542
677
  renderTable(data: Response) {
543
678
  this.itemsPerPage = Math.max(DEFAULT_PER_PAGE, data.perPage ?? DEFAULT_PER_PAGE);
544
679
  this.page = Math.max(1, data.page ?? DEFAULT_PAGE);
680
+ this._totalRows = Math.max(0, data.total ?? 0);
545
681
  this.totalPages = Math.ceil(Math.max(1, data.total) / this.itemsPerPage);
546
682
 
547
683
  const error = data?.error?.text ? data.error : undefined;
@@ -610,21 +746,15 @@ export default class ZnDataTable extends ZincElement {
610
746
  }
611
747
  });
612
748
 
613
- // render a table for each group
614
- return html`
615
- <zn-sp flush>
616
- ${Object.keys(groupedRows).map((groupKey, index) => html`
617
- <div class="table-group">
618
- <h3 class="table-group__title">${(groupKey === "Ungrouped" || groupKey === "*") ? "" : groupKey}</h3>
619
- ${this.renderTableData(groupedRows[groupKey], index === 0 ? error : undefined)}
620
- </div>`
621
- )}
622
- </zn-sp>
623
- `;
624
- }
749
+ const groups: RowGroup[] = Object.keys(groupedRows).map((groupKey) => ({
750
+ label: (groupKey === 'Ungrouped' || groupKey === '*') ? '' : groupKey,
751
+ rows: groupedRows[groupKey],
752
+ }));
625
753
 
754
+ return this.renderTableData(groups, error);
755
+ }
626
756
 
627
- return this.renderTableData(this._rows, error);
757
+ return this.renderTableData([{label: '', rows: this._rows}], error);
628
758
  }
629
759
 
630
760
  private renderErrorAlert(error: ResponseError) {
@@ -640,15 +770,9 @@ export default class ZnDataTable extends ZincElement {
640
770
  return str.charAt(0).toUpperCase() + str.slice(1);
641
771
  }
642
772
 
643
- public renderTableData(data: any, error?: ResponseError) {
644
- // Primary (visible) headers exclude those explicitly hidden and those marked as secondary
645
- const filteredHeaders = Object.values(this.headers).filter((header: HeaderConfig) => {
646
- if (header.hideHeader || header.hideColumn) return false;
647
-
648
- if (Object.values(this.hiddenColumns).includes(header.key)) return false;
649
-
650
- return !header.secondary;
651
- });
773
+ public renderTableData(groups: RowGroup[], error?: ResponseError) {
774
+ // Primary (visible) headers exclude those explicitly hidden, deselected and those marked as secondary
775
+ const filteredHeaders = this.visibleHeaders();
652
776
 
653
777
  // Secondary headers (shown in expandable details)
654
778
  this._secondaryHeaders = Object.values(this.headers).filter((header: HeaderConfig) => {
@@ -689,10 +813,9 @@ export default class ZnDataTable extends ZincElement {
689
813
  const colCount = filteredHeaders.length + (this.rowHasActions ? 1 : 0) + (anyHidden ? 1 : 0);
690
814
 
691
815
  return html`
692
- <div style="overflow-x: auto">
816
+ <div class="table__scroll">
693
817
  <table class="${classMap({
694
818
  'table': true,
695
- 'table--standalone': this.standalone,
696
819
  'with-hover': !this.unsortable && !this.hideCheckboxes,
697
820
  })}">
698
821
  <thead>
@@ -709,60 +832,167 @@ export default class ZnDataTable extends ZincElement {
709
832
  <tr class="table__row--error">
710
833
  <td colspan="${colCount}">${this.renderErrorAlert(error)}</td>
711
834
  </tr>` : nothing}
712
- ${(data as Row[]).map((row: Row) => html`
713
- <tr class="${classMap({
714
- 'table__row--selected': this.isRowSelected(row),
715
- 'table__row--data': true,
716
- })}" data-row-id="${row.id}">
717
- ${anyHidden ? this.renderExpanderCell(row) : nothing}
718
- ${(visibleRowCells.get(row.id) || row.cells).map((value: Cell, index: number) => this.renderCellBody(index, value, row))}
719
- ${this.rowHasActions ? this.renderActions(row) : nothing}
720
- </tr>
721
- ${this._expandedRows.has(row.id) ? this.renderDetailsRow(row, colCount) : nothing}
835
+ ${groups.map((group: RowGroup) => html`
836
+ ${group.label ? html`
837
+ <tr class="table__row--group">
838
+ <th colspan="${colCount}" scope="colgroup">${group.label}</th>
839
+ </tr>` : nothing}
840
+ ${group.rows.map((row: Row) => html`
841
+ <tr class="${classMap({
842
+ 'table__row--selected': this.isRowSelected(row),
843
+ 'table__row--data': true,
844
+ })}" data-row-id="${row.id}">
845
+ ${anyHidden ? this.renderExpanderCell(row) : nothing}
846
+ ${(visibleRowCells.get(row.id) || row.cells).map((value: Cell, index: number) => this.renderCellBody(index, value, row))}
847
+ ${this.rowHasActions ? this.renderActions(row) : nothing}
848
+ </tr>
849
+ ${this._expandedRows.has(row.id) ? this.renderDetailsRow(row, colCount) : nothing}
850
+ `)}
722
851
  `)}
723
852
  </tbody>
724
853
  </table>
725
854
  </div>
726
-
727
- ${this.getTableFooter()}
728
855
  `;
729
856
  }
730
857
 
731
858
  getTableHeader() {
732
859
  return html`
733
- <slot name="${ActionSlots.filter_top.valueOf()}"></slot>
734
860
  <div class="table__header">
735
861
  <div class="table__header__actions">
862
+ ${this.caption ? html`
863
+ <h3 class="table__header__caption">${this.caption}</h3>` : nothing}
736
864
  ${this.getActions()}
737
865
  </div>
738
866
  <div class="table__header__right">
739
- <slot name="${ActionSlots.search.valueOf()}"></slot>
740
- <slot name="${ActionSlots.sort.valueOf()}"></slot>
741
- <slot name="${ActionSlots.filter.valueOf()}"></slot>
867
+ ${this.getHeaderControls()}
742
868
  </div>
743
869
  </div>
744
870
  `;
745
871
  }
746
872
 
747
- getTableFooter() {
873
+ private getHeaderControls() {
874
+ return html`
875
+ ${this.getColumnSelect()}
876
+ ${this.getFilterToggle()}
877
+ ${this.getRefreshButton()}
878
+ <slot name="${ActionSlots.sort.valueOf()}"></slot>
879
+ <slot name="${ActionSlots.search.valueOf()}"></slot>
880
+ <slot name="${ActionSlots.create.valueOf()}"></slot>`;
881
+ }
882
+
883
+ // Nothing to pick columns on or refresh into while the table has no rows
884
+ private hasRows(): boolean {
885
+ return this._hasLoadedData && this._lastLoadHadRows;
886
+ }
887
+
888
+ private getFilterToggle() {
889
+ if (!this.hasSlotController.test(ActionSlots.filter.valueOf())) return nothing;
890
+
891
+ const applied = this.appliedFilterCount();
892
+
893
+ return html`
894
+ <zn-button icon-button="small"
895
+ icon="funnel@lu"
896
+ icon-size="18"
897
+ tooltip="Filter"
898
+ aria-label="Filter"
899
+ muted-notifications
900
+ notification="${applied || nothing}"
901
+ aria-pressed="${this._filtersOpen}"
902
+ class="${classMap({'table__header__toggle--active': applied > 0})}"
903
+ @click="${this.toggleFilters}">
904
+ </zn-button>`;
905
+ }
906
+
907
+ private toggleFilters = () => {
908
+ this._filtersOpen = !this._filtersOpen;
909
+ }
910
+
911
+ private appliedFilterCount(): number {
912
+ if (!this.filter) return 0;
913
+
914
+ try {
915
+ const applied: unknown = JSON.parse(atob(this.filter));
916
+ return Array.isArray(applied) ? applied.length : 0;
917
+ } catch {
918
+ return 0;
919
+ }
920
+ }
921
+
922
+ private hasColumnSelect(): boolean {
923
+ return !this.hideColumnSelect && this.hasRows() && this.selectableHeaders().length > 1;
924
+ }
925
+
926
+ private hasRefresh(): boolean {
927
+ return !this.hideRefresh && this.hasRows() && !!this.dataUri;
928
+ }
929
+
930
+ private getColumnSelect() {
931
+ if (!this.hasColumnSelect()) return nothing;
932
+
933
+ return html`
934
+ <zn-dropdown placement="bottom-end" stay-open-on-select>
935
+ <zn-button slot="trigger"
936
+ icon-button="small"
937
+ icon="columns-3@lu"
938
+ icon-size="18"
939
+ tooltip="Columns"
940
+ aria-label="Select columns">
941
+ </zn-button>
942
+ <zn-menu variant="shell" @zn-menu-select="${this.handleColumnSelect}">
943
+ ${this.selectableHeaders().map((header: HeaderConfig) => html`
944
+ <zn-menu-item type="checkbox"
945
+ value="${header.key}"
946
+ ?checked="${this.isColumnVisible(header)}"
947
+ ?disabled="${header.required === true}">${header.label}
948
+ </zn-menu-item>`)}
949
+ </zn-menu>
950
+ </zn-dropdown>`;
951
+ }
952
+
953
+ private getRefreshButton() {
954
+ if (!this.hasRefresh()) return nothing;
955
+
956
+ return html`
957
+ <zn-button icon-button="small"
958
+ icon="refresh-cw@lu"
959
+ icon-size="18"
960
+ tooltip="Refresh"
961
+ aria-label="Refresh data"
962
+ @click="${this.refresh}">
963
+ </zn-button>`;
964
+ }
965
+
966
+ private handleColumnSelect = (event: CustomEvent<{ value: string }>) => {
967
+ const header = (Object.values(this.headers) as HeaderConfig[])
968
+ .find((h: HeaderConfig) => h.key === event.detail.value);
969
+
970
+ if (header) this.toggleColumn(header);
971
+ }
972
+
973
+ // Returns null when there is nothing to show, so a panel footer is only slotted when it has content
974
+ getTableFooter(slot?: string) {
975
+ if (!this._hasLoadedData) return null;
976
+
748
977
  const rowSelected = this.getRowsSelected();
749
978
  const pagination = this.getPagination();
750
979
  const rowsPerPage = this.getRowsPerPage();
751
980
 
752
- if (rowSelected !== null || pagination !== null || rowsPerPage !== null) {
753
- return html`
754
- <div class="table__footer">
755
- <div class="table__footer__left">
756
- ${pagination}
757
- ${rowSelected}
758
- </div>
759
- <div class="table__footer__right">
760
- ${rowsPerPage}
761
- </div>
762
- </div>`;
763
- }
981
+ if (rowSelected === null && pagination === null && rowsPerPage === null) return null;
764
982
 
765
- return html``;
983
+ return html`
984
+ <div class="${classMap({
985
+ 'table__footer': true,
986
+ 'table__footer--panel': !!slot,
987
+ })}" slot="${ifDefined(slot)}">
988
+ <div class="table__footer__left">
989
+ ${pagination}
990
+ ${rowSelected}
991
+ </div>
992
+ <div class="table__footer__right">
993
+ ${rowsPerPage}
994
+ </div>
995
+ </div>`;
766
996
  }
767
997
 
768
998
  getRowsSelected() {
@@ -773,14 +1003,17 @@ export default class ZnDataTable extends ZincElement {
773
1003
  }
774
1004
 
775
1005
  getRowsPerPage() {
776
- if (this.hidePagination || (this.totalPages <= 1 && this._rows.length <= this.itemsPerPage)) return null;
1006
+ if (this.hidePagination) return null;
777
1007
 
778
1008
  const optionsRowsPerPage = [10, 20, 30, 40, 50];
779
- optionsRowsPerPage.filter((option) => option <= this._rows.length);
1009
+
1010
+ // Keyed to the dataset, not the current page size - raising the size past the total
1011
+ // must not hide the only control that can lower it again
1012
+ if (this._totalRows <= optionsRowsPerPage[0]) return null;
780
1013
 
781
1014
  return html`
782
1015
  <div class="table__footer__rows-per-page">
783
- <p>Rows per page</p>
1016
+ <p>Rows</p>
784
1017
  <zn-select name="rowPerPage"
785
1018
  size="small"
786
1019
  value="${this.itemsPerPage}"
@@ -794,76 +1027,67 @@ export default class ZnDataTable extends ZincElement {
794
1027
  </div>`;
795
1028
  }
796
1029
 
797
- private getPageRange(): (number | 'ellipsis')[] {
798
- const total = this.totalPages;
799
- const current = this.page;
800
-
801
- if (total <= 7) {
802
- return Array.from({length: total}, (_, i) => i + 1);
803
- }
804
-
805
- const showLeftEllipsis = current > 4;
806
- const showRightEllipsis = current < total - 3;
1030
+ // A window of PAGE_WINDOW pages that slides with the current page and stays inside the page
1031
+ // count; the first/last buttons cover jumping to either end
1032
+ private getPageRange(): number[] {
1033
+ const size = Math.min(PAGE_WINDOW, this.totalPages);
1034
+ const start = Math.min(
1035
+ Math.max(1, this.page - Math.floor(size / 2)),
1036
+ this.totalPages - size + 1
1037
+ );
807
1038
 
808
- if (!showLeftEllipsis && showRightEllipsis) {
809
- return [1, 2, 3, 4, 5, 'ellipsis', total];
810
- }
811
-
812
- if (showLeftEllipsis && !showRightEllipsis) {
813
- return [1, 'ellipsis', total - 4, total - 3, total - 2, total - 1, total];
814
- }
815
-
816
- return [1, 'ellipsis', current - 1, current, current + 1, 'ellipsis', total];
1039
+ return Array.from({length: size}, (_, i) => start + i);
817
1040
  }
818
1041
 
819
1042
  getPagination() {
820
1043
  if (this.hidePagination || this.totalPages <= 1) return null;
821
1044
 
822
1045
  return html`
823
- <div class="table__footer__pagination-buttons">
1046
+ <zn-button-group>
824
1047
  <zn-button @click="${this.page !== 1 ? this.goToFirstPage : undefined}"
825
1048
  ?disabled="${this.page === 1}"
826
1049
  icon="chevrons-left@lu"
827
1050
  icon-button="small"
828
- panel-bg>
1051
+ label="First page">
829
1052
  </zn-button>
830
1053
  <zn-button @click="${this.page !== 1 ? this.goToPreviousPage : undefined}"
831
1054
  ?disabled="${this.page === 1}"
832
1055
  icon="chevron-left@lu"
833
1056
  icon-button="small"
834
- panel-bg>
1057
+ label="Previous page">
835
1058
  </zn-button>
836
- ${this.getPageRange().map((p) => p === 'ellipsis'
837
- ? html`<span class="table__footer__pagination-ellipsis">…</span>`
838
- : html`
839
- <zn-button @click="${p !== this.page ? () => this.goToPage(p) : undefined}"
840
- icon-button="small"
841
- class="${p === this.page ? 'table__footer__pagination-page--active' : ''}">${p}
842
- </zn-button>`
843
- )}
1059
+ ${this.getPageRange().map((p: number) => html`
1060
+ <zn-button @click="${p !== this.page ? () => this.goToPage(p) : undefined}"
1061
+ class="${classMap({
1062
+ 'table__footer__pagination-page': true,
1063
+ 'table__footer__pagination-page--active': p === this.page,
1064
+ })}"
1065
+ icon-button="small"
1066
+ aria-current="${ifDefined(p === this.page ? 'page' : undefined)}">${p}
1067
+ </zn-button>`)}
844
1068
  <zn-button @click="${this.page !== this.totalPages ? this.goToNextPage : undefined}"
845
1069
  ?disabled="${this.page === this.totalPages}"
846
1070
  icon="chevron-right@lu"
847
1071
  icon-button="small"
848
- panel-bg>
1072
+ label="Next page">
849
1073
  </zn-button>
850
1074
  <zn-button @click="${this.page !== this.totalPages ? this.goToLastPage : undefined}"
851
1075
  ?disabled="${this.page === this.totalPages}"
852
1076
  icon="chevrons-right@lu"
853
1077
  icon-button="small"
854
- panel-bg>
1078
+ label="Last page">
855
1079
  </zn-button>
856
- </div>`;
1080
+ </zn-button-group>`;
857
1081
  }
858
1082
 
859
1083
  getActions() {
860
1084
  const actions = [];
861
1085
 
862
- const hasSlots = this.hasSlotController.test(ActionSlots.delete.valueOf())
863
- || this.hasSlotController.test(ActionSlots.modify.valueOf())
864
- || this.hasSlotController.test(ActionSlots.create.valueOf());
1086
+ // Select-all only earns its place alongside the bulk actions it feeds
1087
+ const hasBulkActions = this.hasSlotController.test(ActionSlots.delete.valueOf())
1088
+ || this.hasSlotController.test(ActionSlots.modify.valueOf());
865
1089
 
866
- if (!hasSlots) {
1090
+ if (!hasBulkActions) {
867
1091
  return [];
868
1092
  }
869
1093
 
@@ -901,11 +1125,6 @@ export default class ZnDataTable extends ZincElement {
901
1125
  }
902
1126
  }
903
1127
 
904
- if (this.hasSlotController.test(ActionSlots.create.valueOf())) {
905
- actions.push(html`
906
- <slot name="${ActionSlots.create.valueOf()}"></slot>`);
907
- }
908
-
909
1128
  return actions;
910
1129
  }
911
1130
 
@@ -1192,16 +1411,7 @@ export default class ZnDataTable extends ZincElement {
1192
1411
  private renderCellHeader(header: HeaderConfig) {
1193
1412
  const sortable = !Object.values(this.unsortableHeaders).includes(header.key) && !Object.values(this.hiddenHeaders).includes(header.key) && !this.unsortable && header.sortable !== false;
1194
1413
 
1195
- // Determine the last visible header considering secondary flags and hidden columns
1196
- const lastVisibleHeaderKey = Object.values(this.headers)
1197
- .filter((h: HeaderConfig) => {
1198
- if (h.hideHeader || h.hideColumn) return false;
1199
-
1200
- if (Object.values(this.hiddenColumns).includes(h.key)) return false;
1201
-
1202
- return !h.secondary;
1203
- })
1204
- .slice(-1)[0]?.key;
1414
+ const lastVisibleHeaderKey = this.visibleHeaders().slice(-1)[0]?.key;
1205
1415
 
1206
1416
  return html`
1207
1417
  <th
@@ -1221,13 +1431,7 @@ export default class ZnDataTable extends ZincElement {
1221
1431
  }
1222
1432
 
1223
1433
  private renderCellBody(index: number, value: Cell, row: Row) {
1224
- const filteredHeaders = Object.values(this.headers).filter((header: HeaderConfig) => {
1225
- if (header.hideHeader || header.hideColumn) return false;
1226
-
1227
- if (Object.values(this.hiddenColumns).includes(header.key)) return false;
1228
-
1229
- return !header.secondary;
1230
- });
1434
+ const filteredHeaders = this.visibleHeaders();
1231
1435
  const header: HeaderConfig | undefined = filteredHeaders[index];
1232
1436
  const headerKey: string | undefined = header?.key;
1233
1437
 
@@ -1253,14 +1457,15 @@ export default class ZnDataTable extends ZincElement {
1253
1457
  <td class="table__cell table__cell--expander"></td>`;
1254
1458
 
1255
1459
  const expanded = this._expandedRows.has(row.id);
1256
- const icon = expanded ? 'chevron_down@lu' : 'chevron_right';
1460
+ const icon = expanded ? 'chevron_down@lu' : 'chevron_right@lu';
1257
1461
 
1258
1462
  return html`
1259
1463
  <td class="table__cell table__cell--expander">
1260
1464
  <zn-button
1261
- color="transparent"
1465
+ icon-button="small"
1466
+ plain
1262
1467
  icon="${icon}"
1263
- icon-size="18"
1468
+ icon-size="20"
1264
1469
  aria-label="${expanded ? 'Collapse row' : 'Expand row'}"
1265
1470
  @click="${(e: Event) => this.toggleRowExpansion(e, row)}">
1266
1471
  </zn-button>
@@ -1404,11 +1609,8 @@ export default class ZnDataTable extends ZincElement {
1404
1609
 
1405
1610
  private loadingTable() {
1406
1611
  return html`
1407
- <div class="table-container">
1408
- <table class="${classMap({
1409
- 'table': true,
1410
- 'table--standalone': this.standalone,
1411
- })}">
1612
+ <div class="table__scroll">
1613
+ <table class="table">
1412
1614
  <thead>
1413
1615
  <tr>
1414
1616
  <th colspan="30%"></th>
@@ -1493,7 +1695,7 @@ export default class ZnDataTable extends ZincElement {
1493
1695
  plain
1494
1696
  aria-label="Row actions">
1495
1697
  </zn-button>
1496
- <zn-menu>
1698
+ <zn-menu variant="shell">
1497
1699
  ${row.actions?.map((action: ActionConfig) => {
1498
1700
  if (action.confirmContent) {
1499
1701
  const triggerId = 'confirm-action-' + Math.random().toString(36).substring(2, 15);