@uibit/table 0.1.0

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/dist/table.js ADDED
@@ -0,0 +1,1121 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { html, nothing } from 'lit';
8
+ import { customElement, fromLucide, getIcon, msg, str, UIBitElement } from '@uibit/core';
9
+ import { ChevronLeft, ChevronRight } from 'lucide';
10
+ import { property, state } from 'lit/decorators.js';
11
+ import { styles } from './styles';
12
+ /**
13
+ * Wraps a standard HTML `<table>` in the default slot and layers on a full
14
+ * datagrid feature set. The source table is parsed once on slot assignment and
15
+ * never mutated; all rendering happens inside shadow DOM.
16
+ *
17
+ * @slot - A standard `<table>` element
18
+ * @slot search-placeholder - A slot to customize/translate the search input placeholder
19
+ *
20
+ * @fires {{ query: string }} search
21
+ * @fires {{ sorts: SortEntry[] }} sort
22
+ * @fires {{ page: number }} page-change
23
+ * @fires {{ indices: number[], rows: string[][] }} row-select
24
+ * @fires load-more - Dispatched when infiniteScroll is enabled and scrolled near the bottom
25
+ *
26
+ * @cssprop [--uibit-table-font-size=0.875rem]
27
+ * @cssprop [--uibit-table-font-family=inherit]
28
+ * @cssprop [--uibit-table-color=#111827]
29
+ * @cssprop [--uibit-table-border-color=#e5e7eb]
30
+ * @cssprop [--uibit-table-radius=0.375rem]
31
+ * @cssprop [--uibit-table-bg=#ffffff]
32
+ * @cssprop [--uibit-table-head-bg=#f9fafb]
33
+ * @cssprop [--uibit-table-head-color=#6b7280]
34
+ * @cssprop [--uibit-table-hover-bg=#f9fafb]
35
+ * @cssprop [--uibit-table-selected-bg=#eff6ff]
36
+ * @cssprop [--uibit-table-stripe-bg=#f9fafb]
37
+ * @cssprop [--uibit-table-highlight-bg=#fef9c3]
38
+ * @cssprop [--uibit-table-filter-highlight-bg=#dcfce7]
39
+ * @cssprop [--uibit-table-focus-color=#6b7280]
40
+ * @cssprop [--uibit-table-cell-padding=0.625rem 0.875rem]
41
+ * @cssprop [--uibit-table-max-height=24rem] - Max height when sticky-header is set
42
+ */
43
+ const defaultTrueBoolean = {
44
+ fromAttribute: (value) => value === null ? true : value !== 'false',
45
+ toAttribute: (value) => value ? '' : null
46
+ };
47
+ let Table = class Table extends UIBitElement {
48
+ constructor() {
49
+ super(...arguments);
50
+ // ── Feature toggles ────────────────────────────────────────────
51
+ /** Show the global search input. */
52
+ this.searchable = true;
53
+ /** Show pagination controls and per-page selector. */
54
+ this.paginated = true;
55
+ /** Show CSV export button. */
56
+ this.exportable = true;
57
+ /** Enable row-selection checkboxes. */
58
+ this.selectable = false;
59
+ /** Show per-column filter row below the header. */
60
+ this.filterable = false;
61
+ /** Enable drag-to-resize column handles. */
62
+ this.resizable = false;
63
+ /** Show column visibility toggle in the toolbar. */
64
+ this.columnChooser = false;
65
+ /** Alternating row background. */
66
+ this.striped = false;
67
+ /** Stick the header row when the table overflows vertically. */
68
+ this.stickyHeader = false;
69
+ // ── Config ─────────────────────────────────────────────────────
70
+ /** Comma-separated rows-per-page options. */
71
+ this.pageSizes = '10,25,50,100';
72
+ /** Controls layout: 'inline' or consolidated 'menu' under a dropdown. */
73
+ this.controlsLayout = 'inline';
74
+ /** Enable infinite scroll behavior instead of page pagination. */
75
+ this.infiniteScroll = false;
76
+ /** Indicates the table is loading more items in infinite scroll mode. */
77
+ this.loading = false;
78
+ // ── Reactive state ──────────────────────────────────────────────
79
+ this._cols = [];
80
+ this._rows = [];
81
+ this._query = '';
82
+ this._sorts = [];
83
+ this._page = 1;
84
+ this._perPage = 10;
85
+ this._selected = new Set();
86
+ this._hiddenCols = new Set();
87
+ this._colFilters = new Map();
88
+ this._colMenuOpen = false;
89
+ this._optionsMenuOpen = false;
90
+ this._allFilteredSelected = false;
91
+ this._searchPlaceholderText = '';
92
+ // ── Non-reactive internal ───────────────────────────────────────
93
+ this._colWidths = new Map();
94
+ this._resizing = null;
95
+ this._rafId = 0;
96
+ // ── Header alignment ───────────────────────────────────────────
97
+ this._resizeHandler = () => this._updateHeaderHeight();
98
+ this._cleanupResizeListeners = () => { };
99
+ }
100
+ static { this.styles = styles; }
101
+ // ── Lifecycle ───────────────────────────────────────────────────
102
+ connectedCallback() {
103
+ super.connectedCallback();
104
+ const sizes = this._pageSizeOptions;
105
+ if (sizes.length)
106
+ this._perPage = sizes[0];
107
+ window.addEventListener('resize', this._resizeHandler);
108
+ }
109
+ disconnectedCallback() {
110
+ super.disconnectedCallback();
111
+ this._cleanupResizeListeners();
112
+ this._removeMenuListener();
113
+ window.removeEventListener('resize', this._resizeHandler);
114
+ }
115
+ firstUpdated(changedProperties) {
116
+ super.firstUpdated(changedProperties);
117
+ this._updateHeaderHeight();
118
+ const root = this.shadowRoot;
119
+ if (root) {
120
+ // Scroll listener on wrapper
121
+ const wrap = root.querySelector('.table-wrap');
122
+ if (wrap) {
123
+ wrap.addEventListener('scroll', (e) => this._onScroll(e));
124
+ }
125
+ // Slotchange listener delegation
126
+ root.addEventListener('slotchange', (e) => {
127
+ const slot = e.target;
128
+ if (slot.name === 'search-placeholder') {
129
+ this._onSearchPlaceholderChange(e);
130
+ }
131
+ else if (!slot.name) {
132
+ this._onSlotChange(e);
133
+ }
134
+ });
135
+ // Mousedown listener delegation for resize handles
136
+ root.addEventListener('mousedown', (e) => {
137
+ const target = e.target;
138
+ if (target.classList.contains('resize-handle')) {
139
+ const th = target.closest('th');
140
+ const colKey = th?.getAttribute('data-col-key');
141
+ const col = this._cols.find(c => c.key === colKey);
142
+ if (col) {
143
+ this._onResizeStart(e, col);
144
+ }
145
+ }
146
+ });
147
+ // Click listener delegation
148
+ root.addEventListener('click', (e) => {
149
+ const target = e.target;
150
+ // Column sort click
151
+ const th = target.closest('th');
152
+ if (th && th.classList.contains('sortable')) {
153
+ const isResize = target.classList.contains('resize-handle');
154
+ if (!isResize) {
155
+ const colKey = th.getAttribute('data-col-key');
156
+ const col = this._cols.find(c => c.key === colKey);
157
+ if (col) {
158
+ this._onSort(col, e);
159
+ return;
160
+ }
161
+ }
162
+ }
163
+ // Options menu button click
164
+ const optionsBtn = target.closest('.options-menu-wrap button.ctrl-btn');
165
+ if (optionsBtn) {
166
+ this._optionsMenuOpen ? (this._optionsMenuOpen = false, this._removeMenuListener()) : this._openOptionsMenu();
167
+ return;
168
+ }
169
+ // Column chooser button click
170
+ const colBtn = target.closest('.col-menu-wrap button.ctrl-btn');
171
+ if (colBtn && !optionsBtn) {
172
+ this._colMenuOpen ? (this._colMenuOpen = false, this._removeMenuListener()) : this._openColMenu();
173
+ return;
174
+ }
175
+ // Selection banner buttons
176
+ const clearBtn = target.closest('.sel-banner-btn');
177
+ if (clearBtn) {
178
+ if (clearBtn.classList.contains('sel-banner-btn-clear') || clearBtn.textContent?.includes('Clear')) {
179
+ this._clearSelection();
180
+ }
181
+ else {
182
+ this._onSelectAllFiltered();
183
+ }
184
+ return;
185
+ }
186
+ // Options dropdown item click
187
+ const dropdownBtn = target.closest('.options-dropdown .dropdown-btn');
188
+ if (dropdownBtn) {
189
+ if (dropdownBtn.classList.contains('dropdown-btn-danger')) {
190
+ this._query = '';
191
+ this._colFilters = new Map();
192
+ this._page = 1;
193
+ }
194
+ else {
195
+ this._exportCsv();
196
+ }
197
+ this._optionsMenuOpen = false;
198
+ this._removeMenuListener();
199
+ return;
200
+ }
201
+ // Pagination buttons click
202
+ const pageBtn = target.closest('.pagination .page-btn');
203
+ if (pageBtn) {
204
+ const action = pageBtn.getAttribute('data-page-action');
205
+ const numAttr = pageBtn.getAttribute('data-page-num');
206
+ if (action === 'prev') {
207
+ this._page = Math.max(1, this._page - 1);
208
+ this.dispatchCustomEvent('page-change', { page: this._page });
209
+ }
210
+ else if (action === 'next') {
211
+ this._page = Math.min(this._totalPages, this._page + 1);
212
+ this.dispatchCustomEvent('page-change', { page: this._page });
213
+ }
214
+ else if (numAttr !== null) {
215
+ this._page = parseInt(numAttr, 10);
216
+ this.dispatchCustomEvent('page-change', { page: this._page });
217
+ }
218
+ return;
219
+ }
220
+ // Inline Clear Filters button click
221
+ const inlineClearBtn = target.closest('.controls .ctrl-btn-clear-filters');
222
+ if (inlineClearBtn) {
223
+ this._query = '';
224
+ this._colFilters = new Map();
225
+ this._page = 1;
226
+ return;
227
+ }
228
+ // Export CSV button click
229
+ const exportBtn = target.closest('[part="export-btn"]');
230
+ if (exportBtn) {
231
+ this._exportCsv();
232
+ return;
233
+ }
234
+ // Row click delegation (for selection)
235
+ this._onTableClick(e);
236
+ });
237
+ // Change listener delegation
238
+ root.addEventListener('change', (e) => {
239
+ const target = e.target;
240
+ // Select all header checkbox
241
+ if (target.closest('thead th.col-check input')) {
242
+ this._onSelectAllPage(target.checked);
243
+ return;
244
+ }
245
+ // Row select checkbox
246
+ if (target.closest('tbody td.col-check input')) {
247
+ this._onTableChange(e);
248
+ return;
249
+ }
250
+ // Column show/hide checkbox in options/column dropdowns
251
+ if (target.closest('.col-dropdown-item input')) {
252
+ const label = target.closest('.col-dropdown-item');
253
+ const colLabel = label?.textContent?.trim();
254
+ const col = this._cols.find(c => c.label === colLabel);
255
+ if (col) {
256
+ this._onToggleCol(col);
257
+ }
258
+ return;
259
+ }
260
+ // Rows per page dropdown
261
+ if (target.classList.contains('footer-select')) {
262
+ this._onPerPage(e);
263
+ return;
264
+ }
265
+ });
266
+ // Input listener delegation
267
+ root.addEventListener('input', (e) => {
268
+ const target = e.target;
269
+ if (target.classList.contains('search')) {
270
+ this._onSearch(e);
271
+ }
272
+ else if (target.classList.contains('filter-input')) {
273
+ const th = target.closest('th');
274
+ const colKey = th?.getAttribute('data-col-key');
275
+ const col = this._cols.find(c => c.key === colKey);
276
+ if (col) {
277
+ this._onColFilter(col, e);
278
+ }
279
+ }
280
+ });
281
+ }
282
+ // If slotchange hasn't fired yet (e.g. in test environment), parse manually
283
+ if (this._rows.length === 0) {
284
+ const slot = this.shadowRoot?.querySelector('slot');
285
+ if (slot) {
286
+ this._onSlotChange({ target: slot });
287
+ }
288
+ }
289
+ const placeholderSlot = this.shadowRoot?.querySelector('slot[name="search-placeholder"]');
290
+ if (placeholderSlot) {
291
+ this._onSearchPlaceholderChange({ target: placeholderSlot });
292
+ }
293
+ }
294
+ updated(changedProperties) {
295
+ super.updated(changedProperties);
296
+ const selectAllCheckbox = this.shadowRoot?.querySelector('thead th.col-check input');
297
+ if (selectAllCheckbox) {
298
+ selectAllCheckbox.indeterminate = !this._allPageSelected && this._somePageSelected;
299
+ }
300
+ }
301
+ // ── Data pipeline ───────────────────────────────────────────────
302
+ get _pageSizeOptions() {
303
+ return this.pageSizes.split(',').map(n => parseInt(n.trim(), 10)).filter(Boolean);
304
+ }
305
+ get _visibleCols() {
306
+ return this._cols.filter(c => !this._hiddenCols.has(c.key));
307
+ }
308
+ get _indexed() {
309
+ return this._rows.map((row, i) => ({ i, row }));
310
+ }
311
+ get _colFiltered() {
312
+ if (!this._colFilters.size)
313
+ return this._indexed;
314
+ return this._indexed.filter(({ row }) => {
315
+ for (const [key, val] of this._colFilters) {
316
+ if (!val)
317
+ continue;
318
+ const ci = this._cols.findIndex(c => c.key === key);
319
+ if (ci < 0)
320
+ continue;
321
+ if (!(row[ci] ?? '').toLowerCase().includes(val.toLowerCase()))
322
+ return false;
323
+ }
324
+ return true;
325
+ });
326
+ }
327
+ get _searched() {
328
+ if (!this._query)
329
+ return this._colFiltered;
330
+ const q = this._query.toLowerCase();
331
+ return this._colFiltered.filter(({ row }) => row.some(cell => cell.toLowerCase().includes(q)));
332
+ }
333
+ get _sortedRows() {
334
+ const rows = [...this._searched];
335
+ if (!this._sorts.length)
336
+ return rows;
337
+ return rows.sort((a, b) => {
338
+ for (const { key, dir } of this._sorts) {
339
+ const ci = this._cols.findIndex(c => c.key === key);
340
+ if (ci < 0)
341
+ continue;
342
+ const numeric = this._cols[ci]?.numeric ?? false;
343
+ const av = a.row[ci] ?? '';
344
+ const bv = b.row[ci] ?? '';
345
+ const cmp = numeric
346
+ ? parseFloat(av.replace(/[^0-9.-]/g, '')) - parseFloat(bv.replace(/[^0-9.-]/g, ''))
347
+ : av.localeCompare(bv, undefined, { sensitivity: 'base' });
348
+ if (cmp !== 0)
349
+ return dir === 'asc' ? cmp : -cmp;
350
+ }
351
+ return 0;
352
+ });
353
+ }
354
+ get _pageRows() {
355
+ if (this.infiniteScroll || !this.paginated)
356
+ return this._sortedRows;
357
+ const start = (this._page - 1) * this._perPage;
358
+ return this._sortedRows.slice(start, start + this._perPage);
359
+ }
360
+ get _totalPages() {
361
+ return Math.max(1, Math.ceil(this._sortedRows.length / this._perPage));
362
+ }
363
+ get _allPageSelected() {
364
+ const page = this._pageRows;
365
+ return page.length > 0 && page.every(({ i }) => this._selected.has(i));
366
+ }
367
+ get _somePageSelected() {
368
+ return this._pageRows.some(({ i }) => this._selected.has(i));
369
+ }
370
+ _updateHeaderHeight() {
371
+ if (!this.shadowRoot)
372
+ return;
373
+ const firstRow = this.shadowRoot.querySelector('thead tr:first-child');
374
+ if (firstRow) {
375
+ const height = firstRow.getBoundingClientRect().height;
376
+ this.style.setProperty('--uibit-table-header-height', `${height}px`);
377
+ }
378
+ }
379
+ // ── Slot parsing ────────────────────────────────────────────────
380
+ _onSlotChange(e) {
381
+ const slot = e.target;
382
+ let table = slot.assignedElements({ flatten: true }).find(el => el.tagName === 'TABLE');
383
+ if (!table) {
384
+ table = this.querySelector('table');
385
+ }
386
+ if (!table)
387
+ return;
388
+ this._cols = Array.from(table.querySelectorAll('thead th, thead td')).map(th => {
389
+ const label = th.textContent?.trim() ?? '';
390
+ return {
391
+ key: label.toLowerCase().replace(/\W+/g, '_') || `col_${Math.random().toString(36).slice(2)}`,
392
+ label,
393
+ sortable: th.getAttribute('data-sortable') !== 'false',
394
+ numeric: th.getAttribute('data-type') === 'number',
395
+ };
396
+ });
397
+ this._rows = Array.from(table.querySelectorAll('tbody tr')).map(tr => Array.from(tr.querySelectorAll('td, th')).map(td => td.textContent?.trim() ?? ''));
398
+ this._page = 1;
399
+ this._selected = new Set();
400
+ this._colFilters = new Map();
401
+ this._sorts = [];
402
+ this.updateComplete.then(() => {
403
+ this._updateHeaderHeight();
404
+ });
405
+ }
406
+ _onSearchPlaceholderChange(e) {
407
+ const slot = e.target;
408
+ let text = slot.assignedNodes({ flatten: true }).map(n => n.textContent).join('').trim();
409
+ if (!text) {
410
+ text = this.querySelector('[slot="search-placeholder"]')?.textContent?.trim() || '';
411
+ }
412
+ this._searchPlaceholderText = text;
413
+ }
414
+ // ── Event handlers ──────────────────────────────────────────────
415
+ _onSearch(e) {
416
+ this._query = e.target.value;
417
+ this._page = 1;
418
+ this._allFilteredSelected = false;
419
+ this.dispatchCustomEvent('search', { query: this._query });
420
+ }
421
+ _onSort(col, e) {
422
+ if (!col.sortable)
423
+ return;
424
+ const shift = e.shiftKey && this._sorts.length > 0;
425
+ if (shift) {
426
+ const idx = this._sorts.findIndex(s => s.key === col.key);
427
+ if (idx >= 0) {
428
+ const cur = this._sorts[idx];
429
+ if (cur.dir === 'asc') {
430
+ this._sorts = [...this._sorts.slice(0, idx), { key: col.key, dir: 'desc' }, ...this._sorts.slice(idx + 1)];
431
+ }
432
+ else {
433
+ this._sorts = [...this._sorts.slice(0, idx), ...this._sorts.slice(idx + 1)];
434
+ }
435
+ }
436
+ else {
437
+ this._sorts = [...this._sorts, { key: col.key, dir: 'asc' }];
438
+ }
439
+ }
440
+ else {
441
+ const cur = this._sorts.length === 1 && this._sorts[0].key === col.key ? this._sorts[0] : null;
442
+ if (!cur) {
443
+ this._sorts = [{ key: col.key, dir: 'asc' }];
444
+ }
445
+ else if (cur.dir === 'asc') {
446
+ this._sorts = [{ key: col.key, dir: 'desc' }];
447
+ }
448
+ else {
449
+ this._sorts = [];
450
+ }
451
+ }
452
+ this._page = 1;
453
+ if (this._sorts.length) {
454
+ this.dispatchCustomEvent('sort', { sorts: this._sorts });
455
+ }
456
+ }
457
+ _onPerPage(e) {
458
+ this._perPage = parseInt(e.target.value, 10);
459
+ this._page = 1;
460
+ }
461
+ _onColFilter(col, e) {
462
+ const val = e.target.value;
463
+ const next = new Map(this._colFilters);
464
+ if (val)
465
+ next.set(col.key, val);
466
+ else
467
+ next.delete(col.key);
468
+ this._colFilters = next;
469
+ this._page = 1;
470
+ this._allFilteredSelected = false;
471
+ }
472
+ _onSelectRow(i) {
473
+ const next = new Set(this._selected);
474
+ if (next.has(i))
475
+ next.delete(i);
476
+ else
477
+ next.add(i);
478
+ this._selected = next;
479
+ this._allFilteredSelected = false;
480
+ this._emitSelect();
481
+ }
482
+ _onSelectAllPage(checked) {
483
+ const next = new Set(this._selected);
484
+ for (const { i } of this._pageRows) {
485
+ if (checked)
486
+ next.add(i);
487
+ else
488
+ next.delete(i);
489
+ }
490
+ this._selected = next;
491
+ this._allFilteredSelected = false;
492
+ this._emitSelect();
493
+ }
494
+ _onTableClick(e) {
495
+ if (!this.selectable)
496
+ return;
497
+ const target = e.target;
498
+ const isCheckbox = target.tagName === 'INPUT' && target.type === 'checkbox';
499
+ if (isCheckbox)
500
+ return;
501
+ const tr = target.closest('tr');
502
+ if (!tr)
503
+ return;
504
+ const indexAttr = tr.getAttribute('data-row-index');
505
+ if (indexAttr === null)
506
+ return;
507
+ const i = parseInt(indexAttr, 10);
508
+ this._onSelectRow(i);
509
+ }
510
+ _onTableChange(e) {
511
+ const target = e.target;
512
+ if (target.tagName === 'INPUT' && target.type === 'checkbox') {
513
+ const tr = target.closest('tr');
514
+ if (!tr)
515
+ return;
516
+ const indexAttr = tr.getAttribute('data-row-index');
517
+ if (indexAttr === null)
518
+ return;
519
+ const i = parseInt(indexAttr, 10);
520
+ this._onSelectRow(i);
521
+ }
522
+ }
523
+ _onSelectAllFiltered() {
524
+ const next = new Set();
525
+ for (const { i } of this._sortedRows)
526
+ next.add(i);
527
+ this._selected = next;
528
+ this._allFilteredSelected = true;
529
+ this._emitSelect();
530
+ }
531
+ _clearSelection() {
532
+ this._selected = new Set();
533
+ this._allFilteredSelected = false;
534
+ this._emitSelect();
535
+ }
536
+ _emitSelect() {
537
+ const indices = [...this._selected];
538
+ const rows = indices.map(i => this._rows[i]);
539
+ this.dispatchCustomEvent('row-select', { indices, rows });
540
+ }
541
+ _onToggleCol(col) {
542
+ const next = new Set(this._hiddenCols);
543
+ if (next.has(col.key))
544
+ next.delete(col.key);
545
+ else
546
+ next.add(col.key);
547
+ this._hiddenCols = next;
548
+ this.updateComplete.then(() => {
549
+ this._updateHeaderHeight();
550
+ });
551
+ }
552
+ _openColMenu() {
553
+ this._colMenuOpen = true;
554
+ this._removeMenuListener();
555
+ this._closeMenuHandler = (e) => {
556
+ const path = e.composedPath();
557
+ const wrap = this.shadowRoot?.querySelector('.col-menu-wrap');
558
+ if (wrap && !path.includes(wrap)) {
559
+ this._colMenuOpen = false;
560
+ this._removeMenuListener();
561
+ }
562
+ };
563
+ this._escMenuHandler = (e) => {
564
+ if (e.key === 'Escape') {
565
+ this._colMenuOpen = false;
566
+ this._removeMenuListener();
567
+ const btn = this.shadowRoot?.querySelector('.col-menu-wrap .ctrl-btn');
568
+ btn?.focus();
569
+ }
570
+ };
571
+ setTimeout(() => {
572
+ document.addEventListener('click', this._closeMenuHandler);
573
+ document.addEventListener('keydown', this._escMenuHandler);
574
+ }, 0);
575
+ }
576
+ _openOptionsMenu() {
577
+ this._optionsMenuOpen = true;
578
+ this._removeMenuListener();
579
+ this._closeMenuHandler = (e) => {
580
+ const path = e.composedPath();
581
+ const wrap = this.shadowRoot?.querySelector('.options-menu-wrap');
582
+ if (wrap && !path.includes(wrap)) {
583
+ this._optionsMenuOpen = false;
584
+ this._removeMenuListener();
585
+ }
586
+ };
587
+ this._escMenuHandler = (e) => {
588
+ if (e.key === 'Escape') {
589
+ this._optionsMenuOpen = false;
590
+ this._removeMenuListener();
591
+ const btn = this.shadowRoot?.querySelector('.options-menu-wrap .ctrl-btn');
592
+ btn?.focus();
593
+ }
594
+ };
595
+ setTimeout(() => {
596
+ document.addEventListener('click', this._closeMenuHandler);
597
+ document.addEventListener('keydown', this._escMenuHandler);
598
+ }, 0);
599
+ }
600
+ _removeMenuListener() {
601
+ if (this._closeMenuHandler) {
602
+ document.removeEventListener('click', this._closeMenuHandler);
603
+ this._closeMenuHandler = undefined;
604
+ }
605
+ if (this._escMenuHandler) {
606
+ document.removeEventListener('keydown', this._escMenuHandler);
607
+ this._escMenuHandler = undefined;
608
+ }
609
+ }
610
+ // ── Infinite scroll ──────────────────────────────────────────────
611
+ _onScroll(e) {
612
+ if (!this.infiniteScroll || this.loading)
613
+ return;
614
+ const wrap = e.currentTarget;
615
+ const threshold = 30; // px threshold from bottom
616
+ const isNearBottom = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight <= threshold;
617
+ if (isNearBottom) {
618
+ this.dispatchCustomEvent('load-more');
619
+ }
620
+ }
621
+ // ── Column resize ───────────────────────────────────────────────
622
+ _onResizeStart(e, col) {
623
+ e.stopPropagation();
624
+ e.preventDefault();
625
+ const th = e.currentTarget.closest('th');
626
+ const startW = th.getBoundingClientRect().width;
627
+ this._resizing = { key: col.key, startX: e.clientX, startW };
628
+ this.shadowRoot?.querySelector('.table-wrap')?.classList.add('resizing');
629
+ const onMove = (ev) => {
630
+ if (!this._resizing)
631
+ return;
632
+ const diff = ev.clientX - this._resizing.startX;
633
+ const newW = Math.max(60, this._resizing.startW + diff);
634
+ this._colWidths.set(this._resizing.key, newW);
635
+ if (!this._rafId) {
636
+ this._rafId = requestAnimationFrame(() => {
637
+ this._rafId = 0;
638
+ this.requestUpdate();
639
+ });
640
+ }
641
+ };
642
+ const onUp = () => {
643
+ this._resizing = null;
644
+ this.shadowRoot?.querySelector('.table-wrap')?.classList.remove('resizing');
645
+ this._cleanupResizeListeners();
646
+ };
647
+ document.addEventListener('mousemove', onMove);
648
+ document.addEventListener('mouseup', onUp, { once: true });
649
+ this._cleanupResizeListeners = () => {
650
+ document.removeEventListener('mousemove', onMove);
651
+ document.removeEventListener('mouseup', onUp);
652
+ this._cleanupResizeListeners = () => { };
653
+ };
654
+ }
655
+ // ── Export ──────────────────────────────────────────────────────
656
+ _exportCsv() {
657
+ const exportRows = this._selected.size > 0
658
+ ? [...this._selected].map(i => this._rows[i])
659
+ : this._sortedRows.map(r => r.row);
660
+ const visKeys = new Set(this._visibleCols.map(c => c.key));
661
+ const visCols = this._cols.filter(c => visKeys.has(c.key));
662
+ const csvCell = (val) => {
663
+ const escaped = val.replace(/"/g, '""');
664
+ return /^[=+\-@\t\r]/.test(val) ? `"'${escaped}"` : `"${escaped}"`;
665
+ };
666
+ const header = visCols.map(c => csvCell(c.label)).join(',');
667
+ const colIdxs = visCols.map(c => this._cols.findIndex(col => col.key === c.key));
668
+ const body = exportRows.map(row => colIdxs.map(ci => csvCell(row[ci] ?? '')).join(','));
669
+ const csv = [header, ...body].join('\n');
670
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
671
+ const url = URL.createObjectURL(blob);
672
+ const a = document.createElement('a');
673
+ a.href = url;
674
+ a.download = 'table-export.csv';
675
+ a.click();
676
+ URL.revokeObjectURL(url);
677
+ }
678
+ // ── Highlight helpers ───────────────────────────────────────────
679
+ _highlight(text, colKey) {
680
+ let result = text;
681
+ const colFilter = this._colFilters.get(colKey);
682
+ if (colFilter) {
683
+ result = this._applyHighlight(text, colFilter, 'filter-highlight');
684
+ }
685
+ if (this._query) {
686
+ const src = typeof result === 'string' ? result : text;
687
+ return this._applyHighlight(src, this._query, 'highlight');
688
+ }
689
+ return result;
690
+ }
691
+ _applyHighlight(text, term, cls) {
692
+ const idx = text.toLowerCase().indexOf(term.toLowerCase());
693
+ if (idx < 0)
694
+ return text;
695
+ return html `${text.slice(0, idx)}<mark class=${cls}>${text.slice(idx, idx + term.length)}</mark>${text.slice(idx + term.length)}`;
696
+ }
697
+ // ── Render helpers ──────────────────────────────────────────────
698
+ _colStyle(col) {
699
+ const w = this._colWidths.get(col.key);
700
+ return w ? `width:${w}px;min-width:${w}px` : '';
701
+ }
702
+ _renderSortIcon(col) {
703
+ if (!col.sortable)
704
+ return nothing;
705
+ const entry = this._sorts.find(s => s.key === col.key);
706
+ const asc = entry?.dir === 'asc';
707
+ const desc = entry?.dir === 'desc';
708
+ const sortIdx = this._sorts.findIndex(s => s.key === col.key);
709
+ return html `
710
+ <svg class="sort-icon" viewBox="0 0 10 14" fill="none" aria-hidden="true">
711
+ <path d="M5 1v12M2 4l3-3 3 3" stroke="currentColor" stroke-width="1.4"
712
+ stroke-linecap="round" stroke-linejoin="round"
713
+ opacity=${asc ? '1' : entry ? '0.25' : '0.35'} />
714
+ <path d="M5 13V1M2 10l3 3 3-3" stroke="currentColor" stroke-width="1.4"
715
+ stroke-linecap="round" stroke-linejoin="round"
716
+ opacity=${desc ? '1' : entry ? '0.25' : '0.35'} />
717
+ </svg>
718
+ ${this._sorts.length > 1 && sortIdx >= 0
719
+ ? html `<span class="sort-badge">${sortIdx + 1}</span>`
720
+ : nothing}
721
+ `;
722
+ }
723
+ _renderPagination() {
724
+ const total = this._totalPages;
725
+ if (total <= 1)
726
+ return nothing;
727
+ const pages = [];
728
+ if (total <= 7) {
729
+ for (let i = 1; i <= total; i++)
730
+ pages.push(i);
731
+ }
732
+ else {
733
+ pages.push(1);
734
+ if (this._page > 3)
735
+ pages.push('…');
736
+ for (let i = Math.max(2, this._page - 1); i <= Math.min(total - 1, this._page + 1); i++)
737
+ pages.push(i);
738
+ if (this._page < total - 2)
739
+ pages.push('…');
740
+ pages.push(total);
741
+ }
742
+ return html `
743
+ <nav class="pagination" aria-label=${msg('Pagination')}>
744
+ <button class="page-btn" ?disabled=${this._page === 1} data-page-action="prev" aria-label=${msg('Previous')}>${getIcon('chevron-left', 14, fromLucide(ChevronLeft))}</button>
745
+ ${pages.map(p => p === '…'
746
+ ? html `<span class="page-btn" style="cursor:default;border-color:transparent;background:transparent">…</span>`
747
+ : html `<button
748
+ class="page-btn ${this._page === p ? 'active' : ''}"
749
+ data-page-num=${p}
750
+ aria-current=${this._page === p ? 'page' : nothing}
751
+ >${p}</button>`)}
752
+ <button class="page-btn" ?disabled=${this._page === this._totalPages} data-page-action="next" aria-label=${msg('Next')}>${getIcon('chevron-right', 14, fromLucide(ChevronRight))}</button>
753
+ </nav>
754
+ `;
755
+ }
756
+ _renderSelectionBanner() {
757
+ if (!this.selectable || this._selected.size === 0)
758
+ return nothing;
759
+ const filteredTotal = this._sortedRows.length;
760
+ const selCount = this._selected.size;
761
+ const allFiltered = this._allFilteredSelected || selCount === filteredTotal;
762
+ return html `
763
+ <div class="sel-banner" role="status" aria-live="polite">
764
+ <span class="sel-banner-count">${msg(str `${selCount} row${selCount === 1 ? '' : 's'} selected`)}</span>
765
+ ${!allFiltered && filteredTotal > this._perPage ? html `
766
+ <span class="sel-banner-sep">·</span>
767
+ <button class="sel-banner-btn sel-banner-btn-all">
768
+ ${msg(str `Select all ${filteredTotal} rows`)}
769
+ </button>
770
+ ` : nothing}
771
+ <span class="sel-banner-sep">·</span>
772
+ <button class="sel-banner-btn sel-banner-btn-clear">${msg('Clear selection')}</button>
773
+ </div>
774
+ `;
775
+ }
776
+ _renderColMenu() {
777
+ if (!this.columnChooser)
778
+ return nothing;
779
+ return html `
780
+ <div class="col-menu-wrap">
781
+ <button
782
+ class="ctrl-btn"
783
+ aria-haspopup="true"
784
+ aria-expanded=${this._colMenuOpen ? 'true' : 'false'}
785
+ >${msg('Columns')} <span class="chevron">▾</span></button>
786
+ ${this._colMenuOpen ? html `
787
+ <div class="col-dropdown" role="menu">
788
+ ${this._cols.map(col => html `
789
+ <label class="col-dropdown-item" role="menuitemcheckbox">
790
+ <input
791
+ type="checkbox"
792
+ ?checked=${!this._hiddenCols.has(col.key)}
793
+ />
794
+ ${col.label}
795
+ </label>
796
+ `)}
797
+ </div>
798
+ ` : nothing}
799
+ </div>
800
+ `;
801
+ }
802
+ _renderOptionsMenu() {
803
+ const hasActiveFilters = this._colFilters.size > 0 || this._query;
804
+ return html `
805
+ <div class="options-menu-wrap col-menu-wrap">
806
+ <button
807
+ class="ctrl-btn"
808
+ aria-haspopup="true"
809
+ aria-expanded=${this._optionsMenuOpen ? 'true' : 'false'}
810
+ >
811
+ <svg class="icon-cog" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true" style="width:14px;height:14px;display:inline-block;vertical-align:middle;margin-right:2px;">
812
+ <circle cx="8" cy="8" r="2"/>
813
+ <path d="M8 1v2m0 10v2M1 8h2m10 0h2m-2.5-4.5l-1.5 1.5M4 12l-1.5 1.5m9.5 0l-1.5-1.5M4 4L2.5 2.5"/>
814
+ </svg>
815
+ ${msg('Options')}
816
+ <span class="chevron">▾</span>
817
+ </button>
818
+ ${this._optionsMenuOpen ? html `
819
+ <div class="col-dropdown options-dropdown" role="menu">
820
+ ${this.columnChooser ? html `
821
+ <div class="dropdown-section-title">${msg('Columns')}</div>
822
+ ${this._cols.map(col => html `
823
+ <label class="col-dropdown-item" role="menuitemcheckbox">
824
+ <input
825
+ type="checkbox"
826
+ ?checked=${!this._hiddenCols.has(col.key)}
827
+ />
828
+ ${col.label}
829
+ </label>
830
+ `)}
831
+ <div class="dropdown-divider"></div>
832
+ ` : nothing}
833
+
834
+ ${this.exportable ? html `
835
+ <button class="dropdown-btn" role="menuitem">
836
+ ${this._selected.size > 0 ? msg(str `Export ${this._selected.size} rows`) : msg('Export CSV')}
837
+ </button>
838
+ ` : nothing}
839
+
840
+ ${hasActiveFilters ? html `
841
+ <button class="dropdown-btn dropdown-btn-danger" role="menuitem">
842
+ ${msg('Clear filters')}
843
+ </button>
844
+ ` : nothing}
845
+ </div>
846
+ ` : nothing}
847
+ </div>
848
+ `;
849
+ }
850
+ _renderFilterRow() {
851
+ if (!this.filterable)
852
+ return nothing;
853
+ const vis = this._visibleCols;
854
+ return html `
855
+ <tr class="filter-row" aria-label=${msg('Column filters')}>
856
+ ${this.selectable ? html `<th class="col-check"></th>` : nothing}
857
+ ${vis.map(col => html `
858
+ <th style=${this._colStyle(col)} data-col-key=${col.key}>
859
+ <input
860
+ class="filter-input ${this._colFilters.get(col.key) ? 'active' : ''}"
861
+ type="search"
862
+ placeholder=${msg('Filter…')}
863
+ value=${this._colFilters.get(col.key) ?? ''}
864
+ aria-label=${msg(str `Filter ${col.label}`)}
865
+ />
866
+ </th>
867
+ `)}
868
+ </tr>
869
+ `;
870
+ }
871
+ render() {
872
+ const vis = this._visibleCols;
873
+ const pageRows = this._pageRows;
874
+ const total = this._sortedRows.length;
875
+ const start = this.paginated && !this.infiniteScroll ? (this._page - 1) * this._perPage + 1 : 1;
876
+ const end = this.paginated && !this.infiniteScroll ? Math.min(start + this._perPage - 1, total) : total;
877
+ const selectAllChecked = this._allPageSelected;
878
+ const showToolbar = this.searchable || this.exportable || this.columnChooser || (this.paginated && !this.infiniteScroll);
879
+ const hasActiveFilters = this._colFilters.size > 0 || this._query;
880
+ return html `
881
+ <slot></slot>
882
+ <div style="display: none;">
883
+ <slot name="search-placeholder">${msg('Search…')}</slot>
884
+ </div>
885
+
886
+ <div style="display: contents;">
887
+ ${showToolbar ? html `
888
+ <div class="toolbar" part="toolbar">
889
+ ${this.searchable ? html `
890
+ <div class="search-wrap">
891
+ <svg class="search-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
892
+ <circle cx="6.5" cy="6.5" r="5" stroke="currentColor" stroke-width="1.4"/>
893
+ <path d="M10 10l4 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
894
+ </svg>
895
+ <input
896
+ class="search"
897
+ part="search"
898
+ type="search"
899
+ placeholder=${this._searchPlaceholderText || msg('Search…')}
900
+ value=${this._query}
901
+ aria-label=${msg('Search table')}
902
+ />
903
+ </div>
904
+ ` : nothing}
905
+
906
+ <div class="controls">
907
+ ${this.controlsLayout === 'menu' ? this._renderOptionsMenu() : html `
908
+ ${this._renderColMenu()}
909
+
910
+ ${this.exportable ? html `
911
+ <button class="ctrl-btn" part="export-btn" aria-label=${msg('Export as CSV')}>
912
+ ${this._selected.size > 0 ? msg(str `Export ${this._selected.size} rows`) : msg('Export CSV')}
913
+ </button>
914
+ ` : nothing}
915
+
916
+ ${hasActiveFilters ? html `
917
+ <button class="ctrl-btn ctrl-btn-clear-filters" aria-label=${msg('Clear all filters')}>
918
+ ${msg('Clear filters')}
919
+ </button>
920
+ ` : nothing}
921
+ `}
922
+ </div>
923
+ </div>
924
+ ` : nothing}
925
+ </div>
926
+
927
+ <div style="display: contents;">
928
+ ${this._renderSelectionBanner()}
929
+ </div>
930
+
931
+ <div class="table-wrap" part="table-wrap" role="region" aria-label="Data table">
932
+ <table part="table">
933
+ <thead part="thead">
934
+ ${vis.length ? html `
935
+ <tr>
936
+ ${this.selectable ? html `
937
+ <th class="col-check" part="th">
938
+ <input
939
+ type="checkbox"
940
+ checked=${selectAllChecked ? '' : nothing}
941
+ aria-label=${msg('Select all rows on this page')}
942
+ />
943
+ </th>
944
+ ` : nothing}
945
+ ${vis.map(col => {
946
+ const entry = this._sorts.find(s => s.key === col.key);
947
+ const sortClass = entry ? `sort-${entry.dir}` : '';
948
+ return html `
949
+ <th
950
+ class="${col.sortable ? 'sortable' : ''} ${sortClass}"
951
+ part="th"
952
+ style=${this._colStyle(col)}
953
+ data-col-key=${col.key}
954
+ aria-sort=${entry
955
+ ? entry.dir === 'asc' ? 'ascending' : 'descending'
956
+ : nothing}
957
+ title=${this._sorts.length > 0 ? 'Click to sort · Shift+click to add secondary sort' : nothing}
958
+ >
959
+ <span class="th-inner">
960
+ ${col.label}
961
+ ${this._renderSortIcon(col)}
962
+ </span>
963
+ ${this.resizable ? html `
964
+ <div
965
+ class="resize-handle"
966
+ aria-hidden="true"
967
+ ></div>
968
+ ` : nothing}
969
+ </th>
970
+ `;
971
+ })}
972
+ </tr>
973
+ ${this._renderFilterRow()}
974
+ ` : nothing}
975
+ </thead>
976
+
977
+ <tbody part="tbody">
978
+ ${pageRows.length === 0 ? html `
979
+ <tr>
980
+ <td
981
+ colspan=${(this.selectable ? 1 : 0) + vis.length || 1}
982
+ class="empty"
983
+ part="empty"
984
+ >${msg('No results found')}</td>
985
+ </tr>
986
+ ` : pageRows.map(({ i, row }) => html `
987
+ <tr
988
+ class=${this._selected.has(i) ? 'row-selected' : ''}
989
+ part="row"
990
+ data-row-index=${i}
991
+ style=${this.selectable ? 'cursor:pointer' : ''}
992
+ >
993
+ ${this.selectable ? html `
994
+ <td class="col-check" part="cell">
995
+ <input
996
+ type="checkbox"
997
+ checked=${this._selected.has(i) ? '' : nothing}
998
+ aria-label=${msg('Select row')}
999
+ />
1000
+ </td>
1001
+ ` : nothing}
1002
+ ${vis.map((col) => html `
1003
+ <td part="cell" style=${this._colStyle(col)}>
1004
+ ${this._highlight(row[this._cols.findIndex(c => c.key === col.key)] ?? '', col.key)}
1005
+ </td>
1006
+ `)}
1007
+ </tr>
1008
+ `)}
1009
+ </tbody>
1010
+ </table>
1011
+ </div>
1012
+
1013
+ <div style="display: contents;">
1014
+ ${(this.paginated || this.infiniteScroll) && total > 0 ? html `
1015
+ <div class="footer" part="footer">
1016
+ <div class="footer-left">
1017
+ <span part="count">
1018
+ ${total === 0 ? msg('No results') : (this.infiniteScroll ? msg(str `${total} rows`) : msg(str `${start}–${end} of ${total} rows`))}
1019
+ ${this._selected.size > 0 ? html ` · <strong>${this._selected.size} selected</strong>` : nothing}
1020
+ </span>
1021
+ ${this.paginated && !this.infiniteScroll ? html `
1022
+ <span class="footer-sep">·</span>
1023
+ <label class="footer-label" for="uibit-per-page-footer">${msg('Rows per page:')}</label>
1024
+ <select id="uibit-per-page-footer" class="ctrl-select footer-select" aria-label=${msg('Rows per page')}>
1025
+ ${this._pageSizeOptions.map(n => html `<option value=${n} selected=${this._perPage === n ? '' : nothing}>${n}</option>`)}
1026
+ </select>
1027
+ ` : nothing}
1028
+ </div>
1029
+ ${this.infiniteScroll && this.loading ? html `
1030
+ <span class="loading-spinner" part="loading-spinner">${msg('Loading more…')}</span>
1031
+ ` : (this.infiniteScroll ? nothing : this._renderPagination())}
1032
+ </div>
1033
+ ` : nothing}
1034
+ </div>
1035
+ `;
1036
+ }
1037
+ };
1038
+ __decorate([
1039
+ property({ converter: defaultTrueBoolean })
1040
+ ], Table.prototype, "searchable", void 0);
1041
+ __decorate([
1042
+ property({ converter: defaultTrueBoolean })
1043
+ ], Table.prototype, "paginated", void 0);
1044
+ __decorate([
1045
+ property({ converter: defaultTrueBoolean })
1046
+ ], Table.prototype, "exportable", void 0);
1047
+ __decorate([
1048
+ property({ type: Boolean })
1049
+ ], Table.prototype, "selectable", void 0);
1050
+ __decorate([
1051
+ property({ type: Boolean })
1052
+ ], Table.prototype, "filterable", void 0);
1053
+ __decorate([
1054
+ property({ type: Boolean })
1055
+ ], Table.prototype, "resizable", void 0);
1056
+ __decorate([
1057
+ property({ type: Boolean, attribute: 'column-chooser' })
1058
+ ], Table.prototype, "columnChooser", void 0);
1059
+ __decorate([
1060
+ property({ type: Boolean, reflect: true })
1061
+ ], Table.prototype, "striped", void 0);
1062
+ __decorate([
1063
+ property({ type: Boolean, attribute: 'sticky-header', reflect: true })
1064
+ ], Table.prototype, "stickyHeader", void 0);
1065
+ __decorate([
1066
+ property({ attribute: 'page-sizes' })
1067
+ ], Table.prototype, "pageSizes", void 0);
1068
+ __decorate([
1069
+ property({ attribute: 'controls-layout' })
1070
+ ], Table.prototype, "controlsLayout", void 0);
1071
+ __decorate([
1072
+ property({ type: Boolean, attribute: 'infinite-scroll' })
1073
+ ], Table.prototype, "infiniteScroll", void 0);
1074
+ __decorate([
1075
+ property({ type: Boolean })
1076
+ ], Table.prototype, "loading", void 0);
1077
+ __decorate([
1078
+ state()
1079
+ ], Table.prototype, "_cols", void 0);
1080
+ __decorate([
1081
+ state()
1082
+ ], Table.prototype, "_rows", void 0);
1083
+ __decorate([
1084
+ state()
1085
+ ], Table.prototype, "_query", void 0);
1086
+ __decorate([
1087
+ state()
1088
+ ], Table.prototype, "_sorts", void 0);
1089
+ __decorate([
1090
+ state()
1091
+ ], Table.prototype, "_page", void 0);
1092
+ __decorate([
1093
+ state()
1094
+ ], Table.prototype, "_perPage", void 0);
1095
+ __decorate([
1096
+ state()
1097
+ ], Table.prototype, "_selected", void 0);
1098
+ __decorate([
1099
+ state()
1100
+ ], Table.prototype, "_hiddenCols", void 0);
1101
+ __decorate([
1102
+ state()
1103
+ ], Table.prototype, "_colFilters", void 0);
1104
+ __decorate([
1105
+ state()
1106
+ ], Table.prototype, "_colMenuOpen", void 0);
1107
+ __decorate([
1108
+ state()
1109
+ ], Table.prototype, "_optionsMenuOpen", void 0);
1110
+ __decorate([
1111
+ state()
1112
+ ], Table.prototype, "_allFilteredSelected", void 0);
1113
+ __decorate([
1114
+ state()
1115
+ ], Table.prototype, "_searchPlaceholderText", void 0);
1116
+ Table = __decorate([
1117
+ customElement('uibit-table')
1118
+ ], Table);
1119
+ export { Table };
1120
+ export default Table;
1121
+ //# sourceMappingURL=table.js.map