@kodaris/krubble-components 1.0.74 → 1.0.75

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,2419 @@
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, render } 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 { krBaseCSS } from '../style/base.js';
12
+ import '../button/button.js';
13
+ import { KRSnackbar } from '../snackbar/snackbar.js';
14
+ import '../form/select-field/select-field.js';
15
+ import '../form/select-field/select-option.js';
16
+ import { KRQuery, getOperatorsForType, termify } from '../table/query.js';
17
+ import '../tabs/tabs.js';
18
+ import '../tabs/tab.js';
19
+ /** Internal grid model built from user-provided def. */
20
+ class KRGridModel {
21
+ constructor() {
22
+ this.title = '';
23
+ this.description = '';
24
+ this.actions = [];
25
+ this.columns = [];
26
+ this.displayedColumns = [];
27
+ this.data = null;
28
+ this.dataSource = null;
29
+ this.refreshInterval = 0;
30
+ this.pageSize = 0;
31
+ this.rowClickable = false;
32
+ this.rowHref = null;
33
+ }
34
+ }
35
+ let KRGrid = class KRGrid extends LitElement {
36
+ constructor() {
37
+ super(...arguments);
38
+ /**
39
+ * Internal flag to switch between scroll edge modes:
40
+ * - 'overlay': Fixed padding with overlay elements that hide content at edges (scrollbar at viewport edge)
41
+ * - 'edge': Padding scrolls with content, allowing table to reach edges when scrolling
42
+ */
43
+ this._scrollStyle = 'edge';
44
+ this._data = [];
45
+ this._dataState = 'idle';
46
+ this._page = 1;
47
+ this._pageSize = 50;
48
+ this._totalItems = 0;
49
+ this._totalPages = 0;
50
+ this._searchQuery = '';
51
+ this._canScrollLeft = false;
52
+ this._canScrollRight = false;
53
+ this._canScrollHorizontal = false;
54
+ this._columnPickerOpen = false;
55
+ this._filterPanelOpened = null;
56
+ this._filterPanelTab = 'filter';
57
+ this._buckets = new Map();
58
+ this._filterPanelPos = { top: 0, left: 0 };
59
+ this._sorts = [];
60
+ this._resizing = null;
61
+ this._resizeObserver = null;
62
+ this._searchPositionLocked = false;
63
+ this._columnWidthsLocked = false;
64
+ this._model = new KRGridModel();
65
+ this.def = { columns: [] };
66
+ /**
67
+ * Table layout variant.
68
+ * - 'default': Full-page table that fills parent height with centered search.
69
+ * - 'card': Embedded table that sizes itself to content, left-aligns search, and
70
+ * reserves space for pageSize rows to prevent layout shift.
71
+ */
72
+ this.variant = 'default';
73
+ this._handleClickOutside = (e) => {
74
+ const path = e.composedPath();
75
+ if (this._columnPickerOpen) {
76
+ const picker = this.shadowRoot?.querySelector('.column-picker-wrapper');
77
+ if (picker && !path.includes(picker)) {
78
+ this._columnPickerOpen = false;
79
+ }
80
+ }
81
+ if (this._filterPanelOpened) {
82
+ if (!path.some((el) => el.classList?.contains('filter-panel'))) {
83
+ this._handleFilterApply();
84
+ }
85
+ }
86
+ };
87
+ this._handleResizeMove = (e) => {
88
+ if (!this._resizing)
89
+ return;
90
+ const col = this._model.columns.find(c => c.id === this._resizing.columnId);
91
+ if (col) {
92
+ const newWidth = this._resizing.startWidth + (e.clientX - this._resizing.startX);
93
+ col.width = `${Math.min(900, Math.max(50, newWidth))}px`;
94
+ this.requestUpdate();
95
+ }
96
+ };
97
+ this._handleResizeEnd = () => {
98
+ this._resizing = null;
99
+ document.removeEventListener('mousemove', this._handleResizeMove);
100
+ document.removeEventListener('mouseup', this._handleResizeEnd);
101
+ };
102
+ }
103
+ connectedCallback() {
104
+ super.connectedCallback();
105
+ this.classList.toggle('kr-table--scroll-overlay', this._scrollStyle === 'overlay');
106
+ this.classList.toggle('kr-table--scroll-edge', this._scrollStyle === 'edge');
107
+ this._fetch();
108
+ this._initRefresh();
109
+ document.addEventListener('click', this._handleClickOutside);
110
+ this._resizeObserver = new ResizeObserver(() => {
111
+ // Unlock and recalculate on resize since layout changes
112
+ this._searchPositionLocked = false;
113
+ this._updateSearchPosition();
114
+ });
115
+ this._resizeObserver.observe(this);
116
+ }
117
+ disconnectedCallback() {
118
+ super.disconnectedCallback();
119
+ clearInterval(this._refreshTimer);
120
+ document.removeEventListener('click', this._handleClickOutside);
121
+ this._resizeObserver?.disconnect();
122
+ }
123
+ willUpdate(changedProperties) {
124
+ if (changedProperties.has('def')) {
125
+ // Build internal model from user-provided def
126
+ this._columnWidthsLocked = false;
127
+ this._model = new KRGridModel();
128
+ if (this.def.title) {
129
+ this._model.title = this.def.title;
130
+ }
131
+ if (this.def.description) {
132
+ this._model.description = this.def.description;
133
+ }
134
+ if (this.def.actions) {
135
+ this._model.actions = this.def.actions;
136
+ }
137
+ if (this.def.data) {
138
+ this._model.data = this.def.data;
139
+ }
140
+ if (this.def.dataSource) {
141
+ this._model.dataSource = this.def.dataSource;
142
+ }
143
+ if (typeof this.def.refreshInterval === 'number') {
144
+ this._model.refreshInterval = this.def.refreshInterval;
145
+ }
146
+ if (typeof this.def.pageSize === 'number') {
147
+ this._model.pageSize = this.def.pageSize;
148
+ this._pageSize = this.def.pageSize;
149
+ }
150
+ if (this.def.rowClickable) {
151
+ this._model.rowClickable = this.def.rowClickable;
152
+ }
153
+ if (this.def.rowHref) {
154
+ this._model.rowHref = this.def.rowHref;
155
+ }
156
+ this._sorts = [];
157
+ this._model.columns = this.def.columns.map(col => {
158
+ const column = {
159
+ ...col,
160
+ filter: null
161
+ };
162
+ if (!column.type) {
163
+ column.type = 'string';
164
+ }
165
+ if (col.sort) {
166
+ this._sorts.push({
167
+ sortBy: col.id,
168
+ sortDirection: col.sort
169
+ });
170
+ }
171
+ if (column.type === 'actions') {
172
+ column.label = col.label ?? '';
173
+ column.sticky = 'right';
174
+ column.resizable = false;
175
+ return column;
176
+ }
177
+ if (col.filterable || col.facetable) {
178
+ column.filter = new KRQuery();
179
+ column.filter.field = col.id;
180
+ column.filter.type = column.type;
181
+ if (col.filter) {
182
+ column.filter.setOperator(col.filter.operator);
183
+ column.filter.setValue(col.filter.value);
184
+ }
185
+ else if (col.facetable && !col.filterable) {
186
+ column.filter.operator = 'in';
187
+ column.filter.value = [];
188
+ }
189
+ else if (column.filter.type === 'string') {
190
+ column.filter.operator = 'contains';
191
+ }
192
+ }
193
+ return column;
194
+ });
195
+ if (this.def.displayedColumns) {
196
+ this._model.displayedColumns = this.def.displayedColumns;
197
+ }
198
+ else {
199
+ this._model.displayedColumns = this._model.columns.map(c => c.id);
200
+ }
201
+ this._fetch();
202
+ this._initRefresh();
203
+ }
204
+ }
205
+ updated(changedProperties) {
206
+ this.classList.toggle('kr-table--card', this.variant === 'card');
207
+ this._updateScrollFlags();
208
+ this._syncSlottedContent();
209
+ this._lockColumnWidths();
210
+ }
211
+ /** Measures header cell widths and locks them to prevent column shift on subsequent data changes. */
212
+ _lockColumnWidths() {
213
+ if (this._columnWidthsLocked || this._data.length === 0)
214
+ return;
215
+ const headerCells = this.shadowRoot?.querySelectorAll('.header-row > .header-cell');
216
+ if (!headerCells)
217
+ return;
218
+ const cols = this.getDisplayedColumns();
219
+ headerCells.forEach((cell, i) => {
220
+ const cellWidth = cell.offsetWidth;
221
+ if (i < cols.length && !cols[i].width && cols[i].type !== 'actions' && cellWidth > 0) {
222
+ cols[i].width = `${cellWidth}px`;
223
+ this._columnWidthsLocked = true;
224
+ }
225
+ });
226
+ }
227
+ /** Syncs light DOM content for cells with custom render functions */
228
+ _syncSlottedContent() {
229
+ const columns = this.getDisplayedColumns().filter(col => col.render);
230
+ if (!columns.length)
231
+ return;
232
+ // Clear old slotted content
233
+ this.querySelectorAll('[slot^="cell-"]').forEach(el => el.remove());
234
+ // Create new slotted content
235
+ this._data.forEach((row, rowIndex) => {
236
+ columns.forEach(col => {
237
+ const result = col.render(row);
238
+ if (!result)
239
+ return;
240
+ const el = document.createElement('span');
241
+ el.slot = `cell-${rowIndex}-${col.id}`;
242
+ if (col.type === 'actions') {
243
+ el.style.display = 'flex';
244
+ el.style.gap = '8px';
245
+ }
246
+ if (typeof result === 'string') {
247
+ el.innerHTML = result;
248
+ }
249
+ else {
250
+ render(result, el);
251
+ }
252
+ this.appendChild(el);
253
+ });
254
+ });
255
+ }
256
+ // ----------------------------------------------------------------------------
257
+ // Public Interface
258
+ // ----------------------------------------------------------------------------
259
+ refresh() {
260
+ this._fetch();
261
+ }
262
+ goToPrevPage() {
263
+ if (this._page > 1) {
264
+ this._page--;
265
+ this._fetch();
266
+ }
267
+ }
268
+ goToNextPage() {
269
+ if (this._page < this._totalPages) {
270
+ this._page++;
271
+ this._fetch();
272
+ }
273
+ }
274
+ goToPage(page) {
275
+ if (page >= 1 && page <= this._totalPages) {
276
+ this._page = page;
277
+ this._fetch();
278
+ }
279
+ }
280
+ // ----------------------------------------------------------------------------
281
+ // Data Fetching
282
+ // ----------------------------------------------------------------------------
283
+ _toSolrData() {
284
+ const request = {
285
+ page: this._page - 1,
286
+ size: this._pageSize,
287
+ sorts: this._sorts,
288
+ filterFields: [],
289
+ queryFields: [],
290
+ facetFields: []
291
+ };
292
+ for (const col of this._model.columns) {
293
+ if (!col.filter || col.filter.isEmpty() || !col.filter.isValid()) {
294
+ continue;
295
+ }
296
+ const filterData = col.filter.toSolrData();
297
+ if (col.facetable && (col.filter.operator === 'in' || col.filter.operator === 'n_in')) {
298
+ filterData.tagged = true;
299
+ }
300
+ request.filterFields.push(filterData);
301
+ }
302
+ for (const col of this._model.columns) {
303
+ if (!col.facetable) {
304
+ continue;
305
+ }
306
+ request.facetFields.push({
307
+ name: col.id,
308
+ type: 'FIELD',
309
+ limit: 100,
310
+ sort: 'count',
311
+ minimumCount: 1
312
+ });
313
+ }
314
+ if (this._searchQuery?.trim().length) {
315
+ request.queryFields.push({
316
+ name: '_text_',
317
+ operation: 'IS',
318
+ value: termify(this._searchQuery, false)
319
+ });
320
+ }
321
+ return request;
322
+ }
323
+ _toDbParams() {
324
+ const request = {
325
+ page: this._page - 1,
326
+ size: this._pageSize,
327
+ sorts: this._sorts,
328
+ filterFields: [],
329
+ queryFields: [],
330
+ facetFields: []
331
+ };
332
+ for (const col of this._model.columns) {
333
+ if (!col.filter || col.filter.isEmpty() || !col.filter.isValid()) {
334
+ continue;
335
+ }
336
+ request.filterFields.push(col.filter.toDbParams());
337
+ }
338
+ if (this._searchQuery?.trim().length) {
339
+ this._model.columns.filter(col => col.searchable).forEach(col => {
340
+ request.queryFields.push({
341
+ name: col.id,
342
+ operation: 'CONTAINS',
343
+ value: this._searchQuery,
344
+ and: false
345
+ });
346
+ });
347
+ }
348
+ return request;
349
+ }
350
+ /**
351
+ * Fetches data from the API and updates the table.
352
+ * Shows a loading spinner while fetching, then displays rows on success
353
+ * or an error snackbar on failure.
354
+ * Request/response format depends on dataSource.mode (solr, opensearch, db).
355
+ */
356
+ _fetch() {
357
+ if (this._model.data) {
358
+ this._data = this._model.data;
359
+ this._totalItems = this._model.data.length;
360
+ this._totalPages = Math.ceil(this._model.data.length / this._pageSize);
361
+ this._dataState = 'success';
362
+ return;
363
+ }
364
+ if (!this._model.dataSource)
365
+ return;
366
+ this._dataState = 'loading';
367
+ let request;
368
+ if (this._model.dataSource.mode === 'db') {
369
+ request = this._toDbParams();
370
+ }
371
+ else {
372
+ request = this._toSolrData();
373
+ }
374
+ this._model.dataSource.fetch(request)
375
+ .then(response => {
376
+ // Parse response based on mode
377
+ switch (this._model.dataSource?.mode) {
378
+ case 'opensearch': {
379
+ throw Error('Opensearch not supported yet');
380
+ break;
381
+ }
382
+ case 'db': {
383
+ const res = response;
384
+ this._data = res.data.content;
385
+ this._totalItems = res.data.totalElements;
386
+ this._totalPages = res.data.totalPages;
387
+ this._pageSize = res.data.size;
388
+ break;
389
+ }
390
+ default: { // solr
391
+ const res = response;
392
+ this._data = res.data.content;
393
+ this._totalItems = res.data.totalElements;
394
+ this._totalPages = res.data.totalPages;
395
+ this._pageSize = res.data.size;
396
+ this._parseFacetResults(res);
397
+ }
398
+ }
399
+ this._dataState = 'success';
400
+ this._updateSearchPosition();
401
+ })
402
+ .catch(err => {
403
+ this._dataState = 'error';
404
+ KRSnackbar.show({
405
+ message: err instanceof Error ? err.message : 'Failed to load data',
406
+ type: 'error'
407
+ });
408
+ });
409
+ }
410
+ _parseFacetResults(response) {
411
+ if (!response.data.facetFields) {
412
+ return;
413
+ }
414
+ for (const col of this._model.columns) {
415
+ if (!col.facetable) {
416
+ continue;
417
+ }
418
+ const rawBuckets = response.data.facetFields[col.id];
419
+ if (!rawBuckets) {
420
+ this._buckets.set(col.id, []);
421
+ continue;
422
+ }
423
+ const buckets = [];
424
+ for (const raw of rawBuckets) {
425
+ // Solr returns boolean facet values as strings — coerce to actual booleans
426
+ // so they match the filter values stored by toggle().
427
+ let val = raw.name;
428
+ if (col.type === 'boolean' && typeof raw.name === 'string') {
429
+ if (raw.name === 'true') {
430
+ val = true;
431
+ }
432
+ else if (raw.name === 'false') {
433
+ val = false;
434
+ }
435
+ }
436
+ if (raw.name === null && raw.count > 0) {
437
+ buckets.unshift({
438
+ val: null,
439
+ count: raw.count
440
+ });
441
+ }
442
+ if (raw.name !== null) {
443
+ buckets.push({
444
+ val: val,
445
+ count: raw.count
446
+ });
447
+ }
448
+ }
449
+ // Bucket sync: ensure selected values appear even with 0 results
450
+ if (col.filter && (col.filter.operator === 'in' || col.filter.operator === 'n_in') && Array.isArray(col.filter.value)) {
451
+ for (const selectedVal of col.filter.value) {
452
+ if (!buckets.some(b => b.val === selectedVal)) {
453
+ buckets.push({
454
+ val: selectedVal,
455
+ count: 0
456
+ });
457
+ }
458
+ }
459
+ }
460
+ this._buckets.set(col.id, buckets);
461
+ }
462
+ // Trigger re-render since Map mutation doesn't trigger Lit updates
463
+ this._buckets = new Map(this._buckets);
464
+ }
465
+ /**
466
+ * Sets up auto-refresh so the table automatically fetches fresh data
467
+ * at a regular interval (useful for dashboards, monitoring views).
468
+ * Configured via def.refreshInterval in milliseconds.
469
+ */
470
+ _initRefresh() {
471
+ clearInterval(this._refreshTimer);
472
+ if (this._model.refreshInterval && this._model.refreshInterval > 0) {
473
+ this._refreshTimer = window.setInterval(() => {
474
+ this._fetch();
475
+ }, this._model.refreshInterval);
476
+ }
477
+ }
478
+ _handleSearch(e) {
479
+ const input = e.target;
480
+ this._searchQuery = input.value;
481
+ this._page = 1;
482
+ this._fetch();
483
+ }
484
+ _getGridTemplateColumns() {
485
+ const cols = this.getDisplayedColumns();
486
+ return cols.map((col) => {
487
+ // If column has explicit width, use it
488
+ if (col.width) {
489
+ return col.width;
490
+ }
491
+ // Actions columns: fit content without minimum
492
+ if (col.type === 'actions') {
493
+ return 'max-content';
494
+ }
495
+ // No width specified - use content-based sizing with minimum
496
+ return 'minmax(80px, auto)';
497
+ }).join(' ');
498
+ }
499
+ /**
500
+ * Updates search position to be centered with equal gaps from title and tools.
501
+ * On first call: resets to flex centering, measures position, then locks with fixed margin.
502
+ * Subsequent calls are ignored unless _searchPositionLocked is reset (e.g., on resize).
503
+ */
504
+ _updateSearchPosition() {
505
+ // Skip if already locked (prevents shifts on pagination changes)
506
+ if (this._searchPositionLocked)
507
+ return;
508
+ // In card mode, search is left-aligned via CSS — no position locking needed
509
+ if (this.variant === 'card')
510
+ return;
511
+ const search = this.shadowRoot?.querySelector('.search');
512
+ const searchField = search?.querySelector('.search-field');
513
+ if (!search || !searchField)
514
+ return;
515
+ // Reset to flex centering
516
+ search.style.justifyContent = 'center';
517
+ searchField.style.marginLeft = '';
518
+ requestAnimationFrame(() => {
519
+ const searchRect = search.getBoundingClientRect();
520
+ const fieldRect = searchField.getBoundingClientRect();
521
+ // Calculate how far from the left of search container the field currently is
522
+ const currentOffset = fieldRect.left - searchRect.left;
523
+ // Lock position: switch to flex-start and use fixed margin
524
+ search.style.justifyContent = 'flex-start';
525
+ searchField.style.marginLeft = `${currentOffset}px`;
526
+ // Mark as locked so pagination changes don't shift the search
527
+ this._searchPositionLocked = true;
528
+ });
529
+ }
530
+ // ----------------------------------------------------------------------------
531
+ // Columns
532
+ // ----------------------------------------------------------------------------
533
+ _toggleColumnPicker() {
534
+ this._columnPickerOpen = !this._columnPickerOpen;
535
+ }
536
+ _toggleColumn(columnId) {
537
+ if (this._model.displayedColumns.includes(columnId)) {
538
+ this._model.displayedColumns = this._model.displayedColumns.filter(id => id !== columnId);
539
+ }
540
+ else {
541
+ this._model.displayedColumns = [...this._model.displayedColumns, columnId];
542
+ }
543
+ this.requestUpdate();
544
+ }
545
+ // Clear any existing text selection on mousedown so we only detect
546
+ // selections made during this click gesture, not stale selections from elsewhere
547
+ _handleRowMouseDown() {
548
+ if (!this._model.rowClickable && !this._model.rowHref) {
549
+ return;
550
+ }
551
+ window.getSelection()?.removeAllRanges();
552
+ }
553
+ _handleRowClick(e, row, rowIndex) {
554
+ if (!this._model.rowClickable && !this._model.rowHref) {
555
+ return;
556
+ }
557
+ const selection = window.getSelection();
558
+ if (selection && selection.toString().length > 0) {
559
+ e.preventDefault();
560
+ return;
561
+ }
562
+ this.dispatchEvent(new CustomEvent('row-click', {
563
+ detail: { row, rowIndex },
564
+ bubbles: true,
565
+ composed: true
566
+ }));
567
+ }
568
+ // When a user toggles a column on via the column picker, it gets appended
569
+ // to _displayedColumns. By mapping over _displayedColumns (not def.columns),
570
+ // the new column appears at the right edge of the table instead of jumping
571
+ // back to its original position in the column definition.
572
+ // Actions columns are always moved to the end.
573
+ getDisplayedColumns() {
574
+ return this._model.displayedColumns
575
+ .map(id => this._model.columns.find(col => col.id === id))
576
+ .sort((a, b) => {
577
+ if (a.type === 'actions' && b.type !== 'actions')
578
+ return 1;
579
+ if (a.type !== 'actions' && b.type === 'actions')
580
+ return -1;
581
+ return 0;
582
+ });
583
+ }
584
+ // ----------------------------------------------------------------------------
585
+ // Scrolling
586
+ // ----------------------------------------------------------------------------
587
+ /**
588
+ * Scroll event handler that updates scroll flags in real-time as user scrolls.
589
+ * Updates shadow indicators to show if more content exists left/right.
590
+ */
591
+ _handleScroll(e) {
592
+ const container = e.target;
593
+ this._canScrollLeft = container.scrollLeft > 0;
594
+ this._canScrollRight = container.scrollLeft < container.scrollWidth - container.clientWidth - 1;
595
+ }
596
+ /**
597
+ * Updates scroll state flags for the table content container.
598
+ * - _canScrollLeft: true if scrolled right (can scroll back left)
599
+ * - _canScrollRight: true if more content exists to the right
600
+ * - _canScrollHorizontal: true if content is wider than container
601
+ * These flags control scroll shadow indicators and CSS classes.
602
+ */
603
+ _updateScrollFlags() {
604
+ const container = this.shadowRoot?.querySelector('.content');
605
+ if (container) {
606
+ this._canScrollLeft = container.scrollLeft > 0;
607
+ this._canScrollRight = container.scrollWidth > container.clientWidth && container.scrollLeft < container.scrollWidth - container.clientWidth - 1;
608
+ this._canScrollHorizontal = container.scrollWidth > container.clientWidth;
609
+ }
610
+ this.classList.toggle('kr-table--scroll-left-available', this._canScrollLeft);
611
+ this.classList.toggle('kr-table--scroll-right-available', this._canScrollRight);
612
+ this.classList.toggle('kr-table--scroll-horizontal-available', this._canScrollHorizontal);
613
+ this.classList.toggle('kr-table--sticky-left', this.getDisplayedColumns().some(c => c.sticky === 'left'));
614
+ this.classList.toggle('kr-table--sticky-right', this.getDisplayedColumns().some(c => c.sticky === 'right'));
615
+ }
616
+ // ----------------------------------------------------------------------------
617
+ // Column Resizing
618
+ // ----------------------------------------------------------------------------
619
+ _handleResizeStart(e, columnId) {
620
+ e.preventDefault();
621
+ const headerCell = this.shadowRoot?.querySelector(`.header-cell[data-column-id="${columnId}"]`);
622
+ this._resizing = {
623
+ columnId,
624
+ startX: e.clientX,
625
+ startWidth: headerCell?.offsetWidth || 200
626
+ };
627
+ document.addEventListener('mousemove', this._handleResizeMove);
628
+ document.addEventListener('mouseup', this._handleResizeEnd);
629
+ }
630
+ // ----------------------------------------------------------------------------
631
+ // Sorting
632
+ // ----------------------------------------------------------------------------
633
+ _handleSortClick(e, column) {
634
+ if (e.shiftKey) {
635
+ // Multi-sort: add or cycle existing
636
+ const existingIndex = this._sorts.findIndex(s => s.sortBy === column.id);
637
+ if (existingIndex === -1) {
638
+ this._sorts.push({ sortBy: column.id, sortDirection: 'asc' });
639
+ }
640
+ else {
641
+ const existing = this._sorts[existingIndex];
642
+ if (existing.sortDirection === 'asc') {
643
+ existing.sortDirection = 'desc';
644
+ }
645
+ else {
646
+ // on third click, remove sorting for the column
647
+ this._sorts.splice(existingIndex, 1);
648
+ }
649
+ }
650
+ this.requestUpdate();
651
+ }
652
+ else {
653
+ // Single sort: replace all
654
+ let existing = null;
655
+ if (this._sorts.length === 1) {
656
+ existing = this._sorts.find(s => s.sortBy === column.id);
657
+ }
658
+ if (!existing) {
659
+ this._sorts = [{ sortBy: column.id, sortDirection: 'asc' }];
660
+ }
661
+ else if (existing.sortDirection === 'asc') {
662
+ this._sorts = [{ sortBy: column.id, sortDirection: 'desc' }];
663
+ }
664
+ else {
665
+ this._sorts = [];
666
+ }
667
+ }
668
+ this._page = 1;
669
+ this._fetch();
670
+ }
671
+ _renderSortIndicator(column) {
672
+ if (!column.sortable) {
673
+ return nothing;
674
+ }
675
+ const sortIndex = this._sorts.findIndex(s => s.sortBy === column.id);
676
+ if (sortIndex === -1) {
677
+ // Ghost arrow: visible only on hover via CSS
678
+ return html `
679
+ <span class="header-cell__sort" @click=${(e) => this._handleSortClick(e, column)}>
680
+ <svg class="header-cell__sort-arrow header-cell__sort-arrow--ghost" viewBox="0 0 24 24" fill="currentColor">
681
+ <path d="M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z"/>
682
+ </svg>
683
+ </span>
684
+ `;
685
+ }
686
+ let arrowStyle = {};
687
+ if (this._sorts[sortIndex].sortDirection === 'desc') {
688
+ arrowStyle = { transform: 'rotate(180deg)' };
689
+ }
690
+ return html `
691
+ <span class="header-cell__sort" @click=${(e) => this._handleSortClick(e, column)}>
692
+ <svg class="header-cell__sort-arrow" viewBox="0 0 24 24" fill="currentColor" style=${styleMap(arrowStyle)}>
693
+ <path d="M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z"/>
694
+ </svg>
695
+ ${this._sorts.length > 1 ? html `
696
+ <span class="header-cell__sort-priority">${sortIndex + 1}</span>
697
+ ` : nothing}
698
+ </span>
699
+ `;
700
+ }
701
+ // ----------------------------------------------------------------------------
702
+ // Header
703
+ // ----------------------------------------------------------------------------
704
+ _handleAction(action) {
705
+ if (action.href) {
706
+ return;
707
+ }
708
+ this.dispatchEvent(new CustomEvent('action', {
709
+ detail: { action: action.id },
710
+ bubbles: true,
711
+ composed: true
712
+ }));
713
+ }
714
+ // ----------------------------------------------------------------------------
715
+ // Filter Handlers
716
+ // ----------------------------------------------------------------------------
717
+ _handleKqlChange(e, column) {
718
+ const kql = e.target.value.trim();
719
+ if (!kql) {
720
+ column.filter.clear();
721
+ this.requestUpdate();
722
+ }
723
+ else {
724
+ column.filter.setKql(kql);
725
+ this.requestUpdate();
726
+ if (!column.filter.isValid()) {
727
+ return;
728
+ }
729
+ }
730
+ this._page = 1;
731
+ this._fetch();
732
+ }
733
+ _handleFilterPanelToggle(e, column) {
734
+ e.stopPropagation();
735
+ if (this._filterPanelOpened === column.id) {
736
+ this._filterPanelOpened = null;
737
+ }
738
+ else {
739
+ const rect = e.currentTarget.getBoundingClientRect();
740
+ let left = rect.left;
741
+ if (left + 328 > window.innerWidth) {
742
+ left = window.innerWidth - 328;
743
+ }
744
+ this._filterPanelPos = {
745
+ top: rect.bottom + 4,
746
+ left
747
+ };
748
+ this._filterPanelOpened = column.id;
749
+ if (column.facetable) {
750
+ this._filterPanelTab = 'counts';
751
+ }
752
+ else {
753
+ this._filterPanelTab = 'filter';
754
+ }
755
+ }
756
+ }
757
+ _handleKqlClear(column) {
758
+ column.filter.clear();
759
+ this._page = 1;
760
+ this._fetch();
761
+ }
762
+ _handleFilterClear() {
763
+ const column = this._model.columns.find(c => c.id === this._filterPanelOpened);
764
+ if (column) {
765
+ column.filter.clear();
766
+ if (column.facetable && !column.filterable) {
767
+ column.filter.operator = 'in';
768
+ column.filter.value = [];
769
+ }
770
+ }
771
+ this._filterPanelOpened = null;
772
+ this._page = 1;
773
+ this._fetch();
774
+ }
775
+ _handleFilterTextKeydown(e, column) {
776
+ if (e.key === 'Enter') {
777
+ e.preventDefault();
778
+ this._handleFilterApply();
779
+ }
780
+ }
781
+ _handleOperatorChange(e, column) {
782
+ column.filter.setOperator(e.target.value);
783
+ this.requestUpdate();
784
+ }
785
+ _handleFilterStringChange(e, column) {
786
+ column.filter.setValue(e.target.value);
787
+ this.requestUpdate();
788
+ }
789
+ _handleFilterNumberChange(e, column) {
790
+ column.filter.setValue(Number(e.target.value));
791
+ this.requestUpdate();
792
+ }
793
+ _handleFilterDateChange(e, column) {
794
+ column.filter.setValue(new Date(e.target.value), 'day');
795
+ this.requestUpdate();
796
+ }
797
+ _handleFilterBooleanChange(e, column) {
798
+ column.filter.setValue(e.target.value === 'true');
799
+ this.requestUpdate();
800
+ }
801
+ _handleFilterDateStartChange(e, column) {
802
+ column.filter.setStart(new Date(e.target.value), 'day');
803
+ this.requestUpdate();
804
+ }
805
+ _handleFilterDateEndChange(e, column) {
806
+ column.filter.setEnd(new Date(e.target.value), 'day');
807
+ this.requestUpdate();
808
+ }
809
+ _handleFilterNumberStartChange(e, column) {
810
+ column.filter.setStart(Number(e.target.value));
811
+ this.requestUpdate();
812
+ }
813
+ _handleFilterNumberEndChange(e, column) {
814
+ column.filter.setEnd(Number(e.target.value));
815
+ this.requestUpdate();
816
+ }
817
+ _handleFilterListChange(e, column) {
818
+ const items = e.target.value.split(',').map((v) => v.trim()).filter((v) => v !== '');
819
+ if (column.type === 'number') {
820
+ column.filter.setValue(items.map((v) => Number(v)));
821
+ }
822
+ else {
823
+ column.filter.setValue(items);
824
+ }
825
+ this.requestUpdate();
826
+ }
827
+ _handleFilterApply() {
828
+ this._filterPanelOpened = null;
829
+ this._page = 1;
830
+ this._fetch();
831
+ }
832
+ _handleFilterPanelTabChange(e) {
833
+ this._filterPanelTab = e.detail.activeTabId;
834
+ }
835
+ _handleBucketToggle(e, column, bucket) {
836
+ column.filter.toggle(bucket.val);
837
+ this._page = 1;
838
+ this._fetch();
839
+ }
840
+ // ----------------------------------------------------------------------------
841
+ // Rendering
842
+ // ----------------------------------------------------------------------------
843
+ _renderCellContent(column, row, rowIndex) {
844
+ const value = row[column.id];
845
+ if (column.render) {
846
+ // Use slot to project content from light DOM so external styles apply
847
+ return html `<slot name="cell-${rowIndex}-${column.id}"></slot>`;
848
+ }
849
+ if (value === null || value === undefined) {
850
+ return '';
851
+ }
852
+ switch (column.type) {
853
+ case 'number':
854
+ if (column.format === 'currency' && typeof value === 'number') {
855
+ return value.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
856
+ }
857
+ return String(value);
858
+ case 'date': {
859
+ let date;
860
+ if (value instanceof Date) {
861
+ date = value;
862
+ }
863
+ else if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(value)) {
864
+ // MySQL datetime format (UTC): "2026-01-28 01:33:44:517"
865
+ // Replace last colon before ms with dot, append Z for UTC
866
+ const isoString = value.replace(/(\d{2}:\d{2}:\d{2}):(\d+)$/, '$1.$2').replace(' ', 'T') + 'Z';
867
+ date = new Date(isoString);
868
+ }
869
+ else {
870
+ date = new Date(value);
871
+ }
872
+ // Show date and time for datetime values in UTC
873
+ return date.toLocaleString(undefined, {
874
+ year: 'numeric', month: 'short', day: 'numeric',
875
+ hour: 'numeric', minute: '2-digit',
876
+ timeZone: 'UTC'
877
+ });
878
+ }
879
+ case 'boolean':
880
+ if (value === true)
881
+ return 'Yes';
882
+ if (value === false)
883
+ return 'No';
884
+ return '';
885
+ default:
886
+ return String(value);
887
+ }
888
+ }
889
+ /**
890
+ * Returns CSS classes for a header cell based on column config.
891
+ */
892
+ _getHeaderCellClasses(column, index) {
893
+ return {
894
+ 'header-cell': true,
895
+ 'header-cell--sortable': !!column.sortable,
896
+ 'header-cell--align-center': column.align === 'center',
897
+ 'header-cell--align-right': column.align === 'right',
898
+ 'header-cell--sticky-left': column.sticky === 'left',
899
+ 'header-cell--sticky-left-last': column.sticky === 'left' &&
900
+ !this.getDisplayedColumns().slice(index + 1).some(c => c.sticky === 'left'),
901
+ 'header-cell--sticky-right': column.sticky === 'right',
902
+ 'header-cell--sticky-right-first': column.sticky === 'right' &&
903
+ !this.getDisplayedColumns().slice(0, index).some(c => c.sticky === 'right')
904
+ };
905
+ }
906
+ /**
907
+ * Returns CSS classes for a table cell based on column config:
908
+ * - Alignment (center, right)
909
+ * - Sticky positioning (left, right)
910
+ * - Border classes for the last left-sticky or first right-sticky column
911
+ */
912
+ _getCellClasses(column, index) {
913
+ return {
914
+ 'cell': true,
915
+ 'cell--actions': column.type === 'actions',
916
+ 'cell--align-center': column.align === 'center',
917
+ 'cell--align-right': column.align === 'right',
918
+ 'cell--sticky-left': column.sticky === 'left',
919
+ 'cell--sticky-left-last': column.sticky === 'left' &&
920
+ !this.getDisplayedColumns().slice(index + 1).some(c => c.sticky === 'left'),
921
+ 'cell--sticky-right': column.sticky === 'right',
922
+ 'cell--sticky-right-first': column.sticky === 'right' &&
923
+ !this.getDisplayedColumns().slice(0, index).some(c => c.sticky === 'right')
924
+ };
925
+ }
926
+ /**
927
+ * Returns inline styles for a table cell:
928
+ * - Width (from column config or default 150px)
929
+ * - Min-width (if specified)
930
+ * - Left/right offset for sticky columns (calculated from widths of preceding sticky columns)
931
+ */
932
+ _getCellStyle(column, index) {
933
+ const styles = {};
934
+ if (column.sticky === 'left') {
935
+ let leftOffset = 0;
936
+ for (let i = 0; i < index; i++) {
937
+ const col = this.getDisplayedColumns()[i];
938
+ if (col.sticky === 'left') {
939
+ leftOffset += parseInt(col.width || '0', 10);
940
+ }
941
+ }
942
+ styles.left = `${leftOffset}px`;
943
+ }
944
+ if (column.sticky === 'right') {
945
+ let rightOffset = 0;
946
+ for (let i = index + 1; i < this.getDisplayedColumns().length; i++) {
947
+ const col = this.getDisplayedColumns()[i];
948
+ if (col.sticky === 'right') {
949
+ rightOffset += parseInt(col.width || '0', 10);
950
+ }
951
+ }
952
+ styles.right = `${rightOffset}px`;
953
+ }
954
+ return styles;
955
+ }
956
+ /**
957
+ * Renders the pagination controls:
958
+ * - Previous page arrow (disabled on first page)
959
+ * - Range text showing "1-50 of 150" format
960
+ * - Next page arrow (disabled on last page)
961
+ *
962
+ * Hidden when there's no data or all data fits on one page.
963
+ */
964
+ _renderPagination() {
965
+ const start = (this._page - 1) * this._pageSize + 1;
966
+ const end = Math.min(this._page * this._pageSize, this._totalItems);
967
+ return html `
968
+ <div class="pagination">
969
+ <span
970
+ class="pagination-icon ${this._page === 1 ? 'pagination-icon--disabled' : ''}"
971
+ @click=${this.goToPrevPage}
972
+ >
973
+ <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>
974
+ </span>
975
+ <span class="pagination-info">${start}-${end} of ${this._totalItems}</span>
976
+ <span
977
+ class="pagination-icon ${this._page === this._totalPages ? 'pagination-icon--disabled' : ''}"
978
+ @click=${this.goToNextPage}
979
+ >
980
+ <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>
981
+ </span>
982
+ </div>
983
+ `;
984
+ }
985
+ /** Renders the card title block (title + description) above the toolbar in card mode. */
986
+ _renderCardHeader() {
987
+ if (this.variant !== 'card')
988
+ return nothing;
989
+ if (!this._model.title && !this._model.description)
990
+ return nothing;
991
+ return html `
992
+ <div class="card-header">
993
+ ${this._model.title ? html `<h2 class="card-header__title">${this._model.title}</h2>` : nothing}
994
+ ${this._model.description ? html `<p class="card-header__description">${this._model.description}</p>` : nothing}
995
+ </div>
996
+ `;
997
+ }
998
+ /**
999
+ * Renders the header toolbar containing:
1000
+ * - Title (left, default variant only)
1001
+ * - Search bar with view selector dropdown (center, or left-aligned in card variant)
1002
+ * - Tools (right): page navigation, refresh button, column visibility picker, actions dropdown
1003
+ *
1004
+ * Hidden when there's no title, no actions, and data fits on one page.
1005
+ */
1006
+ _renderHeader() {
1007
+ if (!this._model.title && !this._model.actions?.length && this._totalPages <= 1) {
1008
+ return nothing;
1009
+ }
1010
+ return html `
1011
+ <div class="header">
1012
+ ${this._model.title && this.variant !== 'card' ? html `<div class="title">${this._model.title}</div>` : nothing}
1013
+ ${this._model.dataSource?.mode === 'db' && !this._model.columns.some(col => col.searchable) ? html `<div class="search"></div>` : html `
1014
+ <div class="search">
1015
+ <!-- TODO: Saved views dropdown
1016
+ <div class="views">
1017
+ <span>Default View</span>
1018
+ <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>
1019
+ </div>
1020
+ -->
1021
+ <div class="search-field">
1022
+ <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>
1023
+ <input
1024
+ type="text"
1025
+ class="search-input"
1026
+ placeholder="Search..."
1027
+ .value=${this._searchQuery}
1028
+ @input=${this._handleSearch}
1029
+ />
1030
+ </div>
1031
+ </div>
1032
+ `}
1033
+ <div class="tools">
1034
+ ${this._renderPagination()}
1035
+ <span class="refresh" title="Refresh" @click=${() => this.refresh()}>
1036
+ <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>
1037
+ </span>
1038
+ <div class="column-picker-wrapper">
1039
+ <span class="header-icon" title="Columns" @click=${this._toggleColumnPicker}>
1040
+ <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>
1041
+ </span>
1042
+ <div class="column-picker ${this._columnPickerOpen ? 'open' : ''}">
1043
+ ${[...this._model.columns].filter(col => col.type !== 'actions').sort((a, b) => (a.label ?? a.id).localeCompare(b.label ?? b.id)).map(col => html `
1044
+ <div class="column-picker-item" @click=${() => this._toggleColumn(col.id)}>
1045
+ <div class="column-picker-checkbox ${this._model.displayedColumns.includes(col.id) ? 'checked' : ''}">
1046
+ <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>
1047
+ </div>
1048
+ <span class="column-picker-label">${col.label ?? col.id}</span>
1049
+ </div>
1050
+ `)}
1051
+ </div>
1052
+ </div>
1053
+ ${this._model.actions?.length === 1 ? html `
1054
+ <kr-button
1055
+ class="actions"
1056
+ .href=${this._model.actions[0].href}
1057
+ .target=${this._model.actions[0].target}
1058
+ @click=${() => this._handleAction(this._model.actions[0])}
1059
+ >
1060
+ ${this._model.actions[0].label}
1061
+ </kr-button>
1062
+ ` : this._model.actions?.length ? html `
1063
+ <kr-button
1064
+ class="actions"
1065
+ .options=${this._model.actions.map(a => ({ id: a.id, label: a.label }))}
1066
+ @option-select=${(e) => this._handleAction({ id: e.detail.id, label: e.detail.label })}
1067
+ >
1068
+ Actions
1069
+ </kr-button>
1070
+ ` : nothing}
1071
+ </div>
1072
+ </div>
1073
+ `;
1074
+ }
1075
+ /** Renders status message (loading, error, empty) */
1076
+ _renderStatus() {
1077
+ if (this._dataState === 'loading' && this._data.length === 0) {
1078
+ return html `<div class="status">Loading...</div>`;
1079
+ }
1080
+ if (this._dataState === 'error' && this._data.length === 0) {
1081
+ return html `<div class="status status--error">Error loading data</div>`;
1082
+ }
1083
+ if (this._data.length === 0) {
1084
+ return html `<div class="status">No data available</div>`;
1085
+ }
1086
+ return nothing;
1087
+ }
1088
+ _renderFilterPanel() {
1089
+ if (!this._filterPanelOpened) {
1090
+ return nothing;
1091
+ }
1092
+ const column = this._model.columns.find(c => c.id === this._filterPanelOpened);
1093
+ // Build filter content (operator + value input)
1094
+ let valueInput = html ``;
1095
+ if (column.filter.operator === 'empty' || column.filter.operator === 'n_empty') {
1096
+ valueInput = html `
1097
+ <input
1098
+ type="text"
1099
+ class="filter-panel__input"
1100
+ disabled
1101
+ .value=${column.filter.text}
1102
+ />
1103
+ `;
1104
+ }
1105
+ else if (column.filter.operator === 'between' && column.type === 'date') {
1106
+ valueInput = html `
1107
+ <input
1108
+ type="date"
1109
+ class="filter-panel__input"
1110
+ .valueAsDate=${column.filter.value?.start ?? null}
1111
+ @change=${(e) => this._handleFilterDateStartChange(e, column)}
1112
+ />
1113
+ <input
1114
+ type="date"
1115
+ class="filter-panel__input"
1116
+ .valueAsDate=${column.filter.value?.end ?? null}
1117
+ @change=${(e) => this._handleFilterDateEndChange(e, column)}
1118
+ />
1119
+ `;
1120
+ }
1121
+ else if (column.filter.operator === 'between' && column.type === 'number') {
1122
+ valueInput = html `
1123
+ <input
1124
+ type="number"
1125
+ class="filter-panel__input"
1126
+ placeholder="Start"
1127
+ .value=${column.filter.value?.start ?? ''}
1128
+ @input=${(e) => this._handleFilterNumberStartChange(e, column)}
1129
+ @keydown=${(e) => this._handleFilterTextKeydown(e, column)}
1130
+ />
1131
+ <input
1132
+ type="number"
1133
+ class="filter-panel__input"
1134
+ placeholder="End"
1135
+ .value=${column.filter.value?.end ?? ''}
1136
+ @input=${(e) => this._handleFilterNumberEndChange(e, column)}
1137
+ @keydown=${(e) => this._handleFilterTextKeydown(e, column)}
1138
+ />
1139
+ `;
1140
+ }
1141
+ else if (column.filter.operator === 'in' || column.filter.operator === 'n_in') {
1142
+ valueInput = html `
1143
+ <textarea
1144
+ class="filter-panel__textarea"
1145
+ rows="3"
1146
+ placeholder="Values (comma-separated)"
1147
+ .value=${column.filter.text}
1148
+ @input=${(e) => this._handleFilterListChange(e, column)}
1149
+ @keydown=${(e) => this._handleFilterTextKeydown(e, column)}
1150
+ ></textarea>
1151
+ `;
1152
+ }
1153
+ else if (column.type === 'boolean') {
1154
+ valueInput = html `
1155
+ <kr-select-field
1156
+ placeholder="Value"
1157
+ .value=${String(column.filter.value ?? '')}
1158
+ @change=${(e) => this._handleFilterBooleanChange(e, column)}
1159
+ >
1160
+ <kr-select-option value="true">Yes</kr-select-option>
1161
+ <kr-select-option value="false">No</kr-select-option>
1162
+ </kr-select-field>
1163
+ `;
1164
+ }
1165
+ else if (column.type === 'date') {
1166
+ valueInput = html `
1167
+ <input
1168
+ type="date"
1169
+ class="filter-panel__input"
1170
+ .valueAsDate=${column.filter.value}
1171
+ @change=${(e) => this._handleFilterDateChange(e, column)}
1172
+ />
1173
+ `;
1174
+ }
1175
+ else if (column.type === 'number') {
1176
+ valueInput = html `
1177
+ <input
1178
+ type="number"
1179
+ class="filter-panel__input"
1180
+ placeholder="Value"
1181
+ min="0"
1182
+ .value=${column.filter.text}
1183
+ @input=${(e) => this._handleFilterNumberChange(e, column)}
1184
+ @keydown=${(e) => this._handleFilterTextKeydown(e, column)}
1185
+ />
1186
+ `;
1187
+ }
1188
+ else {
1189
+ valueInput = html `
1190
+ <input
1191
+ type="text"
1192
+ class="filter-panel__input"
1193
+ placeholder="Value"
1194
+ .value=${column.filter.text}
1195
+ @input=${(e) => this._handleFilterStringChange(e, column)}
1196
+ @keydown=${(e) => this._handleFilterTextKeydown(e, column)}
1197
+ />
1198
+ `;
1199
+ }
1200
+ const filterContent = html `
1201
+ <div class="filter-panel__content">
1202
+ <kr-select-field
1203
+ .value=${column.filter.operator}
1204
+ @change=${(e) => this._handleOperatorChange(e, column)}
1205
+ >
1206
+ ${getOperatorsForType(column.type).map(op => html `
1207
+ <kr-select-option value=${op.key}>${op.label}</kr-select-option>
1208
+ `)}
1209
+ </kr-select-field>
1210
+ ${valueInput}
1211
+ </div>
1212
+ `;
1213
+ // Build bucket list content
1214
+ const buckets = this._buckets.get(column.id) || [];
1215
+ let bucketContent;
1216
+ if (!buckets.length) {
1217
+ bucketContent = html `<div class="bucket-empty">No data</div>`;
1218
+ }
1219
+ else {
1220
+ bucketContent = html `
1221
+ <div class="buckets">
1222
+ ${buckets.map(bucket => {
1223
+ let bucketLabel = '(Empty)';
1224
+ if (bucket.val !== null && bucket.val !== undefined) {
1225
+ if (column.type === 'boolean') {
1226
+ if (bucket.val === true || bucket.val === 'true') {
1227
+ bucketLabel = 'Yes';
1228
+ }
1229
+ else {
1230
+ bucketLabel = 'No';
1231
+ }
1232
+ }
1233
+ else {
1234
+ bucketLabel = String(bucket.val);
1235
+ }
1236
+ }
1237
+ // When using n_in, the user sees all buckets checked by default and unchecks
1238
+ // the ones they want to hide. Under the hood, has() returns true for values
1239
+ // in the exclusion list, so we invert the check state for n_in.
1240
+ let checked = column.filter.has(bucket.val);
1241
+ if (column.filter.operator === 'n_in') {
1242
+ checked = !checked;
1243
+ }
1244
+ let checkIcon = nothing;
1245
+ if (checked) {
1246
+ checkIcon = html `
1247
+ <svg viewBox="0 0 24 24" fill="currentColor">
1248
+ <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
1249
+ </svg>
1250
+ `;
1251
+ }
1252
+ return html `
1253
+ <div
1254
+ class="bucket"
1255
+ @click=${(e) => this._handleBucketToggle(e, column, bucket)}
1256
+ >
1257
+ <div class=${classMap({
1258
+ 'bucket__checkbox': true,
1259
+ 'bucket__checkbox--checked': checked
1260
+ })}>
1261
+ ${checkIcon}
1262
+ </div>
1263
+ <span class="bucket__label">${bucketLabel}</span>
1264
+ <span class="bucket__count">${bucket.count}</span>
1265
+ </div>
1266
+ `;
1267
+ })}
1268
+ </div>
1269
+ `;
1270
+ }
1271
+ // Build panel body — tabs if both filterable+facetable, otherwise just the relevant content
1272
+ let panelBody;
1273
+ if (column.facetable && column.filterable) {
1274
+ panelBody = html `
1275
+ <kr-tab-group
1276
+ size="small"
1277
+ active-tab-id=${this._filterPanelTab}
1278
+ @tab-change=${(e) => this._handleFilterPanelTabChange(e)}
1279
+ >
1280
+ <kr-tab id="filter" label="Filter">
1281
+ ${filterContent}
1282
+ </kr-tab>
1283
+ <kr-tab id="counts" label="Counts">
1284
+ ${bucketContent}
1285
+ </kr-tab>
1286
+ </kr-tab-group>
1287
+ `;
1288
+ }
1289
+ else if (column.facetable) {
1290
+ panelBody = bucketContent;
1291
+ }
1292
+ else {
1293
+ panelBody = filterContent;
1294
+ }
1295
+ return html `
1296
+ <div
1297
+ class="filter-panel"
1298
+ style=${styleMap({
1299
+ top: this._filterPanelPos.top + 'px',
1300
+ left: this._filterPanelPos.left + 'px'
1301
+ })}
1302
+ >
1303
+ ${panelBody}
1304
+ <div class="filter-panel__actions">
1305
+ <kr-button variant="outline" color="secondary" size="small" @click=${this._handleFilterClear}>
1306
+ Clear
1307
+ </kr-button>
1308
+ <kr-button size="small" @click=${this._handleFilterApply}>
1309
+ Apply
1310
+ </kr-button>
1311
+ </div>
1312
+ </div>
1313
+ `;
1314
+ }
1315
+ /**
1316
+ * Renders filter row below column headers.
1317
+ * Only displays for columns with filterable: true.
1318
+ */
1319
+ _renderFilterRow() {
1320
+ const columns = this.getDisplayedColumns();
1321
+ if (!columns.some(col => col.filterable || col.facetable)) {
1322
+ return nothing;
1323
+ }
1324
+ return html `
1325
+ <div class="filter-row">
1326
+ ${columns.map((col, i) => {
1327
+ if (!col.filterable && !col.facetable) {
1328
+ return html `<div
1329
+ class=${classMap({
1330
+ 'filter-cell': true,
1331
+ 'filter-cell--sticky-left': col.sticky === 'left',
1332
+ 'filter-cell--sticky-right': col.sticky === 'right',
1333
+ 'filter-cell--sticky-right-first': col.sticky === 'right' &&
1334
+ !columns.slice(0, i).some((c) => c.sticky === 'right')
1335
+ })}
1336
+ style=${styleMap(this._getCellStyle(col, i))}
1337
+ ></div>`;
1338
+ }
1339
+ return html `
1340
+ <div
1341
+ class=${classMap({
1342
+ 'filter-cell': true,
1343
+ 'filter-cell--sticky-left': col.sticky === 'left',
1344
+ 'filter-cell--sticky-right': col.sticky === 'right',
1345
+ 'filter-cell--sticky-right-first': col.sticky === 'right' &&
1346
+ !columns.slice(0, i).some((c) => c.sticky === 'right')
1347
+ })}
1348
+ style=${styleMap(this._getCellStyle(col, i))}
1349
+ >
1350
+ <div class="filter-cell__wrapper">
1351
+ <input
1352
+ type="text"
1353
+ class=${classMap({
1354
+ 'filter-cell__input': true,
1355
+ 'filter-cell__input--invalid': !col.filter.isValid()
1356
+ })}
1357
+ .value=${col.filter.kql}
1358
+ @change=${(e) => this._handleKqlChange(e, col)}
1359
+ />
1360
+ ${col.filter?.kql?.length > 0 ? html `
1361
+ <button
1362
+ class="filter-cell__clear"
1363
+ @click=${() => this._handleKqlClear(col)}
1364
+ >
1365
+ <svg viewBox="0 0 24 24" fill="currentColor">
1366
+ <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
1367
+ </svg>
1368
+ </button>
1369
+ ` : nothing}
1370
+ <button
1371
+ class=${classMap({
1372
+ 'filter-cell__advanced': true,
1373
+ 'filter-cell__advanced--opened': this._filterPanelOpened === col.id
1374
+ })}
1375
+ @click=${(e) => this._handleFilterPanelToggle(e, col)}
1376
+ >
1377
+ <svg viewBox="0 0 24 24" fill="currentColor">
1378
+ <path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/>
1379
+ </svg>
1380
+ </button>
1381
+ </div>
1382
+ </div>
1383
+ `;
1384
+ })}
1385
+ </div>
1386
+ `;
1387
+ }
1388
+ /** Renders the scrollable data grid with column headers and rows. */
1389
+ _renderTable() {
1390
+ return html `
1391
+ <div class="wrapper">
1392
+ <div class="overlay-left"></div>
1393
+ <div class="overlay-right"></div>
1394
+ ${this._renderStatus()}
1395
+ <div class="content" @scroll=${this._handleScroll}>
1396
+ <div class="table" style="grid-template-columns: ${this._getGridTemplateColumns()}">
1397
+ <div class="header-row">
1398
+ ${this.getDisplayedColumns().map((col, i) => html `
1399
+ <div
1400
+ class=${classMap(this._getHeaderCellClasses(col, i))}
1401
+ style=${styleMap(this._getCellStyle(col, i))}
1402
+ data-column-id=${col.id}
1403
+ >
1404
+ <span class="header-cell__label">${col.label ?? col.id}</span>
1405
+ ${this._renderSortIndicator(col)}
1406
+ ${col.resizable !== false ? html `<div
1407
+ class="header-cell__resize"
1408
+ @mousedown=${(e) => this._handleResizeStart(e, col.id)}
1409
+ ></div>` : nothing}
1410
+ </div>
1411
+ `)}
1412
+ </div>
1413
+ ${this._renderFilterRow()}
1414
+ ${this._data.map((row, rowIndex) => {
1415
+ const cells = this.getDisplayedColumns().map((col, i) => html `
1416
+ <div
1417
+ class=${classMap(this._getCellClasses(col, i))}
1418
+ style=${styleMap(this._getCellStyle(col, i))}
1419
+ data-column-id=${col.id}
1420
+ >
1421
+ ${this._renderCellContent(col, row, rowIndex)}
1422
+ </div>
1423
+ `);
1424
+ if (this._model.rowHref) {
1425
+ return html `
1426
+ <a
1427
+ href=${this._model.rowHref(row)}
1428
+ draggable="false"
1429
+ class=${classMap({ 'row': true, 'row--clickable': true, 'row--link': true })}
1430
+ @mousedown=${() => this._handleRowMouseDown()}
1431
+ @click=${(e) => this._handleRowClick(e, row, rowIndex)}
1432
+ >${cells}</a>
1433
+ `;
1434
+ }
1435
+ return html `
1436
+ <div
1437
+ class=${classMap({ 'row': true, 'row--clickable': !!this._model.rowClickable })}
1438
+ @mousedown=${() => this._handleRowMouseDown()}
1439
+ @click=${(e) => this._handleRowClick(e, row, rowIndex)}
1440
+ >${cells}</div>
1441
+ `;
1442
+ })}
1443
+ </div>
1444
+ </div>
1445
+ </div>
1446
+ `;
1447
+ }
1448
+ /**
1449
+ * Renders a data table with:
1450
+ * - Header bar with title, search input with view selector, and tools (pagination, refresh, column visibility, actions dropdown)
1451
+ * - Scrollable grid with sticky header row and optional sticky left/right columns
1452
+ * - Loading, error message, or empty state when no data
1453
+ */
1454
+ render() {
1455
+ if (!this._model.columns.length) {
1456
+ return html `<slot></slot>`;
1457
+ }
1458
+ return html `
1459
+ ${this._renderCardHeader()}
1460
+ ${this._renderHeader()}
1461
+ ${this._renderTable()}
1462
+ ${this._renderFilterPanel()}
1463
+ `;
1464
+ }
1465
+ };
1466
+ KRGrid.styles = [krBaseCSS, css `
1467
+ /* -------------------------------------------------------------------------
1468
+ * Host
1469
+ * ----------------------------------------------------------------------- */
1470
+ :host {
1471
+ display: flex;
1472
+ flex-direction: column;
1473
+ width: 100%;
1474
+ height: 100%;
1475
+ overflow: hidden;
1476
+ container-type: inline-size;
1477
+ }
1478
+
1479
+ /* -------------------------------------------------------------------------
1480
+ * Header
1481
+ * ----------------------------------------------------------------------- */
1482
+ .header {
1483
+ flex-shrink: 0;
1484
+ display: flex;
1485
+ align-items: center;
1486
+ gap: 16px;
1487
+ margin: 0 24px;
1488
+ height: 64px;
1489
+ border-bottom: 1px solid #e5e7eb;
1490
+ background: #fff;
1491
+ }
1492
+
1493
+ :host(.kr-table--scroll-edge) .header {
1494
+ border-bottom: none;
1495
+ }
1496
+
1497
+ .title {
1498
+ font-size: 18px;
1499
+ font-weight: 600;
1500
+ color: #000;
1501
+ }
1502
+
1503
+ /* -------------------------------------------------------------------------
1504
+ * Content
1505
+ * ----------------------------------------------------------------------- */
1506
+ .wrapper {
1507
+ flex: 1;
1508
+ position: relative;
1509
+ overflow: hidden;
1510
+ }
1511
+
1512
+ .content {
1513
+ height: 100%;
1514
+ overflow: auto;
1515
+ }
1516
+
1517
+ /* -------------------------------------------------------------------------
1518
+ * Search
1519
+ * ----------------------------------------------------------------------- */
1520
+ .search {
1521
+ flex: 1;
1522
+ display: flex;
1523
+ align-items: center;
1524
+ justify-content: center;
1525
+ min-width: 0;
1526
+ }
1527
+
1528
+ /* In card mode, align search left instead of center */
1529
+ :host(.kr-table--card) .search {
1530
+ justify-content: flex-start;
1531
+ }
1532
+
1533
+ .search-field {
1534
+ width: 100%;
1535
+ max-width: 400px;
1536
+ position: relative;
1537
+ display: flex;
1538
+ align-items: center;
1539
+ border: 1px solid #00000038;
1540
+ border-radius: 18px;
1541
+ transition: border-color 0.2s, box-shadow 0.2s;
1542
+ }
1543
+
1544
+ .search-field:focus-within {
1545
+ border-color: #163052;
1546
+ box-shadow: 0 0 0 3px rgba(22, 48, 82, 0.1);
1547
+ }
1548
+
1549
+ /* TODO: Uncomment when views dropdown is added
1550
+ .search-field:focus-within .views {
1551
+ border-color: #163052;
1552
+ }
1553
+ */
1554
+
1555
+ .search-icon {
1556
+ position: absolute;
1557
+ left: 16px;
1558
+ width: 20px;
1559
+ height: 20px;
1560
+ color: #656871;
1561
+ pointer-events: none;
1562
+ }
1563
+
1564
+ .search-input {
1565
+ height: 36px;
1566
+ padding: 0 16px 0 42px;
1567
+ border: none;
1568
+ border-radius: 16px;
1569
+ font-size: 14px;
1570
+ font-weight: 400;
1571
+ font-family: inherit;
1572
+ color: #163052;
1573
+ background: transparent;
1574
+ outline: none;
1575
+ flex: 1;
1576
+ min-width: 0;
1577
+ width: 100%;
1578
+ }
1579
+
1580
+ .search-input::placeholder {
1581
+ color: #656871;
1582
+ font-weight: 400;
1583
+ }
1584
+
1585
+ .search-input:focus {
1586
+ outline: none;
1587
+ }
1588
+
1589
+ @container (max-width: 800px) {
1590
+ .search-field {
1591
+ max-width: 250px;
1592
+ }
1593
+ }
1594
+
1595
+ .views {
1596
+ display: flex;
1597
+ align-items: center;
1598
+ gap: 4px;
1599
+ height: 36px;
1600
+ padding: 0 16px;
1601
+ border: 1px solid #00000038;
1602
+ border-right: none;
1603
+ border-radius: 16px 0 0 16px;
1604
+ font-size: 14px;
1605
+ font-family: inherit;
1606
+ color: #163052;
1607
+ background: transparent;
1608
+ cursor: pointer;
1609
+ white-space: nowrap;
1610
+ transition: border-color 0.2s;
1611
+ }
1612
+
1613
+ .views:hover {
1614
+ background: #e8f0f8;
1615
+ }
1616
+
1617
+ .views svg {
1618
+ width: 16px;
1619
+ height: 16px;
1620
+ color: #163052;
1621
+ }
1622
+
1623
+ /* -------------------------------------------------------------------------
1624
+ * Pagination
1625
+ * ----------------------------------------------------------------------- */
1626
+ .tools {
1627
+ display: flex;
1628
+ align-items: center;
1629
+ gap: 8px;
1630
+ }
1631
+
1632
+ .pagination {
1633
+ display: flex;
1634
+ align-items: center;
1635
+ gap: 2px;
1636
+ }
1637
+
1638
+ .pagination-info {
1639
+ font-size: 13px;
1640
+ color: var(--kr-primary);
1641
+ white-space: nowrap;
1642
+ }
1643
+
1644
+ .pagination-icon {
1645
+ display: flex;
1646
+ color: var(--kr-primary);
1647
+ cursor: pointer;
1648
+ }
1649
+
1650
+ .pagination-icon--disabled {
1651
+ opacity: 0.3;
1652
+ pointer-events: none;
1653
+ }
1654
+
1655
+ .pagination-icon svg {
1656
+ width: 24px;
1657
+ height: 24px;
1658
+ }
1659
+
1660
+ /* -------------------------------------------------------------------------
1661
+ * Header Icons
1662
+ * ----------------------------------------------------------------------- */
1663
+ .refresh,
1664
+ .header-icon {
1665
+ display: flex;
1666
+ align-items: center;
1667
+ justify-content: center;
1668
+ color: var(--kr-primary);
1669
+ background: #EBF1FA;
1670
+ cursor: pointer;
1671
+ padding: 6px;
1672
+ border-radius: 50%;
1673
+ transition: background 0.15s;
1674
+ }
1675
+
1676
+ .refresh:hover,
1677
+ .header-icon:hover {
1678
+ background: #e8f0f8;
1679
+ }
1680
+
1681
+ .refresh svg,
1682
+ .header-icon svg {
1683
+ width: 24px;
1684
+ height: 24px;
1685
+ }
1686
+
1687
+ /* -------------------------------------------------------------------------
1688
+ * Column Picker
1689
+ * ----------------------------------------------------------------------- */
1690
+ .column-picker-wrapper {
1691
+ position: relative;
1692
+ }
1693
+
1694
+ .column-picker {
1695
+ position: absolute;
1696
+ top: 100%;
1697
+ right: 0;
1698
+ margin-top: 4px;
1699
+ min-width: 200px;
1700
+ max-height: calc(100vh - 120px);
1701
+ overflow-y: auto;
1702
+ background: white;
1703
+ border: 1px solid #9ba7b6;
1704
+ border-radius: 8px;
1705
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
1706
+ padding: 8px 0;
1707
+ z-index: 100;
1708
+ display: none;
1709
+ transform-origin: top;
1710
+ }
1711
+
1712
+ .column-picker.open {
1713
+ display: block;
1714
+ animation: column-picker-fade-in 150ms ease-out;
1715
+ }
1716
+
1717
+ @keyframes column-picker-fade-in {
1718
+ from {
1719
+ opacity: 0;
1720
+ transform: translateY(-4px);
1721
+ }
1722
+ to {
1723
+ opacity: 1;
1724
+ transform: translateY(0);
1725
+ }
1726
+ }
1727
+
1728
+ .column-picker-item {
1729
+ display: flex;
1730
+ align-items: center;
1731
+ gap: 10px;
1732
+ padding: 8px 16px;
1733
+ cursor: pointer;
1734
+ white-space: nowrap;
1735
+ }
1736
+
1737
+ .column-picker-item:hover {
1738
+ background: #f3f4f6;
1739
+ }
1740
+
1741
+ .column-picker-checkbox {
1742
+ width: 16px;
1743
+ height: 16px;
1744
+ border: 1.5px solid #9ca3af;
1745
+ border-radius: 3px;
1746
+ display: flex;
1747
+ align-items: center;
1748
+ justify-content: center;
1749
+ flex-shrink: 0;
1750
+ transition: all 0.15s;
1751
+ }
1752
+
1753
+ .column-picker-checkbox.checked {
1754
+ background: var(--kr-primary);
1755
+ border-color: var(--kr-primary);
1756
+ }
1757
+
1758
+ .column-picker-checkbox svg {
1759
+ width: 12px;
1760
+ height: 12px;
1761
+ color: white;
1762
+ opacity: 0;
1763
+ }
1764
+
1765
+ .column-picker-checkbox.checked svg {
1766
+ opacity: 1;
1767
+ }
1768
+
1769
+ .column-picker-label {
1770
+ font-size: 14px;
1771
+ color: #374151;
1772
+ }
1773
+
1774
+ /* -------------------------------------------------------------------------
1775
+ * Table Structure
1776
+ * ----------------------------------------------------------------------- */
1777
+ .table {
1778
+ display: grid;
1779
+ width: max-content;
1780
+ min-width: 100%;
1781
+ font-size: 13px;
1782
+ }
1783
+
1784
+ .row {
1785
+ display: contents;
1786
+ }
1787
+
1788
+ .row:hover .cell {
1789
+ background: #f8f9fa;
1790
+ }
1791
+
1792
+ .row--clickable {
1793
+ cursor: pointer;
1794
+ }
1795
+
1796
+ .row--link {
1797
+ color: inherit;
1798
+ text-decoration: none;
1799
+ }
1800
+
1801
+ .header-row {
1802
+ display: contents;
1803
+ }
1804
+
1805
+ .cell {
1806
+ padding: 8px 12px;
1807
+ display: flex;
1808
+ align-items: center;
1809
+ white-space: nowrap;
1810
+ overflow: hidden;
1811
+ text-overflow: ellipsis;
1812
+ box-sizing: border-box;
1813
+ }
1814
+
1815
+ .cell--actions {
1816
+ gap: 8px;
1817
+ }
1818
+
1819
+ .header-cell {
1820
+ position: sticky;
1821
+ top: 0;
1822
+ padding: 10px 12px;
1823
+ white-space: nowrap;
1824
+ box-sizing: border-box;
1825
+ background: #173153;
1826
+ color: #fff;
1827
+ font-weight: 600;
1828
+ font-size: 12px;
1829
+ text-transform: uppercase;
1830
+ letter-spacing: 0.5px;
1831
+ display: flex;
1832
+ align-items: center;
1833
+ }
1834
+
1835
+ .header-cell__resize {
1836
+ position: absolute;
1837
+ right: -7px;
1838
+ top: 0;
1839
+ bottom: 0;
1840
+ width: 14px;
1841
+ cursor: col-resize;
1842
+ z-index: 10;
1843
+ }
1844
+
1845
+ .header-cell--sortable {
1846
+ user-select: none;
1847
+ }
1848
+
1849
+ .header-cell__label {
1850
+ overflow: hidden;
1851
+ text-overflow: ellipsis;
1852
+ min-width: 0;
1853
+ line-height: 20px;
1854
+ }
1855
+
1856
+ .header-cell__sort {
1857
+ flex-grow: 1;
1858
+ display: flex;
1859
+ align-items: center;
1860
+ height: 100%;
1861
+ margin-left: 6px;
1862
+ cursor: pointer;
1863
+ }
1864
+
1865
+ .header-cell__sort-arrow {
1866
+ width: 16px;
1867
+ height: 16px;
1868
+ color: #fff;
1869
+ stroke: currentColor;
1870
+ stroke-width: 1px;
1871
+ }
1872
+
1873
+ .header-cell__sort-arrow--ghost {
1874
+ opacity: 0;
1875
+ color: rgba(255, 255, 255, 0.6);
1876
+ transition: opacity 0.15s;
1877
+ }
1878
+
1879
+ .header-cell--sortable:hover .header-cell__sort-arrow--ghost {
1880
+ opacity: 0.4;
1881
+ }
1882
+
1883
+ .header-cell__sort-priority {
1884
+ font-size: 10px;
1885
+ font-weight: 600;
1886
+ color: #fff;
1887
+ line-height: 1;
1888
+ }
1889
+
1890
+ .cell {
1891
+ background: #fff;
1892
+ border-bottom: 1px solid rgb(238, 239, 241);
1893
+ color: #1f2937;
1894
+ }
1895
+
1896
+ .cell--align-center {
1897
+ text-align: center;
1898
+ }
1899
+
1900
+ .cell--align-right {
1901
+ text-align: right;
1902
+ }
1903
+
1904
+ .cell--sticky-left,
1905
+ .cell--sticky-right {
1906
+ position: sticky;
1907
+ z-index: 1;
1908
+ }
1909
+
1910
+ .header-cell--sticky-left,
1911
+ .header-cell--sticky-right {
1912
+ position: sticky;
1913
+ z-index: 3;
1914
+ }
1915
+
1916
+ .header-cell--align-center {
1917
+ text-align: center;
1918
+ }
1919
+
1920
+ .header-cell--align-right {
1921
+ text-align: right;
1922
+ }
1923
+
1924
+ .header-cell--sticky-left-last,
1925
+ .cell--sticky-left-last {
1926
+ border-right: 1px solid #d1d5db;
1927
+ }
1928
+
1929
+ .header-cell--sticky-right-first,
1930
+ .cell--sticky-right-first {
1931
+ border-left: 1px solid #d1d5db;
1932
+ }
1933
+
1934
+ /* -------------------------------------------------------------------------
1935
+ * Scroll Mode: Edge
1936
+ * Padding scrolls with content, table can reach edges when scrolling
1937
+ * ----------------------------------------------------------------------- */
1938
+ /* Only add right padding when no horizontal scroll is needed */
1939
+ :host(.kr-table--scroll-edge):not(.kr-table--scroll-horizontal-available) .table {
1940
+ padding-right: 24px;
1941
+ }
1942
+
1943
+ :host(.kr-table--scroll-edge) .header-row .header-cell:first-child,
1944
+ :host(.kr-table--scroll-edge) .row .cell:first-child {
1945
+ padding-left: 24px;
1946
+ }
1947
+
1948
+ :host(.kr-table--scroll-edge) .header-row .header-cell:last-child,
1949
+ :host(.kr-table--scroll-edge) .row .cell:last-child {
1950
+ padding-right: 24px;
1951
+ }
1952
+
1953
+ :host(.kr-table--scroll-edge) .filter-row .filter-cell:first-child .filter-cell__input {
1954
+ padding-left: 24px;
1955
+ }
1956
+
1957
+ :host(.kr-table--scroll-edge) .filter-row .filter-cell:last-child .filter-cell__input {
1958
+ padding-right: 52px;
1959
+ }
1960
+
1961
+ /* -------------------------------------------------------------------------
1962
+ * Scroll Mode: Overlay
1963
+ * Fixed padding with overlay elements that hide content at edges
1964
+ * ----------------------------------------------------------------------- */
1965
+ :host(.kr-table--scroll-overlay) .content {
1966
+ padding-left: 24px;
1967
+ }
1968
+
1969
+ .overlay-left,
1970
+ .overlay-right {
1971
+ display: none;
1972
+ position: absolute;
1973
+ top: 0;
1974
+ bottom: 0;
1975
+ width: 24px;
1976
+ z-index: 5;
1977
+ pointer-events: none;
1978
+ transition: box-shadow 0.15s ease;
1979
+ }
1980
+
1981
+ :host(.kr-table--scroll-overlay) .overlay-left,
1982
+ :host(.kr-table--scroll-overlay) .overlay-right {
1983
+ display: block;
1984
+ }
1985
+
1986
+ .overlay-left {
1987
+ left: 0;
1988
+ background: #fff;
1989
+ }
1990
+
1991
+ .overlay-right {
1992
+ right: 0;
1993
+ background: #fff;
1994
+ }
1995
+
1996
+ :host(.kr-table--scroll-overlay.kr-table--scroll-left-available:not(.kr-table--sticky-left)) .overlay-left {
1997
+ border-right: 1px solid #d1d5db54;
1998
+ }
1999
+
2000
+ :host(.kr-table--scroll-overlay.kr-table--scroll-right-available:not(.kr-table--sticky-right)) .overlay-right {
2001
+ border-left: 1px solid #d1d5db54;
2002
+ }
2003
+
2004
+ /* -------------------------------------------------------------------------
2005
+ * Status (Loading, Error, Empty)
2006
+ * ----------------------------------------------------------------------- */
2007
+ .status {
2008
+ position: absolute;
2009
+ top: 0;
2010
+ left: 0;
2011
+ right: 0;
2012
+ bottom: 0;
2013
+ display: flex;
2014
+ align-items: center;
2015
+ justify-content: center;
2016
+ font-size: 14px;
2017
+ font-weight: 400;
2018
+ color: #5f6368;
2019
+ pointer-events: none;
2020
+ }
2021
+
2022
+ .status--error {
2023
+ color: #dc2626;
2024
+ }
2025
+
2026
+ /* -------------------------------------------------------------------------
2027
+ * Filter Row
2028
+ * ----------------------------------------------------------------------- */
2029
+ .filter-row {
2030
+ display: contents;
2031
+ }
2032
+
2033
+ .filter-cell {
2034
+ position: sticky;
2035
+ top: 40px;
2036
+ z-index: 2;
2037
+ height: 36px;
2038
+ padding: 0;
2039
+ display: flex;
2040
+ align-items: center;
2041
+ background: #fff;
2042
+ border-bottom: 1px solid #d1d5db;
2043
+ border-right: 1px solid #e5e7ebba;
2044
+ box-sizing: border-box;
2045
+ box-shadow: inset 0 0 0 0 #163052;
2046
+ transition: box-shadow 0.15s;
2047
+ }
2048
+
2049
+ .filter-cell:focus-within {
2050
+ box-shadow: inset 0 0 0 1px #163052;
2051
+ }
2052
+
2053
+
2054
+ .filter-cell--sticky-left,
2055
+ .filter-cell--sticky-right {
2056
+ position: sticky;
2057
+ z-index: 3;
2058
+ }
2059
+
2060
+ .filter-cell--sticky-right-first {
2061
+ border-left: 1px solid #d1d5db;
2062
+ }
2063
+
2064
+ .filter-cell__wrapper {
2065
+ position: relative;
2066
+ display: flex;
2067
+ align-items: center;
2068
+ width: 100%;
2069
+ height: 100%;
2070
+ }
2071
+
2072
+ .filter-cell__input {
2073
+ width: 100%;
2074
+ height: 100%;
2075
+ padding: 0 60px 0 16px;
2076
+ border: none;
2077
+ border-radius: 0;
2078
+ font-size: 14px;
2079
+ font-family: inherit;
2080
+ color: #111827;
2081
+ background: transparent;
2082
+ outline: none;
2083
+ }
2084
+
2085
+ .filter-cell__input--invalid {
2086
+ border-color: #dc2626;
2087
+ }
2088
+
2089
+ .filter-cell__input--invalid:focus {
2090
+ border-color: #dc2626;
2091
+ box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.1);
2092
+ }
2093
+
2094
+ .filter-cell__input::placeholder {
2095
+ color: #9ca3af;
2096
+ font-size: 13px;
2097
+ }
2098
+
2099
+ .filter-cell__clear {
2100
+ position: absolute;
2101
+ right: 36px;
2102
+ top: 50%;
2103
+ transform: translateY(-50%);
2104
+ display: flex;
2105
+ align-items: center;
2106
+ justify-content: center;
2107
+ width: 24px;
2108
+ height: 24px;
2109
+ padding: 0;
2110
+ border: none;
2111
+ border-radius: 4px;
2112
+ background: transparent;
2113
+ color: #6b7280;
2114
+ cursor: pointer;
2115
+ transition: background 0.15s, color 0.15s;
2116
+ }
2117
+
2118
+ .filter-cell__clear:hover {
2119
+ background: #e5e7eb;
2120
+ color: #374151;
2121
+ }
2122
+
2123
+ .filter-cell__clear svg {
2124
+ width: 16px;
2125
+ height: 16px;
2126
+ }
2127
+
2128
+ .filter-cell__advanced {
2129
+ position: absolute;
2130
+ right: 12px;
2131
+ top: 50%;
2132
+ transform: translateY(-50%);
2133
+ display: flex;
2134
+ align-items: center;
2135
+ justify-content: center;
2136
+ width: 24px;
2137
+ height: 24px;
2138
+ padding: 0;
2139
+ border: none;
2140
+ border-radius: 4px;
2141
+ background: transparent;
2142
+ color: #163052;
2143
+ cursor: pointer;
2144
+ transition: background 0.15s, color 0.15s;
2145
+ }
2146
+
2147
+ .filter-cell__advanced:hover {
2148
+ background: #e5e7eb;
2149
+ }
2150
+
2151
+ .filter-cell__advanced svg {
2152
+ width: 16px;
2153
+ height: 16px;
2154
+ }
2155
+
2156
+ .filter-cell__advanced--opened {
2157
+ background: #163052;
2158
+ color: #fff;
2159
+ }
2160
+
2161
+ .filter-cell__advanced--opened:hover {
2162
+ background: #1a3a5f;
2163
+ color: #fff;
2164
+ }
2165
+
2166
+ /* -------------------------------------------------------------------------
2167
+ * Filter Panel (Advanced)
2168
+ * ----------------------------------------------------------------------- */
2169
+ .filter-panel {
2170
+ position: fixed;
2171
+ min-width: 320px;
2172
+ background: white;
2173
+ border: 1px solid #9ba7b6;
2174
+ border-radius: 8px;
2175
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
2176
+ z-index: 100;
2177
+ animation: filter-panel-fade-in 150ms ease-out;
2178
+ }
2179
+
2180
+ @keyframes filter-panel-fade-in {
2181
+ from {
2182
+ opacity: 0;
2183
+ transform: translateY(-4px);
2184
+ }
2185
+ to {
2186
+ opacity: 1;
2187
+ transform: translateY(0);
2188
+ }
2189
+ }
2190
+
2191
+ .filter-panel__content {
2192
+ padding: 16px;
2193
+ display: flex;
2194
+ flex-direction: column;
2195
+ gap: 12px;
2196
+ }
2197
+
2198
+ .filter-panel__actions {
2199
+ padding: 12px 16px;
2200
+ border-top: 1px solid #e5e7eb;
2201
+ display: flex;
2202
+ justify-content: flex-end;
2203
+ align-items: center;
2204
+ gap: 12px;
2205
+ }
2206
+
2207
+
2208
+ .filter-panel__input {
2209
+ width: 100%;
2210
+ padding: 10px 12px;
2211
+ border: 1px solid #d1d5db;
2212
+ border-radius: 8px;
2213
+ font-size: 14px;
2214
+ font-family: inherit;
2215
+ color: #111827;
2216
+ background: #fff;
2217
+ outline: none;
2218
+ transition: border-color 0.2s, box-shadow 0.2s;
2219
+ }
2220
+
2221
+ .filter-panel__input:focus {
2222
+ border-color: #163052;
2223
+ box-shadow: 0 0 0 2px rgba(22, 48, 82, 0.1);
2224
+ }
2225
+
2226
+ .filter-panel__input::placeholder {
2227
+ color: #9ca3af;
2228
+ }
2229
+
2230
+ .filter-panel__textarea {
2231
+ width: 100%;
2232
+ padding: 10px 12px;
2233
+ border: 1px solid #d1d5db;
2234
+ border-radius: 8px;
2235
+ font-size: 14px;
2236
+ font-family: inherit;
2237
+ color: #111827;
2238
+ background: #fff;
2239
+ outline: none;
2240
+ resize: vertical;
2241
+ transition: border-color 0.2s, box-shadow 0.2s;
2242
+ }
2243
+
2244
+ .filter-panel__textarea:focus {
2245
+ border-color: #163052;
2246
+ box-shadow: 0 0 0 2px rgba(22, 48, 82, 0.1);
2247
+ }
2248
+
2249
+ .filter-panel__textarea::placeholder {
2250
+ color: #9ca3af;
2251
+ }
2252
+
2253
+ /* -------------------------------------------------------------------------
2254
+ * Bucket List
2255
+ * ----------------------------------------------------------------------- */
2256
+ .buckets {
2257
+ max-height: 280px;
2258
+ overflow-y: auto;
2259
+ padding: 8px 0;
2260
+ }
2261
+
2262
+ .bucket {
2263
+ display: flex;
2264
+ align-items: center;
2265
+ gap: 16px;
2266
+ height: 32px;
2267
+ padding: 0 16px;
2268
+ cursor: pointer;
2269
+ transition: background 0.1s;
2270
+ }
2271
+
2272
+ .bucket:hover {
2273
+ background: #f3f4f6;
2274
+ }
2275
+
2276
+ .bucket__checkbox {
2277
+ width: 16px;
2278
+ height: 16px;
2279
+ border: 1.5px solid #9ca3af;
2280
+ border-radius: 3px;
2281
+ display: flex;
2282
+ align-items: center;
2283
+ justify-content: center;
2284
+ flex-shrink: 0;
2285
+ transition: all 0.15s;
2286
+ }
2287
+
2288
+ .bucket__checkbox--checked {
2289
+ background: var(--kr-primary, #163052);
2290
+ border-color: var(--kr-primary, #163052);
2291
+ }
2292
+
2293
+ .bucket__checkbox svg {
2294
+ width: 12px;
2295
+ height: 12px;
2296
+ color: white;
2297
+ }
2298
+
2299
+ .bucket__label {
2300
+ flex: 1;
2301
+ font-size: 14px;
2302
+ color: #000;
2303
+ overflow: hidden;
2304
+ text-overflow: ellipsis;
2305
+ white-space: nowrap;
2306
+ }
2307
+
2308
+ .bucket__count {
2309
+ font-size: 14px;
2310
+ color: #000;
2311
+ flex-shrink: 0;
2312
+ }
2313
+
2314
+ .bucket-empty {
2315
+ font-size: 14px;
2316
+ color: #000;
2317
+ padding: 16px;
2318
+ }
2319
+
2320
+ /* Card variant — self-contained card with border, auto height, left-aligned search */
2321
+ :host(.kr-table--card) {
2322
+ height: auto;
2323
+ /* overflow: visible; */
2324
+ overflow: hidden;
2325
+ /* border: 1px solid #e5e7eb; */
2326
+ /* border-radius: 8px; */
2327
+ border: 1px solid #c6c6cd;
2328
+ border-radius: 12px;
2329
+ background: #fff;
2330
+ }
2331
+
2332
+ .card-header {
2333
+ padding: 24px 24px 16px;
2334
+ }
2335
+
2336
+ .card-header__title {
2337
+ font-size: 18px;
2338
+ font-weight: 600;
2339
+ color: #1f2937;
2340
+ margin: 0;
2341
+ }
2342
+
2343
+ .card-header__description {
2344
+ font-size: 14px;
2345
+ color: #1f2937;
2346
+ margin: 12px 0 0;
2347
+ }
2348
+
2349
+ :host(.kr-table--card) .header {
2350
+ margin: 0 24px;
2351
+ }
2352
+
2353
+ :host(.kr-table--card) .wrapper {
2354
+ flex: none;
2355
+ overflow: visible;
2356
+ min-height: 200px;
2357
+ }
2358
+
2359
+ :host(.kr-table--card) .content {
2360
+ height: auto;
2361
+ }
2362
+
2363
+ `];
2364
+ __decorate([
2365
+ state()
2366
+ ], KRGrid.prototype, "_data", void 0);
2367
+ __decorate([
2368
+ state()
2369
+ ], KRGrid.prototype, "_dataState", void 0);
2370
+ __decorate([
2371
+ state()
2372
+ ], KRGrid.prototype, "_page", void 0);
2373
+ __decorate([
2374
+ state()
2375
+ ], KRGrid.prototype, "_pageSize", void 0);
2376
+ __decorate([
2377
+ state()
2378
+ ], KRGrid.prototype, "_totalItems", void 0);
2379
+ __decorate([
2380
+ state()
2381
+ ], KRGrid.prototype, "_totalPages", void 0);
2382
+ __decorate([
2383
+ state()
2384
+ ], KRGrid.prototype, "_searchQuery", void 0);
2385
+ __decorate([
2386
+ state()
2387
+ ], KRGrid.prototype, "_canScrollLeft", void 0);
2388
+ __decorate([
2389
+ state()
2390
+ ], KRGrid.prototype, "_canScrollRight", void 0);
2391
+ __decorate([
2392
+ state()
2393
+ ], KRGrid.prototype, "_canScrollHorizontal", void 0);
2394
+ __decorate([
2395
+ state()
2396
+ ], KRGrid.prototype, "_columnPickerOpen", void 0);
2397
+ __decorate([
2398
+ state()
2399
+ ], KRGrid.prototype, "_filterPanelOpened", void 0);
2400
+ __decorate([
2401
+ state()
2402
+ ], KRGrid.prototype, "_filterPanelTab", void 0);
2403
+ __decorate([
2404
+ state()
2405
+ ], KRGrid.prototype, "_buckets", void 0);
2406
+ __decorate([
2407
+ state()
2408
+ ], KRGrid.prototype, "_sorts", void 0);
2409
+ __decorate([
2410
+ property({ type: Object })
2411
+ ], KRGrid.prototype, "def", void 0);
2412
+ __decorate([
2413
+ property({ type: String, reflect: true })
2414
+ ], KRGrid.prototype, "variant", void 0);
2415
+ KRGrid = __decorate([
2416
+ customElement('kr-grid')
2417
+ ], KRGrid);
2418
+ export { KRGrid };
2419
+ //# sourceMappingURL=grid.js.map