vueless 1.3.9-beta.9 → 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,36 +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";
22
18
  import { useVirtualScroll } from "../composables/useVirtualScroll";
19
+ import { useComponentLocaleMessages } from "../composables/useComponentLocaleMassages";
23
20
  import { getDefaults, cx, getMergedConfig } from "../utils/ui";
24
21
  import { hasSlotContent } from "../utils/helper";
25
- import { useComponentLocaleMessages } from "../composables/useComponentLocaleMassages";
26
-
27
- import defaultConfig from "./config";
28
- import { normalizeColumns, mapRowColumns, getFlatRows, getRowChildrenIds } from "./utilTable";
29
22
 
30
23
  import { PX_IN_REM } from "../constants";
31
24
  import { COMPONENT_NAME } from "./constants";
32
25
 
33
- 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";
34
37
  import type { Config as UDividerConfig } from "../ui.container-divider/types";
35
38
  import type {
36
39
  Cell,
@@ -42,8 +45,9 @@ import type {
42
45
  DateDivider,
43
46
  FlatRow,
44
47
  ColumnObject,
48
+ SearchMatch,
49
+ UTableRowProps,
45
50
  } from "./types";
46
- import { StickySide } from "./types";
47
51
 
48
52
  defineOptions({ inheritAttrs: false });
49
53
 
@@ -98,6 +102,12 @@ const emit = defineEmits([
98
102
  * @property {array} rowId
99
103
  */
100
104
  "update:expandedRows",
105
+
106
+ /**
107
+ * Triggers when search matches are found.
108
+ * @property {number} totalMatches
109
+ */
110
+ "search",
101
111
  ]);
102
112
 
103
113
  const slots = useSlots();
@@ -123,35 +133,29 @@ const { localeMessages } = useComponentLocaleMessages<typeof defaultConfig.i18n>
123
133
  props?.config?.i18n,
124
134
  );
125
135
 
126
- const localSelectedRows = ref<Row[]>([]);
127
- const localExpandedRows = ref<RowId[]>([]);
136
+ const localSelectedRows = shallowRef<Row[]>([]);
137
+ const localExpandedRows = shallowRef<RowId[]>([]);
138
+
139
+ const expandedRowsSet = computed(() => new Set(localExpandedRows.value));
128
140
 
129
141
  const sortedRows: ComputedRef<FlatRow[]> = computed(() => {
130
142
  const headerKeys = props.columns.map((column) =>
131
143
  typeof column === "object" ? column.key : column,
132
144
  );
133
145
 
134
- return flatTableRows.value.map((row) => {
135
- const rowEntries = Object.entries(row);
136
-
137
- const sortedEntries: typeof rowEntries = new Array(rowEntries.length);
146
+ const keyOrder = new Map(headerKeys.map((key, i) => [key, i]));
138
147
 
139
- rowEntries.forEach((entry) => {
140
- const [key] = entry;
141
- const headerIndex = headerKeys.indexOf(key);
142
-
143
- if (!~headerIndex) {
144
- sortedEntries.push(entry);
148
+ return flatTableRows.value.map((row) => {
149
+ const entries = Object.entries(row);
145
150
 
146
- return;
147
- }
151
+ entries.sort((a, b) => {
152
+ const aIdx = keyOrder.get(a[0]) ?? Infinity;
153
+ const bIdx = keyOrder.get(b[0]) ?? Infinity;
148
154
 
149
- sortedEntries[headerIndex] = entry;
155
+ return aIdx - bIdx;
150
156
  });
151
157
 
152
- const sortedRow = Object.fromEntries(sortedEntries.filter((value) => value));
153
-
154
- return sortedRow as FlatRow;
158
+ return Object.fromEntries(entries) as FlatRow;
155
159
  });
156
160
  });
157
161
 
@@ -169,7 +173,7 @@ const visibleColumns = computed(() => {
169
173
  return normalizedColumns.value.filter((column) => column.isShown !== false);
170
174
  });
171
175
 
172
- const columnPositions = ref<Map<string, number>>(new Map());
176
+ const columnPositions = shallowRef<Map<string, number>>(new Map());
173
177
 
174
178
  const colsCount = computed(() => {
175
179
  return normalizedColumns.value.length + 1;
@@ -184,11 +188,10 @@ const isShownActionsHeader = computed(() => {
184
188
  return hasSelectedRows && hasHeaderActions;
185
189
  });
186
190
 
187
- const isHeaderSticky = computed(() => {
188
- const positionForFixHeader =
189
- Number(headerRowRef.value?.getBoundingClientRect()?.top) + Number(window?.scrollY) || 0;
191
+ const headerOffsetTop = ref(0);
190
192
 
191
- return positionForFixHeader <= pagePositionY.value && props.stickyHeader;
193
+ const isHeaderSticky = computed(() => {
194
+ return headerOffsetTop.value <= pagePositionY.value && props.stickyHeader;
192
195
  });
193
196
 
194
197
  const isShownFooterPosition = computed(() => {
@@ -208,9 +211,9 @@ const tableRowWidthStyle = computed(() => ({ width: `${tableWidth.value / PX_IN_
208
211
  const flatTableRows = computed(() => getFlatRows(props.rows));
209
212
 
210
213
  const visibleFlatRows = computed(() => {
211
- return flatTableRows.value.filter(
212
- (row) => !row.parentRowId || localExpandedRows.value.includes(row.parentRowId),
213
- );
214
+ const expanded = expandedRowsSet.value;
215
+
216
+ return flatTableRows.value.filter((row) => !row.parentRowId || expanded.has(row.parentRowId));
214
217
  });
215
218
 
216
219
  const virtualScroll = useVirtualScroll({
@@ -221,58 +224,290 @@ const virtualScroll = useVirtualScroll({
221
224
  });
222
225
 
223
226
  const renderedRows = computed(() => {
224
- return props.virtualScroll
225
- ? visibleFlatRows.value.slice(virtualScroll.startIndex.value, virtualScroll.endIndex.value)
226
- : visibleFlatRows.value;
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));
227
334
  });
228
335
 
229
336
  const isSelectedAllRows = computed(() => {
230
337
  return localSelectedRows.value.length === flatTableRows.value.length;
231
338
  });
232
339
 
233
- const tableRowAttrs = computed(() => ({
234
- bodyCellContentAttrs,
235
- bodyCellCheckboxAttrs,
236
- bodyCheckboxAttrs,
237
- bodyCellNestedAttrs,
238
- bodyCellNestedExpandIconAttrs,
239
- bodyCellNestedCollapseIconAttrs,
240
- bodyCellBaseAttrs,
241
- bodyCellNestedIconWrapperAttrs,
242
- bodyRowCheckedAttrs,
243
- bodyRowAttrs,
244
- bodyCellStickyLeftAttrs,
245
- bodyCellStickyRightAttrs,
246
- }));
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
+ });
247
380
 
248
- watch(localSelectedRows, onChangeLocalSelectedRows, { deep: true });
249
- watch(() => props.selectedRows, onChangeSelectedRows, { deep: true, immediate: true });
250
- watch(() => props.expandedRows, onChangeExpandedRows, { deep: true, immediate: true });
251
- watch(selectAll, onChangeSelectAll);
252
- watch(isHeaderSticky, setHeaderCellWidth);
253
- watch(isFooterSticky, (newValue) =>
254
- newValue ? nextTick(setFooterCellWidth) : setFooterCellWidth(null),
255
- );
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
+ }
256
477
 
257
478
  onMounted(async () => {
258
479
  document.addEventListener("keyup", onKeyupEsc);
259
480
  document.addEventListener("scroll", onScroll, { passive: true });
260
- window.addEventListener("resize", onWindowResize);
261
481
 
262
482
  await nextTick();
483
+ updateHeaderOffsetTop();
263
484
  calculateStickyColumnPositions();
264
- });
265
485
 
266
- onUpdated(() => {
267
- tableHeight.value = Number(tableWrapperRef.value?.offsetHeight);
268
- tableWidth.value = Number(tableWrapperRef.value?.offsetWidth);
269
- 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
+ }
270
497
  });
271
498
 
272
499
  onBeforeUnmount(() => {
273
500
  document.removeEventListener("keyup", onKeyupEsc);
274
501
  document.removeEventListener("scroll", onScroll);
275
- 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
+ }
276
511
  });
277
512
 
278
513
  function onChangeSelectedRows() {
@@ -288,13 +523,18 @@ function onChangeExpandedRows() {
288
523
  }
289
524
 
290
525
  function onWindowResize() {
291
- tableWidth.value = tableWrapperRef.value?.offsetWidth || 0;
292
-
526
+ updateHeaderOffsetTop();
293
527
  setHeaderCellWidth();
294
528
  setFooterCellWidth();
295
529
  calculateStickyColumnPositions();
296
530
  }
297
531
 
532
+ function updateHeaderOffsetTop() {
533
+ if (headerRowRef.value) {
534
+ headerOffsetTop.value = headerRowRef.value.getBoundingClientRect().top + window.scrollY;
535
+ }
536
+ }
537
+
298
538
  function calculateStickyColumnPositions() {
299
539
  if (!headerRowRef.value) return;
300
540
 
@@ -457,7 +697,12 @@ function setHeaderCellWidth() {
457
697
  }
458
698
 
459
699
  function onScroll() {
460
- pagePositionY.value = Number(window?.scrollY);
700
+ if (scrollRafId !== null) return;
701
+
702
+ scrollRafId = requestAnimationFrame(() => {
703
+ pagePositionY.value = Number(window?.scrollY);
704
+ scrollRafId = null;
705
+ });
461
706
  }
462
707
 
463
708
  function onKeyupEsc(event: KeyboardEvent) {
@@ -492,6 +737,85 @@ function onClickCell(cell: Cell, row: Row, key: string | number) {
492
737
  emit("clickCell", cell, row, key);
493
738
  }
494
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
+
495
819
  function onChangeSelectAll(selectAll: boolean) {
496
820
  if (selectAll && canSelectAll.value) {
497
821
  localSelectedRows.value = [...flatTableRows.value];
@@ -511,6 +835,7 @@ function onChangeLocalSelectedRows(selectedRows: Row[]) {
511
835
  nextTick(setHeaderCellWidth);
512
836
  }
513
837
 
838
+ // Set flag to prevent selectAll watcher from triggering
514
839
  selectAll.value = !!selectedRows.length;
515
840
 
516
841
  emit("update:selectedRows", localSelectedRows.value);
@@ -521,15 +846,15 @@ function clearSelectedItems() {
521
846
  }
522
847
 
523
848
  function onToggleExpand(row: Row) {
524
- const targetIndex = localExpandedRows.value.findIndex((expandedId) => expandedId === row.id);
849
+ const expanded = localExpandedRows.value;
525
850
 
526
- if (~targetIndex) {
527
- localExpandedRows.value = localExpandedRows.value.filter((expendedRow) => {
528
- return ![row.id, ...getRowChildrenIds(row)].includes(expendedRow);
529
- });
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));
530
855
  emit("row-collapse", row);
531
856
  } else {
532
- localExpandedRows.value.push(row.id);
857
+ localExpandedRows.value = [...expanded, row.id];
533
858
  emit("row-expand", row);
534
859
  }
535
860
 
@@ -553,7 +878,9 @@ function isRowSelectedWithin(rowIndex: number) {
553
878
  function onToggleRowCheckbox(row: Row) {
554
879
  const targetIndex = localSelectedRows.value.findIndex((selectedRow) => selectedRow.id === row.id);
555
880
 
556
- ~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];
557
884
  }
558
885
 
559
886
  function getDateDividerConfig(row: Row, isSelected: boolean) {
@@ -570,7 +897,46 @@ function getDateDividerConfig(row: Row, isSelected: boolean) {
570
897
  function isRowSelected(row: Row | undefined) {
571
898
  if (!row) return false;
572
899
 
573
- 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
+ }
574
940
  }
575
941
 
576
942
  defineExpose({
@@ -587,6 +953,23 @@ defineExpose({
587
953
  wrapperRef,
588
954
  });
589
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
+
590
973
  /**
591
974
  * Get element / nested component attributes for each config token ✨
592
975
  * Applies: `class`, `config`, redefined default `props` and dev `vl-...` attributes.
@@ -649,7 +1032,93 @@ const {
649
1032
  headerCellStickyRightAttrs,
650
1033
  bodyCellStickyLeftAttrs,
651
1034
  bodyCellStickyRightAttrs,
1035
+ bodyCellSearchMatchAttrs,
1036
+ bodyCellSearchMatchTextAttrs,
1037
+ bodyCellSearchMatchActiveAttrs,
1038
+ bodyCellSearchMatchTextActiveAttrs,
652
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
+ }
653
1122
  </script>
654
1123
 
655
1124
  <template>
@@ -690,10 +1159,10 @@ const {
690
1159
  >
691
1160
  <template v-if="hasSlotContent($slots[`header-${column.key}`], { column, index })">
692
1161
  <!--
693
- @slot Use it to customize needed header cell.
694
- @binding {object} column
695
- @binding {number} index
696
- -->
1162
+ @slot Use it to customize needed header cell.
1163
+ @binding {object} column
1164
+ @binding {number} index
1165
+ -->
697
1166
  <slot :name="`header-${column.key}`" :column="column" :index="index" />
698
1167
  </template>
699
1168
 
@@ -772,12 +1241,7 @@ const {
772
1241
  <div ref="table-wrapper" v-bind="tableWrapperAttrs" @scroll="virtualScroll.onScroll">
773
1242
  <table v-bind="tableAttrs">
774
1243
  <thead v-bind="headerAttrs" :style="tableRowWidthStyle">
775
- <tr
776
- v-if="
777
- hasSlotContent($slots['before-header'], { colsCount, classes: headerRowAttrs.class })
778
- "
779
- v-bind="beforeHeaderRowAttrs"
780
- >
1244
+ <tr v-if="hasBeforeHeaderSlot" v-bind="beforeHeaderRowAttrs">
781
1245
  <!--
782
1246
  @slot Use it to add something before header row.
783
1247
  @binding {number} cols-count
@@ -844,9 +1308,14 @@ const {
844
1308
  <ULoaderProgress :loading="loading" v-bind="headerLoaderAttrs" />
845
1309
  </thead>
846
1310
 
847
- <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
+ >
848
1317
  <tr
849
- v-if="hasSlotContent($slots['before-first-row'], { colsCount })"
1318
+ v-if="hasBeforeFirstRowSlot"
850
1319
  v-bind="isRowSelected(sortedRows[0]) ? beforeBodyRowCheckedAttrs : beforeBodyRowAttrs"
851
1320
  >
852
1321
  <td :colspan="colsCount" v-bind="beforeBodyRowCellAttrs">
@@ -862,100 +1331,9 @@ const {
862
1331
  />
863
1332
  </tr>
864
1333
 
865
- <template v-for="(row, rowIndex) in renderedRows" :key="row.id">
866
- <tr
867
- v-if="isShownDateDivider(rowIndex) && !isRowSelectedWithin(rowIndex) && row.rowDate"
868
- v-bind="bodyRowDateDividerAttrs"
869
- >
870
- <td v-bind="bodyCellDateDividerAttrs" :colspan="colsCount">
871
- <UDivider
872
- :label="getDateDividerData(row.rowDate).label"
873
- v-bind="bodyDateDividerAttrs"
874
- :config="getDateDividerConfig(row, false)"
875
- />
876
- </td>
877
- </tr>
878
-
879
- <tr
880
- v-if="isShownDateDivider(rowIndex) && isRowSelectedWithin(rowIndex) && row.rowDate"
881
- v-bind="bodyRowCheckedDateDividerAttrs"
882
- >
883
- <td v-bind="bodyCellDateDividerAttrs" :colspan="colsCount">
884
- <UDivider
885
- :label="getDateDividerData(row.rowDate).label"
886
- v-bind="bodySelectedDateDividerAttrs"
887
- :config="getDateDividerConfig(row, true)"
888
- />
889
- </td>
890
- </tr>
891
-
892
- <UTableRow
893
- :selectable="selectable"
894
- :row="row"
895
- :columns="normalizedColumns"
896
- :config="config"
897
- :attrs="tableRowAttrs as unknown as UTableRowAttrs"
898
- :cols-count="colsCount"
899
- :nested-level="Number(row.nestedLevel || 0)"
900
- :empty-cell-label="emptyCellLabel"
901
- :data-test="getDataTest('row')"
902
- :is-expanded="localExpandedRows.includes(row.id)"
903
- :is-checked="isRowSelected(row)"
904
- :column-positions="columnPositions"
905
- @click="onClickRow"
906
- @dblclick="onDoubleClickRow"
907
- @click-cell="onClickCell"
908
- @toggle-expand="onToggleExpand"
909
- @toggle-checkbox="onToggleRowCheckbox"
910
- >
911
- <template
912
- v-for="(value, key, cellIndex) in mapRowColumns(row, normalizedColumns)"
913
- :key="`${rowIndex}-${cellIndex}`"
914
- #[`cell-${key}`]="{ value: cellValue, row: cellRow }"
915
- >
916
- <!--
917
- @slot Use it to customize needed table cell.
918
- @binding {string} value
919
- @binding {object} row
920
- @binding {number} index
921
- @binding {number} cellIndex
922
- -->
923
- <slot
924
- :name="`cell-${key}`"
925
- :value="cellValue"
926
- :row="cellRow"
927
- :index="rowIndex"
928
- :cell-index="cellIndex"
929
- />
930
- </template>
931
-
932
- <template #expand="{ row: expandedRow, expanded }">
933
- <!--
934
- @slot Use it to customize row expand icon.
935
- @binding {object} row
936
- @binding {boolean} expanded
937
- @binding {number} index
938
- -->
939
- <slot name="expand" :index="rowIndex" :row="expandedRow" :expanded="expanded" />
940
- </template>
941
-
942
- <template #nested-row>
943
- <!--
944
- @slot Use it to add inside nested row.
945
- @binding {object} row
946
- @binding {number} index
947
- @binding {number} nestedLevel
948
- -->
949
- <slot
950
- v-if="row"
951
- name="nested-row"
952
- :index="rowIndex"
953
- :row="row"
954
- :nested-level="Number(row.nestedLevel || 0)"
955
- />
956
- </template>
957
- </UTableRow>
958
- </template>
1334
+ <component
1335
+ :is="() => renderedRows.map((row, rowIndex) => renderRowTemplate(row, rowIndex)).flat()"
1336
+ />
959
1337
 
960
1338
  <tr v-if="props.virtualScroll && virtualScroll.bottomSpacerHeight.value > 0">
961
1339
  <td
@@ -964,15 +1342,7 @@ const {
964
1342
  />
965
1343
  </tr>
966
1344
 
967
- <tr
968
- v-if="
969
- hasSlotContent($slots['after-last-row'], {
970
- colsCount,
971
- classes: bodyCellBaseAttrs.class,
972
- })
973
- "
974
- v-bind="afterBodyRowAttrs"
975
- >
1345
+ <tr v-if="hasAfterLastRowSlot" v-bind="afterBodyRowAttrs">
976
1346
  <!--
977
1347
  @slot Use it to add something after last row.
978
1348
  @binding {number} cols-count
@@ -1002,7 +1372,7 @@ const {
1002
1372
  </tr>
1003
1373
  </tbody>
1004
1374
 
1005
- <tfoot v-if="hasSlotContent($slots['footer'], { colsCount })" v-bind="footerAttrs">
1375
+ <tfoot v-if="hasFooterSlot" v-bind="footerAttrs">
1006
1376
  <tr ref="footer-row" v-bind="footerRowAttrs">
1007
1377
  <td v-if="selectable" />
1008
1378