vueless 1.4.12-beta.1 → 1.4.12-beta.10
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.
- package/composables/useUI.ts +19 -1
- package/package.json +2 -2
- package/ui.container-drawer/tests/UDrawer.test.ts +10 -17
- package/ui.container-modal/tests/UModal.test.ts +10 -17
- package/ui.data-list/UDataList.vue +3 -0
- package/ui.data-list/config.ts +4 -1
- package/ui.data-list/storybook/stories.ts +16 -0
- package/ui.data-list/tests/UDataList.test.ts +77 -0
- package/ui.data-list/types.ts +15 -0
- package/ui.data-table/UTable.vue +179 -14
- package/ui.data-table/UTableRow.vue +4 -3
- package/ui.data-table/config.ts +10 -1
- package/ui.data-table/storybook/stories.ts +30 -0
- package/ui.data-table/tests/UTable.test.ts +84 -6
- package/ui.data-table/types.ts +15 -1
- package/ui.form-input/UInput.vue +1 -0
- package/ui.form-input/tests/UInput.test.ts +16 -0
- package/ui.navigation-tab/UTab.vue +14 -9
- package/ui.navigation-tab/tests/UTab.test.ts +14 -0
- package/ui.navigation-tabs/UTabs.vue +152 -2
- package/ui.navigation-tabs/config.ts +2 -1
- package/ui.navigation-tabs/constants.ts +2 -0
- package/ui.navigation-tabs/storybook/stories.ts +8 -0
- package/ui.navigation-tabs/tests/UTabs.test.ts +213 -1
- package/ui.navigation-tabs/types.ts +1 -1
package/composables/useUI.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "1.4.12-beta.10",
|
|
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.
|
|
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
|
-
|
|
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
|
|
166
|
+
const closeRequests = component.emitted("update:modelValue");
|
|
173
167
|
|
|
174
|
-
expect(
|
|
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
|
-
|
|
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
|
|
187
|
+
const closeRequests = component.emitted("update:modelValue");
|
|
195
188
|
|
|
196
|
-
expect(
|
|
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
|
-
|
|
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
|
|
205
|
+
const closeRequests = component.emitted("update:modelValue");
|
|
212
206
|
|
|
213
|
-
expect(
|
|
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
|
-
|
|
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
|
|
227
|
+
const closeRequests = component.emitted("update:modelValue");
|
|
235
228
|
|
|
236
|
-
expect(
|
|
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"
|
package/ui.data-list/config.ts
CHANGED
|
@@ -2,7 +2,7 @@ export default /*tw*/ {
|
|
|
2
2
|
wrapper: "",
|
|
3
3
|
draggable: "",
|
|
4
4
|
draggableDrag: "bg-lifted",
|
|
5
|
-
draggableGhost: "
|
|
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: {
|
package/ui.data-list/types.ts
CHANGED
|
@@ -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
|
package/ui.data-table/UTable.vue
CHANGED
|
@@ -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(() => ({
|
|
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)
|
|
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
|
|
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)
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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">
|