@kodaris/krubble-components 1.0.9 → 1.0.11

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.
@@ -0,0 +1,1230 @@
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 { LitElement, html, css, nothing } from 'lit';
8
+ import { customElement, property, state } from 'lit/decorators.js';
9
+ import { classMap } from 'lit/directives/class-map.js';
10
+ import { styleMap } from 'lit/directives/style-map.js';
11
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js';
12
+ import { krBaseCSS } from '../style/base.js';
13
+ import '../button/button.js';
14
+ import { KRSnackbar } from '../snackbar/snackbar.js';
15
+ // === Solr Utilities ===
16
+ const SOLR_RESERVED_CHARS = [
17
+ '"', '+', '-', '&&', '||', '!', '(', ')', '{',
18
+ '}', '[', ']', '^', '~', '*', '?', ':'
19
+ ];
20
+ const SOLR_RESERVED_CHARS_REPLACEMENT = [
21
+ '\\"', '\\+', '\\-', '\\&\\&', '\\|\\|', '\\!', '\\(', '\\)', '\\{',
22
+ '\\}', '\\[', '\\]', '\\^', '\\~', '\\*', '\\?', '\\:'
23
+ ];
24
+ function escapeSolrQuery(query) {
25
+ let escaped = query;
26
+ for (let i = 0; i < SOLR_RESERVED_CHARS.length; i++) {
27
+ escaped = escaped.split(SOLR_RESERVED_CHARS[i]).join(SOLR_RESERVED_CHARS_REPLACEMENT[i]);
28
+ }
29
+ return escaped;
30
+ }
31
+ let KRTable = class KRTable extends LitElement {
32
+ constructor() {
33
+ super(...arguments);
34
+ /**
35
+ * Internal flag to switch between scroll edge modes:
36
+ * - 'overlay': Fixed padding with overlay elements that hide content at edges (scrollbar at viewport edge)
37
+ * - 'edge': Padding scrolls with content, allowing table to reach edges when scrolling
38
+ */
39
+ this._scrollStyle = 'overlay';
40
+ this._data = [];
41
+ this._dataState = 'idle';
42
+ this._page = 1;
43
+ this._pageSize = 50;
44
+ this._totalItems = 0;
45
+ this._totalPages = 0;
46
+ this._searchQuery = '';
47
+ this._canScrollLeft = false;
48
+ this._canScrollRight = false;
49
+ this._canScrollHorizontal = false;
50
+ this._columnPickerOpen = false;
51
+ this._displayedColumns = [];
52
+ this._widthsLocked = false;
53
+ this._resizing = null;
54
+ this._resizeObserver = null;
55
+ this._searchPositionLocked = false;
56
+ this._def = { columns: [] };
57
+ this.def = { columns: [] };
58
+ this._handleClickOutsideColumnPicker = (e) => {
59
+ if (!this._columnPickerOpen)
60
+ return;
61
+ const path = e.composedPath();
62
+ const picker = this.shadowRoot?.querySelector('.column-picker-wrapper');
63
+ if (picker && !path.includes(picker)) {
64
+ this._columnPickerOpen = false;
65
+ }
66
+ };
67
+ this._handleResizeMove = (e) => {
68
+ if (!this._resizing)
69
+ return;
70
+ const col = this._def.columns.find(c => c.id === this._resizing.columnId);
71
+ if (col) {
72
+ const newWidth = this._resizing.startWidth + (e.clientX - this._resizing.startX);
73
+ col.width = `${Math.min(900, Math.max(50, newWidth))}px`;
74
+ this.requestUpdate();
75
+ }
76
+ };
77
+ this._handleResizeEnd = () => {
78
+ this._resizing = null;
79
+ document.removeEventListener('mousemove', this._handleResizeMove);
80
+ document.removeEventListener('mouseup', this._handleResizeEnd);
81
+ };
82
+ }
83
+ connectedCallback() {
84
+ super.connectedCallback();
85
+ this.classList.toggle('kr-table--scroll-overlay', this._scrollStyle === 'overlay');
86
+ this.classList.toggle('kr-table--scroll-edge', this._scrollStyle === 'edge');
87
+ this._fetch();
88
+ this._initRefresh();
89
+ document.addEventListener('click', this._handleClickOutsideColumnPicker);
90
+ this._resizeObserver = new ResizeObserver(() => {
91
+ // Unlock and recalculate on resize since layout changes
92
+ this._searchPositionLocked = false;
93
+ this._updateSearchPosition();
94
+ });
95
+ this._resizeObserver.observe(this);
96
+ }
97
+ disconnectedCallback() {
98
+ super.disconnectedCallback();
99
+ clearInterval(this._refreshTimer);
100
+ document.removeEventListener('click', this._handleClickOutsideColumnPicker);
101
+ this._resizeObserver?.disconnect();
102
+ }
103
+ willUpdate(changedProperties) {
104
+ if (changedProperties.has('def')) {
105
+ // Copy user's def and normalize action columns
106
+ this._def = {
107
+ ...this.def,
108
+ columns: this.def.columns.map(col => {
109
+ if (col.type === 'actions') {
110
+ return { ...col, sticky: 'right', resizable: false };
111
+ }
112
+ return { ...col };
113
+ })
114
+ };
115
+ this._displayedColumns = this._def.displayedColumns || this._def.columns.map(c => c.id);
116
+ this._widthsLocked = false;
117
+ this.classList.remove('kr-table--widths-locked');
118
+ this._fetch();
119
+ this._initRefresh();
120
+ }
121
+ }
122
+ updated(changedProperties) {
123
+ this._updateScrollFlags();
124
+ }
125
+ // ----------------------------------------------------------------------------
126
+ // Public Interface
127
+ // ----------------------------------------------------------------------------
128
+ refresh() {
129
+ this._fetch();
130
+ }
131
+ goToPrevPage() {
132
+ if (this._page > 1) {
133
+ this._page--;
134
+ this._fetch();
135
+ }
136
+ }
137
+ goToNextPage() {
138
+ if (this._page < this._totalPages) {
139
+ this._page++;
140
+ this._fetch();
141
+ }
142
+ }
143
+ goToPage(page) {
144
+ if (page >= 1 && page <= this._totalPages) {
145
+ this._page = page;
146
+ this._fetch();
147
+ }
148
+ }
149
+ // ----------------------------------------------------------------------------
150
+ // Data Fetching
151
+ // ----------------------------------------------------------------------------
152
+ /**
153
+ * Fetches data from the API and updates the table.
154
+ * Shows a loading spinner while fetching, then displays rows on success
155
+ * or an error snackbar on failure.
156
+ * Request/response format depends on dataSource.mode (solr, opensearch, db).
157
+ */
158
+ _fetch() {
159
+ if (!this._def.dataSource)
160
+ return;
161
+ this._dataState = 'loading';
162
+ // Build request based on mode
163
+ let request;
164
+ switch (this._def.dataSource.mode) {
165
+ case 'opensearch':
166
+ throw Error('Opensearch not supported yet');
167
+ case 'db':
168
+ throw Error('DB not supported yet');
169
+ default: // solr
170
+ request = {
171
+ page: this._page - 1,
172
+ size: this._pageSize,
173
+ sorts: [],
174
+ filterFields: [],
175
+ queryFields: [],
176
+ facetFields: []
177
+ };
178
+ if (this._searchQuery?.trim().length) {
179
+ request.queryFields.push({
180
+ name: '_text_',
181
+ operation: 'IS',
182
+ value: escapeSolrQuery(this._searchQuery)
183
+ });
184
+ }
185
+ }
186
+ this._def.dataSource.fetch(request)
187
+ .then(response => {
188
+ // Parse response based on mode
189
+ switch (this._def.dataSource?.mode) {
190
+ case 'opensearch': {
191
+ throw Error('Opensearch not supported yet');
192
+ break;
193
+ }
194
+ case 'db': {
195
+ throw Error('DB not supported yet');
196
+ break;
197
+ }
198
+ default: { // solr
199
+ const res = response;
200
+ this._data = res.data.content;
201
+ this._totalItems = res.data.totalElements;
202
+ this._totalPages = res.data.totalPages;
203
+ this._pageSize = res.data.size;
204
+ }
205
+ }
206
+ this._dataState = 'success';
207
+ this._updateSearchPosition();
208
+ if (!this._widthsLocked)
209
+ this._lockColumnWidths();
210
+ })
211
+ .catch(err => {
212
+ this._dataState = 'error';
213
+ KRSnackbar.show({
214
+ message: err instanceof Error ? err.message : 'Failed to load data',
215
+ type: 'error'
216
+ });
217
+ });
218
+ }
219
+ /**
220
+ * Sets up auto-refresh so the table automatically fetches fresh data
221
+ * at a regular interval (useful for dashboards, monitoring views).
222
+ * Configured via def.refreshInterval in milliseconds.
223
+ */
224
+ _initRefresh() {
225
+ clearInterval(this._refreshTimer);
226
+ if (this._def.refreshInterval && this._def.refreshInterval > 0) {
227
+ this._refreshTimer = window.setInterval(() => {
228
+ this._fetch();
229
+ }, this._def.refreshInterval);
230
+ }
231
+ }
232
+ _handleSearch(e) {
233
+ const input = e.target;
234
+ this._searchQuery = input.value;
235
+ this._page = 1;
236
+ this._fetch();
237
+ }
238
+ _measureTextWidth(text, font) {
239
+ const canvas = document.createElement('canvas');
240
+ const ctx = canvas.getContext('2d');
241
+ ctx.font = font;
242
+ return ctx.measureText(text).width;
243
+ }
244
+ _lockColumnWidths() {
245
+ this.updateComplete.then(() => {
246
+ requestAnimationFrame(() => {
247
+ const headerCell = this.shadowRoot?.querySelector('.header-cell');
248
+ const dataCell = this.shadowRoot?.querySelector('.cell');
249
+ if (!headerCell)
250
+ return;
251
+ const headerStyle = getComputedStyle(headerCell);
252
+ const headerFont = `${headerStyle.fontWeight} ${headerStyle.fontSize} ${headerStyle.fontFamily}`;
253
+ const headerPadding = parseFloat(headerStyle.paddingLeft) + parseFloat(headerStyle.paddingRight);
254
+ const dataStyle = dataCell ? getComputedStyle(dataCell) : headerStyle;
255
+ const dataFont = `${dataStyle.fontWeight} ${dataStyle.fontSize} ${dataStyle.fontFamily}`;
256
+ const dataPadding = dataCell ? parseFloat(dataStyle.paddingLeft) + parseFloat(dataStyle.paddingRight) : headerPadding;
257
+ this.getDisplayedColumns().forEach(col => {
258
+ if (!col.width) {
259
+ let width;
260
+ // For columns with custom render functions, measure the actual DOM element
261
+ if (col.render) {
262
+ const cell = this.shadowRoot?.querySelector(`.cell[data-column-id="${col.id}"]`);
263
+ width = cell ? cell.scrollWidth : 150;
264
+ }
265
+ else {
266
+ const headerText = col.label ?? col.id;
267
+ const headerWidth = this._measureTextWidth(headerText, headerFont) + headerPadding;
268
+ let maxDataWidth = 0;
269
+ for (const row of this._data) {
270
+ const value = row[col.id];
271
+ if (value != null) {
272
+ const dataWidth = this._measureTextWidth(String(value), dataFont) + dataPadding;
273
+ if (dataWidth > maxDataWidth)
274
+ maxDataWidth = dataWidth;
275
+ }
276
+ }
277
+ width = Math.ceil(Math.max(headerWidth, maxDataWidth));
278
+ }
279
+ col.width = `${Math.max(width, 150)}px`;
280
+ }
281
+ });
282
+ this._widthsLocked = true;
283
+ this.classList.add('kr-table--widths-locked');
284
+ this.requestUpdate();
285
+ });
286
+ });
287
+ }
288
+ /**
289
+ * Updates search position to be centered with equal gaps from title and tools.
290
+ * On first call: resets to flex centering, measures position, then locks with fixed margin.
291
+ * Subsequent calls are ignored unless _searchPositionLocked is reset (e.g., on resize).
292
+ */
293
+ _updateSearchPosition() {
294
+ // Skip if already locked (prevents shifts on pagination changes)
295
+ if (this._searchPositionLocked)
296
+ return;
297
+ const search = this.shadowRoot?.querySelector('.search');
298
+ const searchField = search?.querySelector('.search-field');
299
+ if (!search || !searchField)
300
+ return;
301
+ // Reset to flex centering
302
+ search.style.justifyContent = 'center';
303
+ searchField.style.marginLeft = '';
304
+ requestAnimationFrame(() => {
305
+ const searchRect = search.getBoundingClientRect();
306
+ const fieldRect = searchField.getBoundingClientRect();
307
+ // Calculate how far from the left of search container the field currently is
308
+ const currentOffset = fieldRect.left - searchRect.left;
309
+ // Lock position: switch to flex-start and use fixed margin
310
+ search.style.justifyContent = 'flex-start';
311
+ searchField.style.marginLeft = `${currentOffset}px`;
312
+ // Mark as locked so pagination changes don't shift the search
313
+ this._searchPositionLocked = true;
314
+ });
315
+ }
316
+ // ----------------------------------------------------------------------------
317
+ // Columns
318
+ // ----------------------------------------------------------------------------
319
+ _toggleColumnPicker() {
320
+ this._columnPickerOpen = !this._columnPickerOpen;
321
+ }
322
+ _toggleColumn(columnId) {
323
+ if (this._displayedColumns.includes(columnId)) {
324
+ this._displayedColumns = this._displayedColumns.filter(id => id !== columnId);
325
+ }
326
+ else {
327
+ this._displayedColumns = [...this._displayedColumns, columnId];
328
+ }
329
+ }
330
+ // When a user toggles a column on via the column picker, it gets appended
331
+ // to _displayedColumns. By mapping over _displayedColumns (not def.columns),
332
+ // the new column appears at the right edge of the table instead of jumping
333
+ // back to its original position in the column definition.
334
+ // Actions columns are always moved to the end.
335
+ getDisplayedColumns() {
336
+ return this._displayedColumns
337
+ .map(id => this._def.columns.find(col => col.id === id))
338
+ .sort((a, b) => {
339
+ if (a.type === 'actions' && b.type !== 'actions')
340
+ return 1;
341
+ if (a.type !== 'actions' && b.type === 'actions')
342
+ return -1;
343
+ return 0;
344
+ });
345
+ }
346
+ // ----------------------------------------------------------------------------
347
+ // Scrolling
348
+ // ----------------------------------------------------------------------------
349
+ /**
350
+ * Scroll event handler that updates scroll flags in real-time as user scrolls.
351
+ * Updates shadow indicators to show if more content exists left/right.
352
+ */
353
+ _handleScroll(e) {
354
+ const container = e.target;
355
+ this._canScrollLeft = container.scrollLeft > 0;
356
+ this._canScrollRight = container.scrollLeft < container.scrollWidth - container.clientWidth - 1;
357
+ }
358
+ /**
359
+ * Updates scroll state flags for the table content container.
360
+ * - _canScrollLeft: true if scrolled right (can scroll back left)
361
+ * - _canScrollRight: true if more content exists to the right
362
+ * - _canScrollHorizontal: true if content is wider than container
363
+ * These flags control scroll shadow indicators and CSS classes.
364
+ */
365
+ _updateScrollFlags() {
366
+ const container = this.shadowRoot?.querySelector('.content');
367
+ if (container) {
368
+ this._canScrollLeft = container.scrollLeft > 0;
369
+ this._canScrollRight = container.scrollWidth > container.clientWidth && container.scrollLeft < container.scrollWidth - container.clientWidth - 1;
370
+ this._canScrollHorizontal = container.scrollWidth > container.clientWidth;
371
+ }
372
+ this.classList.toggle('kr-table--scroll-left-available', this._canScrollLeft);
373
+ this.classList.toggle('kr-table--scroll-right-available', this._canScrollRight);
374
+ this.classList.toggle('kr-table--scroll-horizontal-available', this._canScrollHorizontal);
375
+ this.classList.toggle('kr-table--sticky-left', this.getDisplayedColumns().some(c => c.sticky === 'left'));
376
+ this.classList.toggle('kr-table--sticky-right', this.getDisplayedColumns().some(c => c.sticky === 'right'));
377
+ }
378
+ // ----------------------------------------------------------------------------
379
+ // Column Resizing
380
+ // ----------------------------------------------------------------------------
381
+ _handleResizeStart(e, columnId) {
382
+ e.preventDefault();
383
+ const headerCell = this.shadowRoot?.querySelector(`.header-cell[data-column-id="${columnId}"]`);
384
+ this._resizing = {
385
+ columnId,
386
+ startX: e.clientX,
387
+ startWidth: headerCell?.offsetWidth || 200
388
+ };
389
+ document.addEventListener('mousemove', this._handleResizeMove);
390
+ document.addEventListener('mouseup', this._handleResizeEnd);
391
+ }
392
+ // ----------------------------------------------------------------------------
393
+ // Header
394
+ // ----------------------------------------------------------------------------
395
+ _handleAction(action) {
396
+ this.dispatchEvent(new CustomEvent('action', {
397
+ detail: { action: action.id },
398
+ bubbles: true,
399
+ composed: true
400
+ }));
401
+ }
402
+ // ----------------------------------------------------------------------------
403
+ // Rendering
404
+ // ----------------------------------------------------------------------------
405
+ _renderCellContent(column, row) {
406
+ const value = row[column.id];
407
+ if (column.render) {
408
+ const result = column.render(row);
409
+ // If render returns a string, treat it as HTML
410
+ return typeof result === 'string' ? unsafeHTML(result) : result;
411
+ }
412
+ if (value === null || value === undefined) {
413
+ return '';
414
+ }
415
+ switch (column.type) {
416
+ case 'number':
417
+ return typeof value === 'number' ? value.toLocaleString() : String(value);
418
+ case 'currency':
419
+ return typeof value === 'number'
420
+ ? value.toLocaleString('en-US', { style: 'currency', currency: 'USD' })
421
+ : String(value);
422
+ case 'date':
423
+ return value instanceof Date
424
+ ? value.toLocaleDateString()
425
+ : new Date(value).toLocaleDateString();
426
+ case 'boolean':
427
+ if (value === true)
428
+ return 'Yes';
429
+ if (value === false)
430
+ return 'No';
431
+ return '';
432
+ default:
433
+ return String(value);
434
+ }
435
+ }
436
+ /**
437
+ * Returns CSS classes for a header cell based on column config.
438
+ */
439
+ _getHeaderCellClasses(column, index) {
440
+ return {
441
+ 'header-cell': true,
442
+ 'header-cell--align-center': column.align === 'center',
443
+ 'header-cell--align-right': column.align === 'right',
444
+ 'header-cell--sticky-left': column.sticky === 'left',
445
+ 'header-cell--sticky-left-last': column.sticky === 'left' &&
446
+ !this.getDisplayedColumns().slice(index + 1).some(c => c.sticky === 'left'),
447
+ 'header-cell--sticky-right': column.sticky === 'right',
448
+ 'header-cell--sticky-right-first': column.sticky === 'right' &&
449
+ !this.getDisplayedColumns().slice(0, index).some(c => c.sticky === 'right')
450
+ };
451
+ }
452
+ /**
453
+ * Returns CSS classes for a table cell based on column config:
454
+ * - Alignment (center, right)
455
+ * - Sticky positioning (left, right)
456
+ * - Border classes for the last left-sticky or first right-sticky column
457
+ */
458
+ _getCellClasses(column, index) {
459
+ return {
460
+ 'cell': true,
461
+ 'cell--align-center': column.align === 'center',
462
+ 'cell--align-right': column.align === 'right',
463
+ 'cell--sticky-left': column.sticky === 'left',
464
+ 'cell--sticky-left-last': column.sticky === 'left' &&
465
+ !this.getDisplayedColumns().slice(index + 1).some(c => c.sticky === 'left'),
466
+ 'cell--sticky-right': column.sticky === 'right',
467
+ 'cell--sticky-right-first': column.sticky === 'right' &&
468
+ !this.getDisplayedColumns().slice(0, index).some(c => c.sticky === 'right')
469
+ };
470
+ }
471
+ /**
472
+ * Returns inline styles for a table cell:
473
+ * - Width (from column config or default 150px)
474
+ * - Min-width (if specified)
475
+ * - Left/right offset for sticky columns (calculated from widths of preceding sticky columns)
476
+ */
477
+ _getCellStyle(column, index) {
478
+ const styles = {};
479
+ if (column.sticky === 'left') {
480
+ let leftOffset = 0;
481
+ for (let i = 0; i < index; i++) {
482
+ const col = this.getDisplayedColumns()[i];
483
+ if (col.sticky === 'left') {
484
+ leftOffset += parseInt(col.width || '0', 10);
485
+ }
486
+ }
487
+ styles.left = `${leftOffset}px`;
488
+ }
489
+ if (column.sticky === 'right') {
490
+ let rightOffset = 0;
491
+ for (let i = index + 1; i < this.getDisplayedColumns().length; i++) {
492
+ const col = this.getDisplayedColumns()[i];
493
+ if (col.sticky === 'right') {
494
+ rightOffset += parseInt(col.width || '0', 10);
495
+ }
496
+ }
497
+ styles.right = `${rightOffset}px`;
498
+ }
499
+ return styles;
500
+ }
501
+ /**
502
+ * Renders the pagination controls:
503
+ * - Previous page arrow (disabled on first page)
504
+ * - Range text showing "1-50 of 150" format
505
+ * - Next page arrow (disabled on last page)
506
+ *
507
+ * Hidden when there's no data or all data fits on one page.
508
+ */
509
+ _renderPagination() {
510
+ const start = (this._page - 1) * this._pageSize + 1;
511
+ const end = Math.min(this._page * this._pageSize, this._totalItems);
512
+ return html `
513
+ <div class="pagination">
514
+ <span
515
+ class="pagination-icon ${this._page === 1 ? 'pagination-icon--disabled' : ''}"
516
+ @click=${this.goToPrevPage}
517
+ >
518
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
519
+ </span>
520
+ <span class="pagination-info">${start}-${end} of ${this._totalItems}</span>
521
+ <span
522
+ class="pagination-icon ${this._page === this._totalPages ? 'pagination-icon--disabled' : ''}"
523
+ @click=${this.goToNextPage}
524
+ >
525
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
526
+ </span>
527
+ </div>
528
+ `;
529
+ }
530
+ /**
531
+ * Renders the header toolbar containing:
532
+ * - Title (left)
533
+ * - Search bar with view selector dropdown (center)
534
+ * - Tools (right): page navigation, refresh button, column visibility picker, actions dropdown
535
+ *
536
+ * Hidden when there's no title, no actions, and data fits on one page.
537
+ */
538
+ _renderHeader() {
539
+ if (!this._def.title && !this._def.actions?.length && this._totalPages <= 1) {
540
+ return nothing;
541
+ }
542
+ return html `
543
+ <div class="header">
544
+ <div class="title">${this._def.title ?? ''}</div>
545
+ <div class="search">
546
+ <!-- TODO: Saved views dropdown
547
+ <div class="views">
548
+ <span>Default View</span>
549
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
550
+ </div>
551
+ -->
552
+ <div class="search-field">
553
+ <svg class="search-icon" viewBox="0 -960 960 960" fill="currentColor"><path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/></svg>
554
+ <input
555
+ type="text"
556
+ class="search-input"
557
+ placeholder="Search..."
558
+ .value=${this._searchQuery}
559
+ @input=${this._handleSearch}
560
+ />
561
+ </div>
562
+ </div>
563
+ <div class="tools">
564
+ ${this._renderPagination()}
565
+ <span class="refresh" title="Refresh" @click=${() => this.refresh()}>
566
+ <svg viewBox="0 -960 960 960" fill="currentColor"><path d="M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z"/></svg>
567
+ </span>
568
+ <div class="column-picker-wrapper">
569
+ <span class="header-icon" title="Columns" @click=${this._toggleColumnPicker}>
570
+ <svg viewBox="0 -960 960 960" fill="currentColor"><path d="M121-280v-400q0-33 23.5-56.5T201-760h559q33 0 56.5 23.5T840-680v400q0 33-23.5 56.5T760-200H201q-33 0-56.5-23.5T121-280Zm79 0h133v-400H200v400Zm213 0h133v-400H413v400Zm213 0h133v-400H626v400Z"/></svg>
571
+ </span>
572
+ <div class="column-picker ${this._columnPickerOpen ? 'open' : ''}">
573
+ ${[...this._def.columns].filter(col => col.type !== 'actions').sort((a, b) => (a.label ?? a.id).localeCompare(b.label ?? b.id)).map(col => html `
574
+ <div class="column-picker-item" @click=${() => this._toggleColumn(col.id)}>
575
+ <div class="column-picker-checkbox ${this._displayedColumns.includes(col.id) ? 'checked' : ''}">
576
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
577
+ </div>
578
+ <span class="column-picker-label">${col.label ?? col.id}</span>
579
+ </div>
580
+ `)}
581
+ </div>
582
+ </div>
583
+ ${this._def.actions?.length === 1 ? html `
584
+ <kr-button
585
+ class="actions"
586
+ @click=${() => this._handleAction(this._def.actions[0])}
587
+ >
588
+ ${this._def.actions[0].label}
589
+ </kr-button>
590
+ ` : this._def.actions?.length ? html `
591
+ <kr-button
592
+ class="actions"
593
+ .options=${this._def.actions.map(a => ({ id: a.id, label: a.label }))}
594
+ @option-select=${(e) => this._handleAction({ id: e.detail.id, label: e.detail.label })}
595
+ >
596
+ Actions
597
+ </kr-button>
598
+ ` : nothing}
599
+ </div>
600
+ </div>
601
+ `;
602
+ }
603
+ /** Renders status message (loading, error, empty) */
604
+ _renderStatus() {
605
+ if (this._dataState === 'loading' && this._data.length === 0) {
606
+ return html `<div class="status">Loading...</div>`;
607
+ }
608
+ if (this._dataState === 'error' && this._data.length === 0) {
609
+ return html `<div class="status status--error">Error loading data</div>`;
610
+ }
611
+ if (this._data.length === 0) {
612
+ return html `<div class="status">No data available</div>`;
613
+ }
614
+ return nothing;
615
+ }
616
+ _getGridTemplateColumns() {
617
+ const cols = this.getDisplayedColumns();
618
+ const lastNonStickyIndex = cols.map((c, i) => c.sticky ? -1 : i).filter(i => i >= 0).pop();
619
+ return cols.map((col, i) => {
620
+ if (i === lastNonStickyIndex && this._widthsLocked) {
621
+ return `minmax(${col.width || 'auto'}, 1fr)`;
622
+ }
623
+ return col.width || 'auto';
624
+ }).join(' ');
625
+ }
626
+ /** Renders the scrollable data grid with column headers and rows. */
627
+ _renderTable() {
628
+ return html `
629
+ <div class="wrapper">
630
+ <div class="overlay-left"></div>
631
+ <div class="overlay-right"></div>
632
+ ${this._renderStatus()}
633
+ <div class="content" @scroll=${this._handleScroll}>
634
+ <div class="table" style="grid-template-columns: ${this._getGridTemplateColumns()}">
635
+ <div class="header-row">
636
+ ${this.getDisplayedColumns().map((col, i) => html `
637
+ <div
638
+ class=${classMap(this._getHeaderCellClasses(col, i))}
639
+ style=${styleMap(this._getCellStyle(col, i))}
640
+ data-column-id=${col.id}
641
+ >${col.label ?? col.id}${col.resizable !== false ? html `<div
642
+ class="header-cell__resize"
643
+ @mousedown=${(e) => this._handleResizeStart(e, col.id)}
644
+ ></div>` : nothing}</div>
645
+ `)}
646
+ </div>
647
+ ${this._data.map(row => html `
648
+ <div class="row">
649
+ ${this.getDisplayedColumns().map((col, i) => html `
650
+ <div
651
+ class=${classMap(this._getCellClasses(col, i))}
652
+ style=${styleMap(this._getCellStyle(col, i))}
653
+ data-column-id=${col.id}
654
+ >
655
+ ${this._renderCellContent(col, row)}
656
+ </div>
657
+ `)}
658
+ </div>
659
+ `)}
660
+ </div>
661
+ </div>
662
+ </div>
663
+ `;
664
+ }
665
+ /**
666
+ * Renders a data table with:
667
+ * - Header bar with title, search input with view selector, and tools (pagination, refresh, column visibility, actions dropdown)
668
+ * - Scrollable grid with sticky header row and optional sticky left/right columns
669
+ * - Loading, error message, or empty state when no data
670
+ */
671
+ render() {
672
+ if (!this._def.columns.length) {
673
+ return html `<slot></slot>`;
674
+ }
675
+ return html `
676
+ ${this._renderHeader()}
677
+ ${this._renderTable()}
678
+ `;
679
+ }
680
+ };
681
+ KRTable.styles = [krBaseCSS, css `
682
+ /* -------------------------------------------------------------------------
683
+ * Host
684
+ * ----------------------------------------------------------------------- */
685
+ :host {
686
+ display: flex;
687
+ flex-direction: column;
688
+ width: 100%;
689
+ height: 100%;
690
+ overflow: hidden;
691
+ container-type: inline-size;
692
+ }
693
+
694
+ /* -------------------------------------------------------------------------
695
+ * Header
696
+ * ----------------------------------------------------------------------- */
697
+ .header {
698
+ flex-shrink: 0;
699
+ display: flex;
700
+ align-items: center;
701
+ gap: 16px;
702
+ margin: 0 24px;
703
+ padding: 0 4px;
704
+ height: 64px;
705
+ border-bottom: 1px solid #e5e7eb;
706
+ background: #fff;
707
+ }
708
+
709
+ :host(.kr-table--scroll-edge) .header {
710
+ border-bottom: none;
711
+ }
712
+
713
+ .title {
714
+ font-size: 20px;
715
+ font-weight: 400;
716
+ color: #000;
717
+ }
718
+
719
+ /* -------------------------------------------------------------------------
720
+ * Content
721
+ * ----------------------------------------------------------------------- */
722
+ .wrapper {
723
+ flex: 1;
724
+ position: relative;
725
+ overflow: hidden;
726
+ }
727
+
728
+ .content {
729
+ height: 100%;
730
+ overflow: auto;
731
+ padding-bottom: 24px;
732
+ }
733
+
734
+ /* -------------------------------------------------------------------------
735
+ * Search
736
+ * ----------------------------------------------------------------------- */
737
+ .search {
738
+ flex: 1;
739
+ display: flex;
740
+ align-items: center;
741
+ justify-content: center;
742
+ min-width: 0;
743
+ }
744
+
745
+ .search-field {
746
+ width: 100%;
747
+ max-width: 400px;
748
+ position: relative;
749
+ display: flex;
750
+ align-items: center;
751
+ border: 1px solid #00000038;
752
+ border-radius: 18px;
753
+ transition: border-color 0.2s, box-shadow 0.2s;
754
+ }
755
+
756
+ .search-field:focus-within {
757
+ border-color: #163052;
758
+ box-shadow: 0 0 0 3px rgba(22, 48, 82, 0.1);
759
+ }
760
+
761
+ /* TODO: Uncomment when views dropdown is added
762
+ .search-field:focus-within .views {
763
+ border-color: #163052;
764
+ }
765
+ */
766
+
767
+ .search-icon {
768
+ position: absolute;
769
+ left: 16px;
770
+ width: 20px;
771
+ height: 20px;
772
+ color: #656871;
773
+ pointer-events: none;
774
+ }
775
+
776
+ .search-input {
777
+ height: 36px;
778
+ padding: 0 16px 0 42px;
779
+ border: none;
780
+ border-radius: 16px;
781
+ font-size: 14px;
782
+ font-weight: 400;
783
+ font-family: inherit;
784
+ color: #163052;
785
+ background: transparent;
786
+ outline: none;
787
+ flex: 1;
788
+ min-width: 0;
789
+ width: 100%;
790
+ }
791
+
792
+ .search-input::placeholder {
793
+ color: #656871;
794
+ font-weight: 400;
795
+ }
796
+
797
+ .search-input:focus {
798
+ outline: none;
799
+ }
800
+
801
+ @container (max-width: 800px) {
802
+ .search-field {
803
+ max-width: 250px;
804
+ }
805
+ }
806
+
807
+ .views {
808
+ display: flex;
809
+ align-items: center;
810
+ gap: 4px;
811
+ height: 36px;
812
+ padding: 0 16px;
813
+ border: 1px solid #00000038;
814
+ border-right: none;
815
+ border-radius: 16px 0 0 16px;
816
+ font-size: 14px;
817
+ font-family: inherit;
818
+ color: #163052;
819
+ background: transparent;
820
+ cursor: pointer;
821
+ white-space: nowrap;
822
+ transition: border-color 0.2s;
823
+ }
824
+
825
+ .views:hover {
826
+ background: #e8f0f8;
827
+ }
828
+
829
+ .views svg {
830
+ width: 16px;
831
+ height: 16px;
832
+ color: #163052;
833
+ }
834
+
835
+ /* -------------------------------------------------------------------------
836
+ * Pagination
837
+ * ----------------------------------------------------------------------- */
838
+ .tools {
839
+ display: flex;
840
+ align-items: center;
841
+ gap: 8px;
842
+ }
843
+
844
+ .pagination {
845
+ display: flex;
846
+ align-items: center;
847
+ gap: 2px;
848
+ }
849
+
850
+ .pagination-info {
851
+ font-size: 13px;
852
+ color: var(--kr-primary);
853
+ white-space: nowrap;
854
+ }
855
+
856
+ .pagination-icon {
857
+ display: flex;
858
+ color: var(--kr-primary);
859
+ cursor: pointer;
860
+ }
861
+
862
+ .pagination-icon--disabled {
863
+ opacity: 0.3;
864
+ pointer-events: none;
865
+ }
866
+
867
+ .pagination-icon svg {
868
+ width: 24px;
869
+ height: 24px;
870
+ }
871
+
872
+ /* -------------------------------------------------------------------------
873
+ * Header Icons
874
+ * ----------------------------------------------------------------------- */
875
+ .refresh,
876
+ .header-icon {
877
+ display: flex;
878
+ align-items: center;
879
+ justify-content: center;
880
+ color: var(--kr-primary);
881
+ background: #EBF1FA;
882
+ cursor: pointer;
883
+ padding: 6px;
884
+ border-radius: 50%;
885
+ transition: background 0.15s;
886
+ }
887
+
888
+ .refresh:hover,
889
+ .header-icon:hover {
890
+ background: #e8f0f8;
891
+ }
892
+
893
+ .refresh svg,
894
+ .header-icon svg {
895
+ width: 24px;
896
+ height: 24px;
897
+ }
898
+
899
+ /* -------------------------------------------------------------------------
900
+ * Column Picker
901
+ * ----------------------------------------------------------------------- */
902
+ .column-picker-wrapper {
903
+ position: relative;
904
+ }
905
+
906
+ .column-picker {
907
+ position: absolute;
908
+ top: 100%;
909
+ right: 0;
910
+ margin-top: 4px;
911
+ min-width: 200px;
912
+ max-height: calc(100vh - 120px);
913
+ overflow-y: auto;
914
+ background: white;
915
+ border: 1px solid #9ba7b6;
916
+ border-radius: 8px;
917
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
918
+ padding: 8px 0;
919
+ z-index: 100;
920
+ display: none;
921
+ transform-origin: top;
922
+ }
923
+
924
+ .column-picker.open {
925
+ display: block;
926
+ animation: column-picker-fade-in 150ms ease-out;
927
+ }
928
+
929
+ @keyframes column-picker-fade-in {
930
+ from {
931
+ opacity: 0;
932
+ transform: translateY(-4px);
933
+ }
934
+ to {
935
+ opacity: 1;
936
+ transform: translateY(0);
937
+ }
938
+ }
939
+
940
+ .column-picker-item {
941
+ display: flex;
942
+ align-items: center;
943
+ gap: 10px;
944
+ padding: 8px 16px;
945
+ cursor: pointer;
946
+ white-space: nowrap;
947
+ }
948
+
949
+ .column-picker-item:hover {
950
+ background: #f3f4f6;
951
+ }
952
+
953
+ .column-picker-checkbox {
954
+ width: 16px;
955
+ height: 16px;
956
+ border: 1.5px solid #9ca3af;
957
+ border-radius: 3px;
958
+ display: flex;
959
+ align-items: center;
960
+ justify-content: center;
961
+ flex-shrink: 0;
962
+ transition: all 0.15s;
963
+ }
964
+
965
+ .column-picker-checkbox.checked {
966
+ background: var(--kr-primary);
967
+ border-color: var(--kr-primary);
968
+ }
969
+
970
+ .column-picker-checkbox svg {
971
+ width: 12px;
972
+ height: 12px;
973
+ color: white;
974
+ opacity: 0;
975
+ }
976
+
977
+ .column-picker-checkbox.checked svg {
978
+ opacity: 1;
979
+ }
980
+
981
+ .column-picker-label {
982
+ font-size: 14px;
983
+ color: #374151;
984
+ }
985
+
986
+ /* -------------------------------------------------------------------------
987
+ * Table Structure
988
+ * ----------------------------------------------------------------------- */
989
+ .table {
990
+ display: grid;
991
+ width: max-content;
992
+ min-width: 100%;
993
+ font-size: 14px;
994
+ }
995
+
996
+ .row {
997
+ display: contents;
998
+ }
999
+
1000
+ .row:hover .cell {
1001
+ background: #f9fafb;
1002
+ }
1003
+
1004
+ .header-row {
1005
+ display: contents;
1006
+ }
1007
+
1008
+ .cell {
1009
+ height: 48px;
1010
+ padding: 0 16px;
1011
+ display: flex;
1012
+ align-items: center;
1013
+ white-space: nowrap;
1014
+ overflow: hidden;
1015
+ text-overflow: ellipsis;
1016
+ box-sizing: border-box;
1017
+ }
1018
+
1019
+ .header-cell {
1020
+ position: sticky;
1021
+ top: 0;
1022
+ z-index: 2;
1023
+ height: 48px;
1024
+ line-height: 48px;
1025
+ padding: 0 16px;
1026
+ white-space: nowrap;
1027
+ box-sizing: border-box;
1028
+ background: #f9fafb;
1029
+ border-bottom: 2px solid #e5e7eb;
1030
+ font-weight: 600;
1031
+ color: #374151;
1032
+ overflow: hidden;
1033
+ text-overflow: ellipsis;
1034
+ }
1035
+
1036
+ .header-cell__resize {
1037
+ position: absolute;
1038
+ right: -7px;
1039
+ top: 0;
1040
+ bottom: 0;
1041
+ width: 14px;
1042
+ cursor: col-resize;
1043
+ display: flex;
1044
+ align-items: center;
1045
+ justify-content: center;
1046
+ z-index: 10;
1047
+ }
1048
+
1049
+ .header-cell__resize::after {
1050
+ content: '';
1051
+ width: 2px;
1052
+ height: 20px;
1053
+ background: #c6c6cd;
1054
+ }
1055
+
1056
+ .header-cell:last-child .header-cell__resize::after {
1057
+ display: none;
1058
+ }
1059
+
1060
+ .cell {
1061
+ background: #fff;
1062
+ border-bottom: 1px solid #e5e7eb;
1063
+ color: #1f2937;
1064
+ }
1065
+
1066
+ .cell--align-center {
1067
+ text-align: center;
1068
+ }
1069
+
1070
+ .cell--align-right {
1071
+ text-align: right;
1072
+ }
1073
+
1074
+ .cell--sticky-left,
1075
+ .cell--sticky-right {
1076
+ position: sticky;
1077
+ z-index: 1;
1078
+ }
1079
+
1080
+ .header-cell--sticky-left,
1081
+ .header-cell--sticky-right {
1082
+ position: sticky;
1083
+ z-index: 3;
1084
+ }
1085
+
1086
+ .header-cell--align-center {
1087
+ text-align: center;
1088
+ }
1089
+
1090
+ .header-cell--align-right {
1091
+ text-align: right;
1092
+ }
1093
+
1094
+ .header-cell--sticky-left-last,
1095
+ .cell--sticky-left-last {
1096
+ border-right: 1px solid #d1d5db;
1097
+ }
1098
+
1099
+ .header-cell--sticky-right-first,
1100
+ .cell--sticky-right-first {
1101
+ border-left: 1px solid #d1d5db;
1102
+ }
1103
+
1104
+ /* -------------------------------------------------------------------------
1105
+ * Scroll Mode: Edge
1106
+ * Padding scrolls with content, table can reach edges when scrolling
1107
+ * ----------------------------------------------------------------------- */
1108
+ :host(.kr-table--scroll-edge) .table {
1109
+ padding-left: 24px;
1110
+ }
1111
+
1112
+ /* Only add right padding when no horizontal scroll is needed */
1113
+ :host(.kr-table--scroll-edge):not(.kr-table--scroll-horizontal-available) .table {
1114
+ padding-right: 24px;
1115
+ }
1116
+
1117
+ :host(.kr-table--scroll-edge) .header-row .header-cell {
1118
+ border-top: 1px solid #e5e7eb;
1119
+ }
1120
+
1121
+ /* -------------------------------------------------------------------------
1122
+ * Scroll Mode: Overlay
1123
+ * Fixed padding with overlay elements that hide content at edges
1124
+ * ----------------------------------------------------------------------- */
1125
+ :host(.kr-table--scroll-overlay) .content {
1126
+ padding-left: 24px;
1127
+ padding-right: 24px;
1128
+ }
1129
+
1130
+ .overlay-left,
1131
+ .overlay-right {
1132
+ display: none;
1133
+ position: absolute;
1134
+ top: 0;
1135
+ bottom: 0;
1136
+ width: 24px;
1137
+ z-index: 5;
1138
+ pointer-events: none;
1139
+ transition: box-shadow 0.15s ease;
1140
+ }
1141
+
1142
+ :host(.kr-table--scroll-overlay) .overlay-left,
1143
+ :host(.kr-table--scroll-overlay) .overlay-right {
1144
+ display: block;
1145
+ }
1146
+
1147
+ .overlay-left {
1148
+ left: 0;
1149
+ background: #fff;
1150
+ }
1151
+
1152
+ .overlay-right {
1153
+ right: 0;
1154
+ background: #fff;
1155
+ }
1156
+
1157
+ :host(.kr-table--scroll-overlay.kr-table--scroll-left-available:not(.kr-table--sticky-left)) .overlay-left {
1158
+ border-right: 1px solid #d1d5db54;
1159
+ }
1160
+
1161
+ :host(.kr-table--scroll-overlay.kr-table--scroll-right-available:not(.kr-table--sticky-right)) .overlay-right {
1162
+ border-left: 1px solid #d1d5db54;
1163
+ }
1164
+
1165
+ /* -------------------------------------------------------------------------
1166
+ * Status (Loading, Error, Empty)
1167
+ * ----------------------------------------------------------------------- */
1168
+ .status {
1169
+ position: absolute;
1170
+ top: 0;
1171
+ left: 0;
1172
+ right: 0;
1173
+ bottom: 0;
1174
+ display: flex;
1175
+ align-items: center;
1176
+ justify-content: center;
1177
+ font-size: 14px;
1178
+ font-weight: 400;
1179
+ color: #5f6368;
1180
+ pointer-events: none;
1181
+ }
1182
+
1183
+ .status--error {
1184
+ color: #dc2626;
1185
+ }
1186
+ `];
1187
+ __decorate([
1188
+ state()
1189
+ ], KRTable.prototype, "_data", void 0);
1190
+ __decorate([
1191
+ state()
1192
+ ], KRTable.prototype, "_dataState", void 0);
1193
+ __decorate([
1194
+ state()
1195
+ ], KRTable.prototype, "_page", void 0);
1196
+ __decorate([
1197
+ state()
1198
+ ], KRTable.prototype, "_pageSize", void 0);
1199
+ __decorate([
1200
+ state()
1201
+ ], KRTable.prototype, "_totalItems", void 0);
1202
+ __decorate([
1203
+ state()
1204
+ ], KRTable.prototype, "_totalPages", void 0);
1205
+ __decorate([
1206
+ state()
1207
+ ], KRTable.prototype, "_searchQuery", void 0);
1208
+ __decorate([
1209
+ state()
1210
+ ], KRTable.prototype, "_canScrollLeft", void 0);
1211
+ __decorate([
1212
+ state()
1213
+ ], KRTable.prototype, "_canScrollRight", void 0);
1214
+ __decorate([
1215
+ state()
1216
+ ], KRTable.prototype, "_canScrollHorizontal", void 0);
1217
+ __decorate([
1218
+ state()
1219
+ ], KRTable.prototype, "_columnPickerOpen", void 0);
1220
+ __decorate([
1221
+ state()
1222
+ ], KRTable.prototype, "_displayedColumns", void 0);
1223
+ __decorate([
1224
+ property({ type: Object })
1225
+ ], KRTable.prototype, "def", void 0);
1226
+ KRTable = __decorate([
1227
+ customElement('kr-table')
1228
+ ], KRTable);
1229
+ export { KRTable };
1230
+ //# sourceMappingURL=table.js.map