vueless 1.4.12-beta.1 → 1.4.12-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -228,6 +228,16 @@ export function useUI<T>(defaultConfig: T, mutatedProps?: MutatedProps, topLevel
228
228
  const keysAttrs: KeysAttrs<T> = {};
229
229
  const attrsRefs: Record<string, Ref<KeyAttrs>> = {};
230
230
 
231
+ /**
232
+ * Structural signature per key. The watcher below fires on any config/prop/class
233
+ * change and would otherwise re-mint every key's attrs object — even keys whose
234
+ * classes are identical — invalidating downstream reactive readers (e.g. every
235
+ * body row of a table on an unrelated sticky toggle). We skip the `.value` write
236
+ * when the new object is structurally equal to the last one, so the ref keeps its
237
+ * identity and dependents are not re-rendered.
238
+ */
239
+ const attrsSignatures: Record<string, string> = {};
240
+
231
241
  for (const key in config.value) {
232
242
  if (isSystemKey(key)) continue;
233
243
 
@@ -266,12 +276,20 @@ export function useUI<T>(defaultConfig: T, mutatedProps?: MutatedProps, topLevel
266
276
  /* Delete value key to prevent v-model overwrite. */
267
277
  delete commonAttrs.value;
268
278
 
269
- attrsRefs[key].value = {
279
+ const nextValue: KeyAttrs = {
270
280
  ...commonAttrs,
271
281
  class: cx([...data.extendsClasses, classes, commonAttrs.class]),
272
282
  config: data.mergedNestedConfig,
273
283
  ...data.mergedDefaults,
274
284
  };
285
+
286
+ /* Keep the previous ref identity when nothing changed — see attrsSignatures. */
287
+ const signature = JSON.stringify(nextValue);
288
+
289
+ if (attrsSignatures[key] === signature) continue;
290
+
291
+ attrsSignatures[key] = signature;
292
+ attrsRefs[key].value = nextValue;
275
293
  }
276
294
  },
277
295
  { immediate: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vueless",
3
- "version": "1.4.12-beta.1",
3
+ "version": "1.4.12-beta.11",
4
4
  "description": "Vue Styleless UI Component Library, powered by Tailwind CSS.",
5
5
  "author": "Johnny Grid <hello@vueless.com> (https://vueless.com)",
6
6
  "homepage": "https://vueless.com",
@@ -59,7 +59,7 @@
59
59
  "@vue/eslint-config-typescript": "^14.7.0",
60
60
  "@vue/test-utils": "^2.4.8",
61
61
  "@vue/tsconfig": "^0.9.1",
62
- "@vueless/storybook": "^1.7.0",
62
+ "@vueless/storybook": "^1.7.9",
63
63
  "eslint": "^10.2.1",
64
64
  "eslint-plugin-storybook": "^10.3.5",
65
65
  "eslint-plugin-tailwindcss": "^4.0.0-alpha.7",
@@ -9,11 +9,6 @@ import type { Props } from "../types";
9
9
  describe("UDrawer", () => {
10
10
  const modelValue = true;
11
11
 
12
- // Wait for an async component to load
13
- function sleep(ms: number = 0) {
14
- return new Promise((resolve) => setTimeout(resolve, ms));
15
- }
16
-
17
12
  // Props tests
18
13
  describe("Props", () => {
19
14
  it("ModelValue – renders when true", () => {
@@ -150,10 +145,10 @@ describe("UDrawer", () => {
150
145
  expect(innerWrapper.attributes("class")).toContain(expectedClass);
151
146
  });
152
147
 
153
- it("CloseOnOverlay – closes drawer when overlay is clicked", () => {
148
+ it("CloseOnOverlay – closes drawer when overlay is clicked", async () => {
154
149
  const closeOnOverlay = [true, false];
155
150
 
156
- closeOnOverlay.forEach(async (value) => {
151
+ for (const value of closeOnOverlay) {
157
152
  const component = mount(UDrawer, {
158
153
  props: {
159
154
  modelValue,
@@ -167,18 +162,17 @@ describe("UDrawer", () => {
167
162
 
168
163
  await innerWrapper.trigger("mousedown");
169
164
  await innerWrapper.trigger("click");
170
- await sleep(1000);
171
165
 
172
- const drawer = component.find('[vl-key="drawer"]');
166
+ const closeRequests = component.emitted("update:modelValue");
173
167
 
174
- expect(drawer.exists()).toBe(!value);
175
- });
168
+ expect(value ? closeRequests?.at(-1) : closeRequests).toEqual(value ? [false] : undefined);
169
+ }
176
170
  });
177
171
 
178
- it("CloseOnEsc – closes drawer when escape key is pressed", () => {
172
+ it("CloseOnEsc – closes drawer when escape key is pressed", async () => {
179
173
  const closeOnEsc = [true, false];
180
174
 
181
- closeOnEsc.forEach(async (value) => {
175
+ for (const value of closeOnEsc) {
182
176
  const component = mount(UDrawer, {
183
177
  props: {
184
178
  modelValue,
@@ -189,12 +183,11 @@ describe("UDrawer", () => {
189
183
  const wrapper = component.find("[vl-key='wrapper']");
190
184
 
191
185
  await wrapper.trigger("keydown", { key: "Escape" });
192
- await sleep(1000);
193
186
 
194
- const drawer = component.find('[vl-key="drawer"]');
187
+ const closeRequests = component.emitted("update:modelValue");
195
188
 
196
- expect(drawer.exists()).toBe(!value);
197
- });
189
+ expect(value ? closeRequests?.at(-1) : closeRequests).toEqual(value ? [false] : undefined);
190
+ }
198
191
  });
199
192
 
200
193
  it("DataTest – applies the correct data-test attribute", () => {
@@ -34,11 +34,6 @@ const mountWithRouter = (component: unknown, options: UnknownObject) => {
34
34
  describe("UModal", () => {
35
35
  const modelValue = true;
36
36
 
37
- // Wait for an async component to load
38
- function sleep(ms: number = 0) {
39
- return new Promise((resolve) => setTimeout(resolve, ms));
40
- }
41
-
42
37
  // Props tests
43
38
  describe("Props", () => {
44
39
  // ModelValue prop
@@ -189,10 +184,10 @@ describe("UModal", () => {
189
184
  });
190
185
 
191
186
  // CloseOnOverlay prop
192
- it("renders with closeOnOverlay prop", () => {
187
+ it("renders with closeOnOverlay prop", async () => {
193
188
  const closeOnOverlay = [true, false];
194
189
 
195
- closeOnOverlay.forEach(async (value) => {
190
+ for (const value of closeOnOverlay) {
196
191
  const component = mount(UModal, {
197
192
  props: {
198
193
  modelValue,
@@ -206,19 +201,18 @@ describe("UModal", () => {
206
201
 
207
202
  await innerWrapper.trigger("mousedown");
208
203
  await innerWrapper.trigger("click");
209
- await sleep(1000);
210
204
 
211
- const modal = component.find('[vl-key="modal"]');
205
+ const closeRequests = component.emitted("update:modelValue");
212
206
 
213
- expect(modal.exists()).toBe(!value);
214
- });
207
+ expect(value ? closeRequests?.at(-1) : closeRequests).toEqual(value ? [false] : undefined);
208
+ }
215
209
  });
216
210
 
217
211
  // CloseOnEsc prop
218
- it("renders with closeOnEsc prop", () => {
212
+ it("renders with closeOnEsc prop", async () => {
219
213
  const closeOnEsc = [true, false];
220
214
 
221
- closeOnEsc.forEach(async (value) => {
215
+ for (const value of closeOnEsc) {
222
216
  const component = mount(UModal, {
223
217
  props: {
224
218
  modelValue,
@@ -229,12 +223,11 @@ describe("UModal", () => {
229
223
  const wrapper = component.find("[vl-key='wrapper']");
230
224
 
231
225
  await wrapper.trigger("keydown", { key: "Escape" });
232
- await sleep(1000);
233
226
 
234
- const modal = component.find('[vl-key="modal"]');
227
+ const closeRequests = component.emitted("update:modelValue");
235
228
 
236
- expect(modal.exists()).toBe(!value);
237
- });
229
+ expect(value ? closeRequests?.at(-1) : closeRequests).toEqual(value ? [false] : undefined);
230
+ }
238
231
  });
239
232
 
240
233
  // Inner prop
@@ -129,6 +129,9 @@ const {
129
129
  :animation="animationDuration"
130
130
  :ghost-class="config.draggableGhost"
131
131
  :drag-class="config.draggableDrag"
132
+ :force-fallback="forceFallback"
133
+ :fallback-on-body="fallbackOnBody"
134
+ :fallback-class="fallbackClass"
132
135
  v-bind="draggableAttrs"
133
136
  :data-test="getDataTest()"
134
137
  @end="onDragEnd"
@@ -2,7 +2,7 @@ export default /*tw*/ {
2
2
  wrapper: "",
3
3
  draggable: "",
4
4
  draggableDrag: "bg-lifted",
5
- draggableGhost: "bg-lifted/50",
5
+ draggableGhost: "data-list-ghost",
6
6
  itemWrapper: "group/item-wrapper",
7
7
  item: {
8
8
  base: `
@@ -55,6 +55,9 @@ export default /*tw*/ {
55
55
  labelKey: "label",
56
56
  valueKey: "value",
57
57
  animationDuration: 200,
58
+ forceFallback: false,
59
+ fallbackOnBody: false,
60
+ fallbackClass: "shadow-sm",
58
61
  /* icons */
59
62
  dragIcon: "drag_indicator",
60
63
  },
@@ -118,6 +118,22 @@ Nesting.args = {
118
118
  export const Sizes = EnumTemplate.bind({});
119
119
  Sizes.args = { enum: "size" };
120
120
 
121
+ export const ForceFallback = DefaultTemplate.bind({});
122
+ ForceFallback.args = {
123
+ forceFallback: true,
124
+ fallbackOnBody: true,
125
+ fallbackClass: "opacity-100!",
126
+ };
127
+ ForceFallback.parameters = {
128
+ docs: {
129
+ description: {
130
+ story:
131
+ // eslint-disable-next-line vue/max-len
132
+ "Use `forceFallback` to drag with Sortable's JS fallback instead of native HTML5 DnD (browsers force translucency on native drag images). Pair with `fallbackOnBody` when the list sits inside an `overflow: hidden` container, and `fallbackClass` to style the floating clone.",
133
+ },
134
+ },
135
+ };
136
+
121
137
  export const LabelSlot = DefaultTemplate.bind({});
122
138
  LabelSlot.args = {
123
139
  slotTemplate: `
@@ -162,6 +162,83 @@ describe("UDataList.vue", () => {
162
162
  expect(draggableComponent.vm.$attrs.animation).toBe(animationDuration);
163
163
  });
164
164
 
165
+ it("Force Fallback – passes forceFallback to draggable", () => {
166
+ const component = mount(UDataList, {
167
+ props: {
168
+ list: defaultList,
169
+ forceFallback: true,
170
+ },
171
+ });
172
+
173
+ const draggableComponent = component.findComponent(draggable);
174
+
175
+ expect(draggableComponent.vm.$attrs["force-fallback"]).toBe(true);
176
+ });
177
+
178
+ it("Force Fallback – defaults to false", () => {
179
+ const component = mount(UDataList, {
180
+ props: {
181
+ list: defaultList,
182
+ },
183
+ });
184
+
185
+ const draggableComponent = component.findComponent(draggable);
186
+
187
+ expect(draggableComponent.vm.$attrs["force-fallback"]).toBe(false);
188
+ });
189
+
190
+ it("Fallback On Body – passes fallbackOnBody to draggable", () => {
191
+ const component = mount(UDataList, {
192
+ props: {
193
+ list: defaultList,
194
+ fallbackOnBody: true,
195
+ },
196
+ });
197
+
198
+ const draggableComponent = component.findComponent(draggable);
199
+
200
+ expect(draggableComponent.vm.$attrs["fallback-on-body"]).toBe(true);
201
+ });
202
+
203
+ it("Fallback On Body – defaults to false", () => {
204
+ const component = mount(UDataList, {
205
+ props: {
206
+ list: defaultList,
207
+ },
208
+ });
209
+
210
+ const draggableComponent = component.findComponent(draggable);
211
+
212
+ expect(draggableComponent.vm.$attrs["fallback-on-body"]).toBe(false);
213
+ });
214
+
215
+ it("Fallback Class – passes fallbackClass to draggable", () => {
216
+ const fallbackClass = "custom-drag-fallback";
217
+
218
+ const component = mount(UDataList, {
219
+ props: {
220
+ list: defaultList,
221
+ fallbackClass,
222
+ },
223
+ });
224
+
225
+ const draggableComponent = component.findComponent(draggable);
226
+
227
+ expect(draggableComponent.vm.$attrs["fallback-class"]).toBe(fallbackClass);
228
+ });
229
+
230
+ it("Fallback Class – defaults to shadow-sm", () => {
231
+ const component = mount(UDataList, {
232
+ props: {
233
+ list: defaultList,
234
+ },
235
+ });
236
+
237
+ const draggableComponent = component.findComponent(draggable);
238
+
239
+ expect(draggableComponent.vm.$attrs["fallback-class"]).toBe("shadow-sm");
240
+ });
241
+
165
242
  it("Nesting – renders nested items when children array is present", async () => {
166
243
  const component = mount(UDataList, {
167
244
  props: {
@@ -49,6 +49,21 @@ export interface Props {
49
49
  */
50
50
  animationDuration?: number;
51
51
 
52
+ /**
53
+ * Sortable JS fallback (instead of native HTML5 DnD).
54
+ */
55
+ forceFallback?: boolean;
56
+
57
+ /**
58
+ * Append the fallback drag clone to document.body.
59
+ */
60
+ fallbackOnBody?: boolean;
61
+
62
+ /**
63
+ * CSS class for the Sortable fallback drag clone (forceFallback mode).
64
+ */
65
+ fallbackClass?: string;
66
+
52
67
  /**
53
68
  * Disable empty state for nested elements if empty (internal props).
54
69
  * @ignore
@@ -31,6 +31,7 @@ import UEmpty from "../ui.container-empty/UEmpty.vue";
31
31
  import UCheckbox from "../ui.form-checkbox/UCheckbox.vue";
32
32
  import ULoaderProgress from "../ui.loader-progress/ULoaderProgress.vue";
33
33
  import UDivider from "../ui.container-divider/UDivider.vue";
34
+ import USkeleton from "../ui.skeleton/USkeleton.vue";
34
35
  import UTableRow from "./UTableRow.vue";
35
36
 
36
37
  import type { ComputedRef, VNode } from "vue";
@@ -58,6 +59,7 @@ const props = withDefaults(defineProps<Props>(), {
58
59
  dateDivider: () => [],
59
60
  selectedRows: () => [],
60
61
  expandedRows: () => [],
62
+ skeletonWidths: () => defaultConfig.defaults.skeletonWidths,
61
63
  });
62
64
 
63
65
  const emit = defineEmits([
@@ -206,7 +208,9 @@ const isCheckedMoreOneTableItems = computed(() => {
206
208
  return Boolean(localSelectedRows.value.length);
207
209
  });
208
210
 
209
- const tableRowWidthStyle = computed(() => ({ width: `${tableWidth.value / PX_IN_REM}rem` }));
211
+ const tableRowWidthStyle = computed(() => ({
212
+ width: `${tableWidth.value / PX_IN_REM}rem`,
213
+ }));
210
214
 
211
215
  const flatTableRows = computed(() => getFlatRows(props.rows));
212
216
 
@@ -216,6 +220,18 @@ const visibleFlatRows = computed(() => {
216
220
  return flatTableRows.value.filter((row) => !row.parentRowId || expanded.has(row.parentRowId));
217
221
  });
218
222
 
223
+ const showSkeletonLoading = computed(() => {
224
+ return props.skeletonLoading && !sortedRows.value.length;
225
+ });
226
+
227
+ const progressLoading = computed(() => {
228
+ return showSkeletonLoading.value ? false : props.loading;
229
+ });
230
+
231
+ const skeletonRowIndexes = computed(() => {
232
+ return Array.from({ length: props.skeletonRows }, (_, index) => index);
233
+ });
234
+
219
235
  const virtualScroll = useVirtualScroll({
220
236
  containerRef: tableWrapperRef,
221
237
  totalCount: computed(() => (props.virtualScroll ? visibleFlatRows.value.length : 0)),
@@ -243,6 +259,10 @@ function isRowVisible(row: FlatRow): boolean {
243
259
  return !row.parentRowId || expandedRowsSet.value.has(row.parentRowId);
244
260
  }
245
261
 
262
+ function getSkeletonWidthClass(columnIndex: number, rowIndex: number): string {
263
+ return props.skeletonWidths[(columnIndex + rowIndex) % props.skeletonWidths.length];
264
+ }
265
+
246
266
  const searchMatches = computed<SearchMatch[]>(() => {
247
267
  const query = props.search?.toLowerCase();
248
268
 
@@ -663,7 +683,9 @@ function getDateDividerData(rowDate: string | Date | undefined) {
663
683
  function setFooterCellWidth(zero?: null) {
664
684
  const ZERO_WIDTH = 0;
665
685
 
666
- if (!props.stickyFooter || !footerRowRef.value || !stickyFooterRowRef.value) return;
686
+ if (!props.stickyFooter || !footerRowRef.value || !stickyFooterRowRef.value) {
687
+ return;
688
+ }
667
689
 
668
690
  const mainFooterItems = [...footerRowRef.value.children] as HTMLElement[];
669
691
  const stickyFooterItems = [...stickyFooterRowRef.value.children] as HTMLElement[];
@@ -816,6 +838,50 @@ function onBodyDoubleClick(event: MouseEvent) {
816
838
  onDoubleClickRow(rowData);
817
839
  }
818
840
 
841
+ // Detect double-tap on touch devices and dispatch a native `dblclick`,
842
+ // which bubbles to the tbody and triggers onBodyDoubleClick.
843
+ const DOUBLE_TAP_DELAY = 300;
844
+ const DOUBLE_TAP_MAX_MOVE = 24;
845
+
846
+ let lastTapTime = 0;
847
+ let lastTapRow: Element | null = null;
848
+ let lastTapX = 0;
849
+ let lastTapY = 0;
850
+
851
+ function onBodyTouchEnd(event: TouchEvent) {
852
+ if (event.changedTouches.length !== 1) return;
853
+
854
+ const [touch] = event.changedTouches;
855
+ const target = touch.target as HTMLElement | null;
856
+ const row = target?.closest("tr[data-row-id]") ?? null;
857
+
858
+ if (!row) {
859
+ lastTapRow = null;
860
+
861
+ return;
862
+ }
863
+
864
+ const now = event.timeStamp;
865
+ const isSameRow = row === lastTapRow;
866
+ const isQuick = now - lastTapTime < DOUBLE_TAP_DELAY;
867
+ const isNear =
868
+ Math.abs(touch.clientX - lastTapX) < DOUBLE_TAP_MAX_MOVE &&
869
+ Math.abs(touch.clientY - lastTapY) < DOUBLE_TAP_MAX_MOVE;
870
+
871
+ if (isSameRow && isQuick && isNear) {
872
+ lastTapTime = 0;
873
+ lastTapRow = null;
874
+ row.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true }));
875
+
876
+ return;
877
+ }
878
+
879
+ lastTapTime = now;
880
+ lastTapRow = row;
881
+ lastTapX = touch.clientX;
882
+ lastTapY = touch.clientY;
883
+ }
884
+
819
885
  function onChangeSelectAll(selectAll: boolean) {
820
886
  if (selectAll && canSelectAll.value) {
821
887
  localSelectedRows.value = [...flatTableRows.value];
@@ -889,7 +955,7 @@ function getDateDividerConfig(row: Row, isSelected: boolean) {
889
955
  : bodyDateDividerAttrs.value.config;
890
956
 
891
957
  return getMergedConfig({
892
- defaultConfig: defaultConfig,
958
+ defaultConfig,
893
959
  globalConfig: getDateDividerData(row.rowDate).config,
894
960
  }) as UDividerConfig;
895
961
  }
@@ -917,7 +983,9 @@ function getRowSearchMatchColumns(row: FlatRow): Set<string> | undefined {
917
983
  }
918
984
 
919
985
  function getRowActiveSearchMatchColumn(row: FlatRow): string | undefined {
920
- if (!activeMatch.value || activeMatch.value.rowId !== row.id) return undefined;
986
+ if (!activeMatch.value || activeMatch.value.rowId !== row.id) {
987
+ return;
988
+ }
921
989
 
922
990
  return activeMatch.value.columnKey;
923
991
  }
@@ -1036,9 +1104,11 @@ const {
1036
1104
  bodyCellSearchMatchTextAttrs,
1037
1105
  bodyCellSearchMatchActiveAttrs,
1038
1106
  bodyCellSearchMatchTextActiveAttrs,
1107
+ skeletonCellAttrs,
1108
+ skeletonCheckboxAttrs,
1039
1109
  } = useUI<Config>(defaultConfig, mutatedProps);
1040
1110
 
1041
- /* Plain object — inner refs are already reactive. */
1111
+ /* Plain object — inner refs are already reactive and identity-stable (see useUI). */
1042
1112
  const tableRowAttrs = {
1043
1113
  bodyCellContentAttrs,
1044
1114
  bodyCellCheckboxAttrs,
@@ -1085,8 +1155,40 @@ function renderDateDividerRow(row: FlatRow, rowIndex: number): VNode | null {
1085
1155
  ]);
1086
1156
  }
1087
1157
 
1158
+ /**
1159
+ * Per-row VNode memo cache. Toggling one checkbox invalidates the `selectedRowIds`
1160
+ * computed, which re-runs the body render function. Without memoization every row's
1161
+ * VNode would be rebuilt and diffed on each toggle (200+ rows → visible lag). We cache
1162
+ * each row's VNode keyed by row id and only rebuild it when an input that actually
1163
+ * affects that row changes — so an unrelated row keeps the same VNode reference and
1164
+ * Vue skips it entirely during patch.
1165
+ */
1166
+ const rowVNodeCache = new Map<RowId, { signature: string; row: FlatRow; vnode: VNode }>();
1167
+
1168
+ function getRowSignature(row: FlatRow, rowIndex: number): string {
1169
+ return [
1170
+ rowIndex,
1171
+ Number(isRowSelected(row)),
1172
+ Number(expandedRowsSet.value.has(row.id)),
1173
+ Number(isRowVisible(row)),
1174
+ props.selectable ? 1 : 0,
1175
+ props.search || "",
1176
+ getRowActiveSearchMatchColumn(row) || "",
1177
+ [...(getRowSearchMatchColumns(row) || [])].join(","),
1178
+ ].join("|");
1179
+ }
1180
+
1088
1181
  function renderTableRow(row: FlatRow, rowIndex: number): VNode {
1089
- return h(
1182
+ const signature = getRowSignature(row, rowIndex);
1183
+ const cached = rowVNodeCache.get(row.id);
1184
+
1185
+ // `row` identity guards against stale data: `flatTableRows` yields fresh row
1186
+ // objects whenever `props.rows` changes, so a new reference means new cell data.
1187
+ if (cached && cached.row === row && cached.signature === signature) {
1188
+ return cached.vnode;
1189
+ }
1190
+
1191
+ const vnode = h(
1090
1192
  UTableRow,
1091
1193
  {
1092
1194
  key: row.id,
@@ -1112,13 +1214,48 @@ function renderTableRow(row: FlatRow, rowIndex: number): VNode {
1112
1214
  } as unknown as UTableRowProps,
1113
1215
  slots,
1114
1216
  );
1217
+
1218
+ rowVNodeCache.set(row.id, { signature, row, vnode });
1219
+
1220
+ return vnode;
1115
1221
  }
1116
1222
 
1223
+ /* Column layout / config changes affect every row — invalidate the whole cache. */
1224
+ watch(
1225
+ [
1226
+ normalizedColumns,
1227
+ config,
1228
+ columnPositions,
1229
+ () => props.textEllipsis,
1230
+ () => props.emptyCellLabel,
1231
+ ],
1232
+ () => rowVNodeCache.clear(),
1233
+ );
1234
+
1235
+ /* Drop cache entries for rows that no longer exist (filters, pagination reset). */
1236
+ watch(flatTableRows, (rows) => {
1237
+ const liveIds = new Set(rows.map((row) => row.id));
1238
+
1239
+ for (const id of rowVNodeCache.keys()) {
1240
+ if (!liveIds.has(id)) rowVNodeCache.delete(id);
1241
+ }
1242
+ });
1243
+
1117
1244
  function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
1118
1245
  return [renderDateDividerRow(row, rowIndex), renderTableRow(row, rowIndex)].filter(
1119
1246
  Boolean,
1120
1247
  ) as VNode[];
1121
1248
  }
1249
+
1250
+ /**
1251
+ * Stable functional component for the body rows. Defined once so its type
1252
+ * identity never changes across parent re-renders — a previous inline `:is`
1253
+ * arrow created a new type on every render, forcing Vue to unmount and rebuild
1254
+ * the entire tbody (e.g. on a sticky-header toggle). Reading `renderedRows`
1255
+ * through the closure keeps it reactive while row keys drive reconciliation.
1256
+ */
1257
+ const BodyRows = () =>
1258
+ renderedRows.value.map((row, rowIndex) => renderRowTemplate(row, rowIndex)).flat();
1122
1259
  </script>
1123
1260
 
1124
1261
  <template>
@@ -1171,7 +1308,7 @@ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
1171
1308
  </template>
1172
1309
  </div>
1173
1310
 
1174
- <ULoaderProgress :loading="loading" v-bind="stickyHeaderLoaderAttrs" />
1311
+ <ULoaderProgress :loading="progressLoading" v-bind="stickyHeaderLoaderAttrs" />
1175
1312
  </div>
1176
1313
 
1177
1314
  <div
@@ -1203,7 +1340,7 @@ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
1203
1340
  -->
1204
1341
  <slot name="header-actions" :selected-rows="localSelectedRows" />
1205
1342
 
1206
- <ULoaderProgress :loading="loading" v-bind="stickyHeaderLoaderAttrs" />
1343
+ <ULoaderProgress :loading="progressLoading" v-bind="stickyHeaderLoaderAttrs" />
1207
1344
  </div>
1208
1345
 
1209
1346
  <div
@@ -1235,7 +1372,7 @@ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
1235
1372
  -->
1236
1373
  <slot name="header-actions" :selected-rows="localSelectedRows" />
1237
1374
 
1238
- <ULoaderProgress :loading="loading" v-bind="stickyHeaderLoaderAttrs" />
1375
+ <ULoaderProgress :loading="progressLoading" v-bind="stickyHeaderLoaderAttrs" />
1239
1376
  </div>
1240
1377
 
1241
1378
  <div ref="table-wrapper" v-bind="tableWrapperAttrs" @scroll="virtualScroll.onScroll">
@@ -1293,7 +1430,12 @@ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
1293
1430
  @binding {number} index
1294
1431
  -->
1295
1432
  <slot
1296
- v-if="hasSlotContent($slots[`header-${column.key}`], { column, index })"
1433
+ v-if="
1434
+ hasSlotContent($slots[`header-${column.key}`], {
1435
+ column,
1436
+ index,
1437
+ })
1438
+ "
1297
1439
  :name="`header-${column.key}`"
1298
1440
  :column="column"
1299
1441
  :index="index"
@@ -1305,7 +1447,7 @@ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
1305
1447
  </th>
1306
1448
  </tr>
1307
1449
 
1308
- <ULoaderProgress :loading="loading" v-bind="headerLoaderAttrs" />
1450
+ <ULoaderProgress :loading="progressLoading" v-bind="headerLoaderAttrs" />
1309
1451
  </thead>
1310
1452
 
1311
1453
  <tbody
@@ -1313,6 +1455,7 @@ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
1313
1455
  v-bind="bodyAttrs"
1314
1456
  @click="onBodyClick"
1315
1457
  @dblclick="onBodyDoubleClick"
1458
+ @touchend="onBodyTouchEnd"
1316
1459
  >
1317
1460
  <tr
1318
1461
  v-if="hasBeforeFirstRowSlot"
@@ -1331,9 +1474,7 @@ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
1331
1474
  />
1332
1475
  </tr>
1333
1476
 
1334
- <component
1335
- :is="() => renderedRows.map((row, rowIndex) => renderRowTemplate(row, rowIndex)).flat()"
1336
- />
1477
+ <component :is="BodyRows" />
1337
1478
 
1338
1479
  <tr v-if="props.virtualScroll && virtualScroll.bottomSpacerHeight.value > 0">
1339
1480
  <td
@@ -1356,6 +1497,30 @@ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
1356
1497
  </tr>
1357
1498
  </tbody>
1358
1499
 
1500
+ <tbody v-else-if="showSkeletonLoading" v-bind="bodyAttrs">
1501
+ <tr
1502
+ v-for="rowIndex in skeletonRowIndexes"
1503
+ :key="`skeleton-row-${rowIndex}`"
1504
+ v-bind="bodyRowAttrs"
1505
+ >
1506
+ <td v-if="selectable" v-bind="bodyCellCheckboxAttrs">
1507
+ <USkeleton v-bind="skeletonCheckboxAttrs" />
1508
+ </td>
1509
+
1510
+ <td
1511
+ v-for="(column, columnIndex) in visibleColumns"
1512
+ :key="`${column.key}-skeleton-${rowIndex}`"
1513
+ v-bind="bodyCellBaseAttrs"
1514
+ :style="getStickyColumnStyle(column)"
1515
+ >
1516
+ <USkeleton
1517
+ v-bind="skeletonCellAttrs"
1518
+ :class="getSkeletonWidthClass(columnIndex, rowIndex)"
1519
+ />
1520
+ </td>
1521
+ </tr>
1522
+ </tbody>
1523
+
1359
1524
  <tbody v-else>
1360
1525
  <tr>
1361
1526
  <td :colspan="colsCount" v-bind="bodyEmptyStateCellAttrs">