vueless 1.3.9-beta.8 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,35 +1,39 @@
1
1
  <script setup lang="ts">
2
2
  import {
3
+ shallowRef,
3
4
  ref,
4
5
  computed,
5
6
  watch,
7
+ watchEffect,
6
8
  useSlots,
7
9
  nextTick,
8
10
  onMounted,
9
- onUpdated,
10
11
  onBeforeUnmount,
11
12
  useTemplateRef,
13
+ h,
12
14
  } from "vue";
13
15
  import { isEqual } from "lodash-es";
14
16
 
15
- import UEmpty from "../ui.container-empty/UEmpty.vue";
16
- import UCheckbox from "../ui.form-checkbox/UCheckbox.vue";
17
- import ULoaderProgress from "../ui.loader-progress/ULoaderProgress.vue";
18
- import UTableRow from "./UTableRow.vue";
19
- import UDivider from "../ui.container-divider/UDivider.vue";
20
-
21
17
  import { useUI } from "../composables/useUI";
18
+ import { useVirtualScroll } from "../composables/useVirtualScroll";
19
+ import { useComponentLocaleMessages } from "../composables/useComponentLocaleMassages";
22
20
  import { getDefaults, cx, getMergedConfig } from "../utils/ui";
23
21
  import { hasSlotContent } from "../utils/helper";
24
- import { useComponentLocaleMessages } from "../composables/useComponentLocaleMassages";
25
-
26
- import defaultConfig from "./config";
27
- import { normalizeColumns, mapRowColumns, getFlatRows, getRowChildrenIds } from "./utilTable";
28
22
 
29
23
  import { PX_IN_REM } from "../constants";
30
24
  import { COMPONENT_NAME } from "./constants";
31
25
 
32
- import type { ComputedRef } from "vue";
26
+ import defaultConfig from "./config";
27
+ import { normalizeColumns, getFlatRows, getRowChildrenIds } from "./utilTable";
28
+ import { StickySide } from "./types";
29
+
30
+ import UEmpty from "../ui.container-empty/UEmpty.vue";
31
+ import UCheckbox from "../ui.form-checkbox/UCheckbox.vue";
32
+ import ULoaderProgress from "../ui.loader-progress/ULoaderProgress.vue";
33
+ import UDivider from "../ui.container-divider/UDivider.vue";
34
+ import UTableRow from "./UTableRow.vue";
35
+
36
+ import type { ComputedRef, VNode } from "vue";
33
37
  import type { Config as UDividerConfig } from "../ui.container-divider/types";
34
38
  import type {
35
39
  Cell,
@@ -41,8 +45,9 @@ import type {
41
45
  DateDivider,
42
46
  FlatRow,
43
47
  ColumnObject,
48
+ SearchMatch,
49
+ UTableRowProps,
44
50
  } from "./types";
45
- import { StickySide } from "./types";
46
51
 
47
52
  defineOptions({ inheritAttrs: false });
48
53
 
@@ -75,13 +80,13 @@ const emit = defineEmits([
75
80
  "clickCell",
76
81
 
77
82
  /**
78
- * Tirggers when row expanded.
83
+ * Triggers when row expanded.
79
84
  * @property {object} row
80
85
  */
81
86
  "row-expand",
82
87
 
83
88
  /**
84
- * Tirggers when row collapsed.
89
+ * Triggers when row collapsed.
85
90
  * @property {object} row
86
91
  */
87
92
  "row-collapse",
@@ -97,6 +102,12 @@ const emit = defineEmits([
97
102
  * @property {array} rowId
98
103
  */
99
104
  "update:expandedRows",
105
+
106
+ /**
107
+ * Triggers when search matches are found.
108
+ * @property {number} totalMatches
109
+ */
110
+ "search",
100
111
  ]);
101
112
 
102
113
  const slots = useSlots();
@@ -122,35 +133,29 @@ const { localeMessages } = useComponentLocaleMessages<typeof defaultConfig.i18n>
122
133
  props?.config?.i18n,
123
134
  );
124
135
 
125
- const localSelectedRows = ref<Row[]>([]);
126
- const localExpandedRows = ref<RowId[]>([]);
136
+ const localSelectedRows = shallowRef<Row[]>([]);
137
+ const localExpandedRows = shallowRef<RowId[]>([]);
138
+
139
+ const expandedRowsSet = computed(() => new Set(localExpandedRows.value));
127
140
 
128
141
  const sortedRows: ComputedRef<FlatRow[]> = computed(() => {
129
142
  const headerKeys = props.columns.map((column) =>
130
143
  typeof column === "object" ? column.key : column,
131
144
  );
132
145
 
133
- return flatTableRows.value.map((row) => {
134
- const rowEntries = Object.entries(row);
135
-
136
- const sortedEntries: typeof rowEntries = new Array(rowEntries.length);
137
-
138
- rowEntries.forEach((entry) => {
139
- const [key] = entry;
140
- const headerIndex = headerKeys.indexOf(key);
146
+ const keyOrder = new Map(headerKeys.map((key, i) => [key, i]));
141
147
 
142
- if (!~headerIndex) {
143
- sortedEntries.push(entry);
148
+ return flatTableRows.value.map((row) => {
149
+ const entries = Object.entries(row);
144
150
 
145
- return;
146
- }
151
+ entries.sort((a, b) => {
152
+ const aIdx = keyOrder.get(a[0]) ?? Infinity;
153
+ const bIdx = keyOrder.get(b[0]) ?? Infinity;
147
154
 
148
- sortedEntries[headerIndex] = entry;
155
+ return aIdx - bIdx;
149
156
  });
150
157
 
151
- const sortedRow = Object.fromEntries(sortedEntries.filter((value) => value));
152
-
153
- return sortedRow as FlatRow;
158
+ return Object.fromEntries(entries) as FlatRow;
154
159
  });
155
160
  });
156
161
 
@@ -168,7 +173,7 @@ const visibleColumns = computed(() => {
168
173
  return normalizedColumns.value.filter((column) => column.isShown !== false);
169
174
  });
170
175
 
171
- const columnPositions = ref<Map<string, number>>(new Map());
176
+ const columnPositions = shallowRef<Map<string, number>>(new Map());
172
177
 
173
178
  const colsCount = computed(() => {
174
179
  return normalizedColumns.value.length + 1;
@@ -183,11 +188,10 @@ const isShownActionsHeader = computed(() => {
183
188
  return hasSelectedRows && hasHeaderActions;
184
189
  });
185
190
 
186
- const isHeaderSticky = computed(() => {
187
- const positionForFixHeader =
188
- Number(headerRowRef.value?.getBoundingClientRect()?.top) + Number(window?.scrollY) || 0;
191
+ const headerOffsetTop = ref(0);
189
192
 
190
- return positionForFixHeader <= pagePositionY.value && props.stickyHeader;
193
+ const isHeaderSticky = computed(() => {
194
+ return headerOffsetTop.value <= pagePositionY.value && props.stickyHeader;
191
195
  });
192
196
 
193
197
  const isShownFooterPosition = computed(() => {
@@ -206,53 +210,304 @@ const tableRowWidthStyle = computed(() => ({ width: `${tableWidth.value / PX_IN_
206
210
 
207
211
  const flatTableRows = computed(() => getFlatRows(props.rows));
208
212
 
213
+ const visibleFlatRows = computed(() => {
214
+ const expanded = expandedRowsSet.value;
215
+
216
+ return flatTableRows.value.filter((row) => !row.parentRowId || expanded.has(row.parentRowId));
217
+ });
218
+
219
+ const virtualScroll = useVirtualScroll({
220
+ containerRef: tableWrapperRef,
221
+ totalCount: computed(() => (props.virtualScroll ? visibleFlatRows.value.length : 0)),
222
+ rowHeight: props.rowHeight,
223
+ bufferSize: props.bufferSize,
224
+ });
225
+
226
+ const renderedRows = computed(() => {
227
+ if (props.virtualScroll) {
228
+ // For virtual scroll, we still need to slice based on visible rows
229
+ // but we need to map back to the actual rows from flatTableRows
230
+ const visibleSlice = visibleFlatRows.value.slice(
231
+ virtualScroll.startIndex.value,
232
+ virtualScroll.endIndex.value,
233
+ );
234
+ const visibleIds = new Set(visibleSlice.map((row) => row.id));
235
+
236
+ return flatTableRows.value.filter((row) => visibleIds.has(row.id));
237
+ }
238
+
239
+ return flatTableRows.value;
240
+ });
241
+
242
+ function isRowVisible(row: FlatRow): boolean {
243
+ return !row.parentRowId || expandedRowsSet.value.has(row.parentRowId);
244
+ }
245
+
246
+ const searchMatches = computed<SearchMatch[]>(() => {
247
+ const query = props.search?.toLowerCase();
248
+
249
+ if (!query) return [];
250
+
251
+ const matches: SearchMatch[] = [];
252
+ const columns = visibleColumns.value;
253
+ const rows = visibleFlatRows.value;
254
+
255
+ for (let i = 0; i < rows.length; i++) {
256
+ const row = rows[i];
257
+
258
+ for (let j = 0; j < columns.length; j++) {
259
+ const key = columns[j].key;
260
+ const cellValue = row[key];
261
+ const text = getCellTextValue(cellValue);
262
+
263
+ if (!text) continue;
264
+
265
+ const lowerText = text.toLowerCase();
266
+ const indices: number[] = [];
267
+ let pos = 0;
268
+
269
+ while ((pos = lowerText.indexOf(query, pos)) !== -1) {
270
+ indices.push(pos);
271
+ pos += query.length;
272
+ }
273
+
274
+ if (indices.length) {
275
+ matches.push({ rowId: row.id, columnKey: key, indices });
276
+ }
277
+ }
278
+ }
279
+
280
+ return matches;
281
+ });
282
+
283
+ const searchMatchColumnSets = computed(() => {
284
+ const map = new Map<RowId, Set<string>>();
285
+
286
+ for (const match of searchMatches.value) {
287
+ let set = map.get(match.rowId);
288
+
289
+ if (!set) {
290
+ set = new Set();
291
+ map.set(match.rowId, set);
292
+ }
293
+
294
+ set.add(match.columnKey);
295
+ }
296
+
297
+ return map;
298
+ });
299
+
300
+ const activeMatch = computed(() => {
301
+ const idx = props.searchMatch;
302
+
303
+ if (idx === undefined || idx < 0 || !searchMatches.value.length) return null;
304
+
305
+ let globalIndex = 0;
306
+
307
+ for (const match of searchMatches.value) {
308
+ if (globalIndex + match.indices.length > idx) {
309
+ return {
310
+ rowId: match.rowId,
311
+ columnKey: match.columnKey,
312
+ charIndex: match.indices[idx - globalIndex],
313
+ };
314
+ }
315
+
316
+ globalIndex += match.indices.length;
317
+ }
318
+
319
+ return null;
320
+ });
321
+
322
+ const totalSearchMatches = computed(() => {
323
+ let count = 0;
324
+
325
+ for (const match of searchMatches.value) {
326
+ count += match.indices.length;
327
+ }
328
+
329
+ return count;
330
+ });
331
+
332
+ const selectedRowIds = computed(() => {
333
+ return new Set(localSelectedRows.value.map((row) => row.id));
334
+ });
335
+
209
336
  const isSelectedAllRows = computed(() => {
210
337
  return localSelectedRows.value.length === flatTableRows.value.length;
211
338
  });
212
339
 
213
- const tableRowAttrs = computed(() => ({
214
- bodyCellContentAttrs,
215
- bodyCellCheckboxAttrs,
216
- bodyCheckboxAttrs,
217
- bodyCellNestedAttrs,
218
- bodyCellNestedExpandIconAttrs,
219
- bodyCellNestedCollapseIconAttrs,
220
- bodyCellBaseAttrs,
221
- bodyCellNestedIconWrapperAttrs,
222
- bodyRowCheckedAttrs,
223
- bodyRowAttrs,
224
- bodyCellStickyLeftAttrs,
225
- bodyCellStickyRightAttrs,
226
- }));
340
+ // Conditional watchers - only create listeners when their associated features are enabled
341
+ // Using watchEffect to dynamically create/destroy watchers based on feature state
342
+
343
+ // Search-related watchers
344
+ // Note: totalSearchMatches watcher always runs to emit events (even when search is cleared)
345
+ watch(totalSearchMatches, (count) => emit("search", count), { flush: "post" });
346
+
347
+ // activeMatch watcher - only created when search is active
348
+ let stopActiveMatchWatch: (() => void) | null = null;
349
+
350
+ watchEffect(() => {
351
+ if (props.search) {
352
+ // Create watcher if it doesn't exist
353
+ if (!stopActiveMatchWatch) {
354
+ stopActiveMatchWatch = watch(
355
+ activeMatch,
356
+ (match) => {
357
+ if (!match) return;
358
+
359
+ if (props.virtualScroll) {
360
+ const rowIndex = visibleFlatRows.value.findIndex((row) => row.id === match.rowId);
361
+
362
+ if (rowIndex === -1) return;
363
+
364
+ virtualScroll.scrollToIndex(rowIndex);
365
+ } else {
366
+ scrollToRow(match.rowId);
367
+ }
368
+ },
369
+ { flush: "post" },
370
+ );
371
+ }
372
+ } else {
373
+ // Cleanup watcher if it exists
374
+ if (stopActiveMatchWatch) {
375
+ stopActiveMatchWatch();
376
+ stopActiveMatchWatch = null;
377
+ }
378
+ }
379
+ });
227
380
 
228
- watch(localSelectedRows, onChangeLocalSelectedRows, { deep: true });
229
- watch(() => props.selectedRows, onChangeSelectedRows, { deep: true, immediate: true });
230
- watch(() => props.expandedRows, onChangeExpandedRows, { deep: true, immediate: true });
231
- watch(selectAll, onChangeSelectAll);
232
- watch(isHeaderSticky, setHeaderCellWidth);
233
- watch(isFooterSticky, (newValue) =>
234
- newValue ? nextTick(setFooterCellWidth) : setFooterCellWidth(null),
235
- );
381
+ // Selection-related watchers (only created when selectable is enabled)
382
+ let stopLocalSelectedRowsWatch: (() => void) | null = null;
383
+ let stopSelectedRowsWatch: (() => void) | null = null;
384
+ let stopSelectAllWatch: (() => void) | null = null;
385
+
386
+ watchEffect(() => {
387
+ if (props.selectable) {
388
+ // Create watchers if they don't exist
389
+ if (!stopLocalSelectedRowsWatch) {
390
+ stopLocalSelectedRowsWatch = watch(localSelectedRows, onChangeLocalSelectedRows);
391
+ }
392
+
393
+ if (!stopSelectedRowsWatch) {
394
+ stopSelectedRowsWatch = watch(() => props.selectedRows, onChangeSelectedRows, {
395
+ immediate: true,
396
+ });
397
+ }
398
+
399
+ if (!stopSelectAllWatch) {
400
+ stopSelectAllWatch = watch(selectAll, onChangeSelectAll);
401
+ }
402
+ } else {
403
+ // Cleanup watchers if they exist
404
+ if (stopLocalSelectedRowsWatch) {
405
+ stopLocalSelectedRowsWatch();
406
+ stopLocalSelectedRowsWatch = null;
407
+ }
408
+
409
+ if (stopSelectedRowsWatch) {
410
+ stopSelectedRowsWatch();
411
+ stopSelectedRowsWatch = null;
412
+ }
413
+
414
+ if (stopSelectAllWatch) {
415
+ stopSelectAllWatch();
416
+ stopSelectAllWatch = null;
417
+ }
418
+ }
419
+ });
420
+
421
+ // Expansion watcher (always register as it's a core feature)
422
+ watch(() => props.expandedRows, onChangeExpandedRows, { immediate: true });
423
+
424
+ // Sticky header watcher (only created when stickyHeader is enabled)
425
+ let stopHeaderStickyWatch: (() => void) | null = null;
426
+
427
+ watchEffect(() => {
428
+ if (props.stickyHeader) {
429
+ // Create watcher if it doesn't exist
430
+ if (!stopHeaderStickyWatch) {
431
+ stopHeaderStickyWatch = watch(isHeaderSticky, setHeaderCellWidth);
432
+ }
433
+ } else {
434
+ // Cleanup watcher if it exists
435
+ if (stopHeaderStickyWatch) {
436
+ stopHeaderStickyWatch();
437
+ stopHeaderStickyWatch = null;
438
+ }
439
+ }
440
+ });
441
+
442
+ // Sticky footer watcher (only created when stickyFooter is enabled)
443
+ let stopFooterStickyWatch: (() => void) | null = null;
444
+
445
+ watchEffect(() => {
446
+ if (props.stickyFooter) {
447
+ // Create watcher if it doesn't exist
448
+ if (!stopFooterStickyWatch) {
449
+ stopFooterStickyWatch = watch(isFooterSticky, (newValue) =>
450
+ newValue ? nextTick(setFooterCellWidth) : setFooterCellWidth(null),
451
+ );
452
+ }
453
+ } else {
454
+ // Cleanup watcher if it exists
455
+ if (stopFooterStickyWatch) {
456
+ stopFooterStickyWatch();
457
+ stopFooterStickyWatch = null;
458
+ }
459
+ }
460
+ });
461
+
462
+ let resizeObserver: ResizeObserver | null = null;
463
+ let scrollRafId: number | null = null;
464
+ let resizeDebounceId: ReturnType<typeof setTimeout> | null = null;
465
+
466
+ function scheduleWindowResize(tableRectWidth: number, tableRectHeight: number) {
467
+ if (resizeDebounceId) clearTimeout(resizeDebounceId);
468
+
469
+ resizeDebounceId = setTimeout(() => {
470
+ resizeDebounceId = null;
471
+ onWindowResize();
472
+
473
+ tableHeight.value = tableRectHeight;
474
+ tableWidth.value = tableRectWidth;
475
+ }, 300);
476
+ }
236
477
 
237
478
  onMounted(async () => {
238
479
  document.addEventListener("keyup", onKeyupEsc);
239
480
  document.addEventListener("scroll", onScroll, { passive: true });
240
- window.addEventListener("resize", onWindowResize);
241
481
 
242
482
  await nextTick();
483
+ updateHeaderOffsetTop();
243
484
  calculateStickyColumnPositions();
244
- });
245
485
 
246
- onUpdated(() => {
247
- tableHeight.value = Number(tableWrapperRef.value?.offsetHeight);
248
- tableWidth.value = Number(tableWrapperRef.value?.offsetWidth);
249
- calculateStickyColumnPositions();
486
+ if (tableWrapperRef.value) {
487
+ resizeObserver = new ResizeObserver((entries) => {
488
+ const entry = entries[0];
489
+
490
+ if (!entry) return;
491
+
492
+ scheduleWindowResize(entry.contentRect.width, entry.contentRect.height);
493
+ });
494
+
495
+ resizeObserver.observe(tableWrapperRef.value);
496
+ }
250
497
  });
251
498
 
252
499
  onBeforeUnmount(() => {
253
500
  document.removeEventListener("keyup", onKeyupEsc);
254
501
  document.removeEventListener("scroll", onScroll);
255
- window.removeEventListener("resize", onWindowResize);
502
+ resizeObserver?.disconnect();
503
+
504
+ if (scrollRafId !== null) {
505
+ cancelAnimationFrame(scrollRafId);
506
+ }
507
+
508
+ if (resizeDebounceId !== null) {
509
+ clearTimeout(resizeDebounceId);
510
+ }
256
511
  });
257
512
 
258
513
  function onChangeSelectedRows() {
@@ -268,13 +523,18 @@ function onChangeExpandedRows() {
268
523
  }
269
524
 
270
525
  function onWindowResize() {
271
- tableWidth.value = tableWrapperRef.value?.offsetWidth || 0;
272
-
526
+ updateHeaderOffsetTop();
273
527
  setHeaderCellWidth();
274
528
  setFooterCellWidth();
275
529
  calculateStickyColumnPositions();
276
530
  }
277
531
 
532
+ function updateHeaderOffsetTop() {
533
+ if (headerRowRef.value) {
534
+ headerOffsetTop.value = headerRowRef.value.getBoundingClientRect().top + window.scrollY;
535
+ }
536
+ }
537
+
278
538
  function calculateStickyColumnPositions() {
279
539
  if (!headerRowRef.value) return;
280
540
 
@@ -437,7 +697,12 @@ function setHeaderCellWidth() {
437
697
  }
438
698
 
439
699
  function onScroll() {
440
- pagePositionY.value = Number(window?.scrollY);
700
+ if (scrollRafId !== null) return;
701
+
702
+ scrollRafId = requestAnimationFrame(() => {
703
+ pagePositionY.value = Number(window?.scrollY);
704
+ scrollRafId = null;
705
+ });
441
706
  }
442
707
 
443
708
  function onKeyupEsc(event: KeyboardEvent) {
@@ -472,6 +737,85 @@ function onClickCell(cell: Cell, row: Row, key: string | number) {
472
737
  emit("clickCell", cell, row, key);
473
738
  }
474
739
 
740
+ function onBodyClick(event: MouseEvent) {
741
+ const target = event.target as HTMLElement;
742
+
743
+ const row = target.closest("tr");
744
+
745
+ if (!row) return;
746
+
747
+ const rowId = row.getAttribute("data-row-id");
748
+
749
+ if (!rowId) return;
750
+
751
+ const rowData = flatTableRows.value.find((r) => String(r.id) === rowId);
752
+
753
+ if (!rowData) return;
754
+
755
+ // Handle checkbox toggle via event delegation.
756
+ // When unchecking, UCheckbox.onIconClick() calls input.click() which dispatches
757
+ // a second (programmatic) click event with target=INPUT and isTrusted=false.
758
+ // Without this guard, onToggleRowCheckbox fires twice (deselect then reselect).
759
+ const checkboxCell = target.closest("td[data-checkbox-id]");
760
+
761
+ if (checkboxCell) {
762
+ if (target.tagName === "INPUT" && !event.isTrusted) return;
763
+
764
+ onToggleRowCheckbox(rowData);
765
+
766
+ return;
767
+ }
768
+
769
+ // Handle expand icon toggle via event delegation
770
+ const expandIconElement = target.closest("[data-expand-icon]");
771
+
772
+ if (expandIconElement) {
773
+ onToggleExpand(rowData);
774
+
775
+ return;
776
+ }
777
+
778
+ // Handle row click via event delegation
779
+ onClickRow(rowData);
780
+
781
+ // Handle cell click via event delegation
782
+ const cell = target.closest("td");
783
+
784
+ if (cell) {
785
+ const cellKey = cell.getAttribute("data-cell-key");
786
+
787
+ if (cellKey) {
788
+ const cellValue = rowData[cellKey];
789
+
790
+ onClickCell(cellValue, rowData, cellKey);
791
+ }
792
+ }
793
+ }
794
+
795
+ function onBodyDoubleClick(event: MouseEvent) {
796
+ const target = event.target as HTMLElement;
797
+
798
+ const row = target.closest("tr");
799
+
800
+ if (!row) return;
801
+
802
+ const rowId = row.getAttribute("data-row-id");
803
+
804
+ if (!rowId) return;
805
+
806
+ const rowData = flatTableRows.value.find((r) => String(r.id) === rowId);
807
+
808
+ if (!rowData) return;
809
+
810
+ const selection = window.getSelection();
811
+
812
+ if (selection) {
813
+ selection.removeAllRanges();
814
+ }
815
+
816
+ onDoubleClickRow(rowData);
817
+ }
818
+
475
819
  function onChangeSelectAll(selectAll: boolean) {
476
820
  if (selectAll && canSelectAll.value) {
477
821
  localSelectedRows.value = [...flatTableRows.value];
@@ -491,6 +835,7 @@ function onChangeLocalSelectedRows(selectedRows: Row[]) {
491
835
  nextTick(setHeaderCellWidth);
492
836
  }
493
837
 
838
+ // Set flag to prevent selectAll watcher from triggering
494
839
  selectAll.value = !!selectedRows.length;
495
840
 
496
841
  emit("update:selectedRows", localSelectedRows.value);
@@ -501,15 +846,15 @@ function clearSelectedItems() {
501
846
  }
502
847
 
503
848
  function onToggleExpand(row: Row) {
504
- const targetIndex = localExpandedRows.value.findIndex((expandedId) => expandedId === row.id);
849
+ const expanded = localExpandedRows.value;
505
850
 
506
- if (~targetIndex) {
507
- localExpandedRows.value = localExpandedRows.value.filter((expendedRow) => {
508
- return ![row.id, ...getRowChildrenIds(row)].includes(expendedRow);
509
- });
851
+ if (expandedRowsSet.value.has(row.id)) {
852
+ const idsToRemove = new Set([row.id, ...getRowChildrenIds(row)]);
853
+
854
+ localExpandedRows.value = expanded.filter((id) => !idsToRemove.has(id));
510
855
  emit("row-collapse", row);
511
856
  } else {
512
- localExpandedRows.value.push(row.id);
857
+ localExpandedRows.value = [...expanded, row.id];
513
858
  emit("row-expand", row);
514
859
  }
515
860
 
@@ -533,7 +878,9 @@ function isRowSelectedWithin(rowIndex: number) {
533
878
  function onToggleRowCheckbox(row: Row) {
534
879
  const targetIndex = localSelectedRows.value.findIndex((selectedRow) => selectedRow.id === row.id);
535
880
 
536
- ~targetIndex ? localSelectedRows.value.splice(targetIndex, 1) : localSelectedRows.value.push(row);
881
+ localSelectedRows.value = ~targetIndex
882
+ ? localSelectedRows.value.filter((_, i) => i !== targetIndex)
883
+ : [...localSelectedRows.value, row];
537
884
  }
538
885
 
539
886
  function getDateDividerConfig(row: Row, isSelected: boolean) {
@@ -550,7 +897,46 @@ function getDateDividerConfig(row: Row, isSelected: boolean) {
550
897
  function isRowSelected(row: Row | undefined) {
551
898
  if (!row) return false;
552
899
 
553
- return !!localSelectedRows.value.find((selectedRow) => selectedRow.id === row.id);
900
+ return selectedRowIds.value.has(row.id);
901
+ }
902
+
903
+ function getCellTextValue(cellValue: unknown): string {
904
+ if (cellValue == null) return "";
905
+
906
+ if (typeof cellValue === "object" && "value" in (cellValue as Record<string, unknown>)) {
907
+ const val = (cellValue as Record<string, unknown>).value;
908
+
909
+ return val != null ? String(val) : "";
910
+ }
911
+
912
+ return String(cellValue);
913
+ }
914
+
915
+ function getRowSearchMatchColumns(row: FlatRow): Set<string> | undefined {
916
+ return searchMatchColumnSets.value.get(row.id);
917
+ }
918
+
919
+ function getRowActiveSearchMatchColumn(row: FlatRow): string | undefined {
920
+ if (!activeMatch.value || activeMatch.value.rowId !== row.id) return undefined;
921
+
922
+ return activeMatch.value.columnKey;
923
+ }
924
+
925
+ function scrollToRow(rowId: RowId) {
926
+ if (!tableWrapperRef.value) return;
927
+
928
+ const targetRow = tableWrapperRef.value.querySelector<HTMLTableRowElement>(
929
+ `tr[data-row-id="${rowId}"]`,
930
+ );
931
+
932
+ if (!targetRow) return;
933
+
934
+ const containerRect = tableWrapperRef.value.getBoundingClientRect();
935
+ const rowRect = targetRow.getBoundingClientRect();
936
+
937
+ if (rowRect.top < containerRect.top || rowRect.bottom > containerRect.bottom) {
938
+ targetRow.scrollIntoView({ block: "center" });
939
+ }
554
940
  }
555
941
 
556
942
  defineExpose({
@@ -567,6 +953,23 @@ defineExpose({
567
953
  wrapperRef,
568
954
  });
569
955
 
956
+ /* Cached slot-content checks to avoid re-creating VNodes on every render. */
957
+ const hasBeforeHeaderSlot = computed(() => {
958
+ return hasSlotContent(slots["before-header"]);
959
+ });
960
+
961
+ const hasBeforeFirstRowSlot = computed(() => {
962
+ return hasSlotContent(slots["before-first-row"]);
963
+ });
964
+
965
+ const hasAfterLastRowSlot = computed(() => {
966
+ return hasSlotContent(slots["after-last-row"]);
967
+ });
968
+
969
+ const hasFooterSlot = computed(() => {
970
+ return hasSlotContent(slots["footer"]);
971
+ });
972
+
570
973
  /**
571
974
  * Get element / nested component attributes for each config token ✨
572
975
  * Applies: `class`, `config`, redefined default `props` and dev `vl-...` attributes.
@@ -629,7 +1032,93 @@ const {
629
1032
  headerCellStickyRightAttrs,
630
1033
  bodyCellStickyLeftAttrs,
631
1034
  bodyCellStickyRightAttrs,
1035
+ bodyCellSearchMatchAttrs,
1036
+ bodyCellSearchMatchTextAttrs,
1037
+ bodyCellSearchMatchActiveAttrs,
1038
+ bodyCellSearchMatchTextActiveAttrs,
632
1039
  } = useUI<Config>(defaultConfig, mutatedProps);
1040
+
1041
+ /* Plain object — inner refs are already reactive. */
1042
+ const tableRowAttrs = {
1043
+ bodyCellContentAttrs,
1044
+ bodyCellCheckboxAttrs,
1045
+ bodyCheckboxAttrs,
1046
+ bodyCellNestedAttrs,
1047
+ bodyCellNestedExpandIconAttrs,
1048
+ bodyCellNestedCollapseIconAttrs,
1049
+ bodyCellBaseAttrs,
1050
+ bodyCellNestedIconWrapperAttrs,
1051
+ bodyRowCheckedAttrs,
1052
+ bodyRowAttrs,
1053
+ bodyCellStickyLeftAttrs,
1054
+ bodyCellStickyRightAttrs,
1055
+ bodyCellSearchMatchAttrs,
1056
+ bodyCellSearchMatchTextAttrs,
1057
+ bodyCellSearchMatchActiveAttrs,
1058
+ bodyCellSearchMatchTextActiveAttrs,
1059
+ } as unknown as UTableRowAttrs;
1060
+
1061
+ function renderDateDividerRow(row: FlatRow, rowIndex: number): VNode | null {
1062
+ if (!isShownDateDivider(rowIndex) || !row.rowDate) return null;
1063
+
1064
+ const isSelected = isRowSelectedWithin(rowIndex);
1065
+
1066
+ const propsDateDivider = isSelected
1067
+ ? bodyRowCheckedDateDividerAttrs.value
1068
+ : bodyRowDateDividerAttrs.value;
1069
+
1070
+ const dividerNode = h(UDivider, {
1071
+ label: getDateDividerData(row.rowDate).label,
1072
+ ...(isSelected ? bodySelectedDateDividerAttrs.value : bodyDateDividerAttrs.value),
1073
+ config: getDateDividerConfig(row, isSelected),
1074
+ });
1075
+
1076
+ return h("tr", { ...propsDateDivider, key: `date-divider-${row.id}` }, [
1077
+ h(
1078
+ "td",
1079
+ {
1080
+ ...bodyCellDateDividerAttrs.value,
1081
+ colspan: colsCount.value,
1082
+ },
1083
+ [dividerNode],
1084
+ ),
1085
+ ]);
1086
+ }
1087
+
1088
+ function renderTableRow(row: FlatRow, rowIndex: number): VNode {
1089
+ return h(
1090
+ UTableRow,
1091
+ {
1092
+ key: row.id,
1093
+ selectable: props.selectable,
1094
+ rowIndex,
1095
+ row,
1096
+ columns: normalizedColumns.value,
1097
+ config: config.value,
1098
+ attrs: tableRowAttrs as unknown as UTableRowAttrs,
1099
+ colsCount: colsCount.value,
1100
+ nestedLevel: Number(row.nestedLevel || 0),
1101
+ emptyCellLabel: props.emptyCellLabel,
1102
+ "data-test": getDataTest("row"),
1103
+ "data-row-id": row.id,
1104
+ isExpanded: expandedRowsSet.value.has(row.id),
1105
+ isChecked: isRowSelected(row),
1106
+ isVisible: isRowVisible(row),
1107
+ columnPositions: columnPositions.value,
1108
+ search: props.search,
1109
+ searchMatchColumns: getRowSearchMatchColumns(row),
1110
+ activeSearchMatchColumn: getRowActiveSearchMatchColumn(row),
1111
+ textEllipsis: props.textEllipsis,
1112
+ } as unknown as UTableRowProps,
1113
+ slots,
1114
+ );
1115
+ }
1116
+
1117
+ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
1118
+ return [renderDateDividerRow(row, rowIndex), renderTableRow(row, rowIndex)].filter(
1119
+ Boolean,
1120
+ ) as VNode[];
1121
+ }
633
1122
  </script>
634
1123
 
635
1124
  <template>
@@ -670,10 +1159,10 @@ const {
670
1159
  >
671
1160
  <template v-if="hasSlotContent($slots[`header-${column.key}`], { column, index })">
672
1161
  <!--
673
- @slot Use it to customize needed header cell.
674
- @binding {object} column
675
- @binding {number} index
676
- -->
1162
+ @slot Use it to customize needed header cell.
1163
+ @binding {object} column
1164
+ @binding {number} index
1165
+ -->
677
1166
  <slot :name="`header-${column.key}`" :column="column" :index="index" />
678
1167
  </template>
679
1168
 
@@ -749,15 +1238,10 @@ const {
749
1238
  <ULoaderProgress :loading="loading" v-bind="stickyHeaderLoaderAttrs" />
750
1239
  </div>
751
1240
 
752
- <div ref="table-wrapper" v-bind="tableWrapperAttrs">
1241
+ <div ref="table-wrapper" v-bind="tableWrapperAttrs" @scroll="virtualScroll.onScroll">
753
1242
  <table v-bind="tableAttrs">
754
1243
  <thead v-bind="headerAttrs" :style="tableRowWidthStyle">
755
- <tr
756
- v-if="
757
- hasSlotContent($slots['before-header'], { colsCount, classes: headerRowAttrs.class })
758
- "
759
- v-bind="beforeHeaderRowAttrs"
760
- >
1244
+ <tr v-if="hasBeforeHeaderSlot" v-bind="beforeHeaderRowAttrs">
761
1245
  <!--
762
1246
  @slot Use it to add something before header row.
763
1247
  @binding {number} cols-count
@@ -824,9 +1308,14 @@ const {
824
1308
  <ULoaderProgress :loading="loading" v-bind="headerLoaderAttrs" />
825
1309
  </thead>
826
1310
 
827
- <tbody v-if="sortedRows.length" v-bind="bodyAttrs">
1311
+ <tbody
1312
+ v-if="sortedRows.length"
1313
+ v-bind="bodyAttrs"
1314
+ @click="onBodyClick"
1315
+ @dblclick="onBodyDoubleClick"
1316
+ >
828
1317
  <tr
829
- v-if="hasSlotContent($slots['before-first-row'], { colsCount })"
1318
+ v-if="hasBeforeFirstRowSlot"
830
1319
  v-bind="isRowSelected(sortedRows[0]) ? beforeBodyRowCheckedAttrs : beforeBodyRowAttrs"
831
1320
  >
832
1321
  <td :colspan="colsCount" v-bind="beforeBodyRowCellAttrs">
@@ -835,115 +1324,25 @@ const {
835
1324
  </td>
836
1325
  </tr>
837
1326
 
838
- <template
839
- v-for="(row, rowIndex) in sortedRows.filter(
840
- (row) => !row.parentRowId || localExpandedRows.includes(row.parentRowId),
841
- )"
842
- :key="row.id"
843
- >
844
- <tr
845
- v-if="isShownDateDivider(rowIndex) && !isRowSelectedWithin(rowIndex) && row.rowDate"
846
- v-bind="bodyRowDateDividerAttrs"
847
- >
848
- <td v-bind="bodyCellDateDividerAttrs" :colspan="colsCount">
849
- <UDivider
850
- :label="getDateDividerData(row.rowDate).label"
851
- v-bind="bodyDateDividerAttrs"
852
- :config="getDateDividerConfig(row, false)"
853
- />
854
- </td>
855
- </tr>
856
-
857
- <tr
858
- v-if="isShownDateDivider(rowIndex) && isRowSelectedWithin(rowIndex) && row.rowDate"
859
- v-bind="bodyRowCheckedDateDividerAttrs"
860
- >
861
- <td v-bind="bodyCellDateDividerAttrs" :colspan="colsCount">
862
- <UDivider
863
- :label="getDateDividerData(row.rowDate).label"
864
- v-bind="bodySelectedDateDividerAttrs"
865
- :config="getDateDividerConfig(row, true)"
866
- />
867
- </td>
868
- </tr>
869
-
870
- <UTableRow
871
- :selectable="selectable"
872
- :row="row"
873
- :columns="normalizedColumns"
874
- :config="config"
875
- :attrs="tableRowAttrs as unknown as UTableRowAttrs"
876
- :cols-count="colsCount"
877
- :nested-level="Number(row.nestedLevel || 0)"
878
- :empty-cell-label="emptyCellLabel"
879
- :data-test="getDataTest('row')"
880
- :is-expanded="localExpandedRows.includes(row.id)"
881
- :is-checked="isRowSelected(row)"
882
- :column-positions="columnPositions"
883
- @click="onClickRow"
884
- @dblclick="onDoubleClickRow"
885
- @click-cell="onClickCell"
886
- @toggle-expand="onToggleExpand"
887
- @toggle-checkbox="onToggleRowCheckbox"
888
- >
889
- <template
890
- v-for="(value, key, cellIndex) in mapRowColumns(row, normalizedColumns)"
891
- :key="`${rowIndex}-${cellIndex}`"
892
- #[`cell-${key}`]="{ value: cellValue, row: cellRow }"
893
- >
894
- <!--
895
- @slot Use it to customize needed table cell.
896
- @binding {string} value
897
- @binding {object} row
898
- @binding {number} index
899
- @binding {number} cellIndex
900
- -->
901
- <slot
902
- :name="`cell-${key}`"
903
- :value="cellValue"
904
- :row="cellRow"
905
- :index="rowIndex"
906
- :cell-index="cellIndex"
907
- />
908
- </template>
1327
+ <tr v-if="props.virtualScroll && virtualScroll.topSpacerHeight.value">
1328
+ <td
1329
+ :colspan="colsCount"
1330
+ :style="{ height: `${virtualScroll.topSpacerHeight.value}px` }"
1331
+ />
1332
+ </tr>
909
1333
 
910
- <template #expand="{ row: expandedRow, expanded }">
911
- <!--
912
- @slot Use it to customize row expand icon.
913
- @binding {object} row
914
- @binding {boolean} expanded
915
- @binding {number} index
916
- -->
917
- <slot name="expand" :index="rowIndex" :row="expandedRow" :expanded="expanded" />
918
- </template>
1334
+ <component
1335
+ :is="() => renderedRows.map((row, rowIndex) => renderRowTemplate(row, rowIndex)).flat()"
1336
+ />
919
1337
 
920
- <template #nested-row>
921
- <!--
922
- @slot Use it to add inside nested row.
923
- @binding {object} row
924
- @binding {number} index
925
- @binding {number} nestedLevel
926
- -->
927
- <slot
928
- v-if="row"
929
- name="nested-row"
930
- :index="rowIndex"
931
- :row="row"
932
- :nested-level="Number(row.nestedLevel || 0)"
933
- />
934
- </template>
935
- </UTableRow>
936
- </template>
1338
+ <tr v-if="props.virtualScroll && virtualScroll.bottomSpacerHeight.value > 0">
1339
+ <td
1340
+ :colspan="colsCount"
1341
+ :style="{ height: `${virtualScroll.bottomSpacerHeight.value}px` }"
1342
+ />
1343
+ </tr>
937
1344
 
938
- <tr
939
- v-if="
940
- hasSlotContent($slots['after-last-row'], {
941
- colsCount,
942
- classes: bodyCellBaseAttrs.class,
943
- })
944
- "
945
- v-bind="afterBodyRowAttrs"
946
- >
1345
+ <tr v-if="hasAfterLastRowSlot" v-bind="afterBodyRowAttrs">
947
1346
  <!--
948
1347
  @slot Use it to add something after last row.
949
1348
  @binding {number} cols-count
@@ -973,7 +1372,7 @@ const {
973
1372
  </tr>
974
1373
  </tbody>
975
1374
 
976
- <tfoot v-if="hasSlotContent($slots['footer'], { colsCount })" v-bind="footerAttrs">
1375
+ <tfoot v-if="hasFooterSlot" v-bind="footerAttrs">
977
1376
  <tr ref="footer-row" v-bind="footerRowAttrs">
978
1377
  <td v-if="selectable" />
979
1378