vueless 1.4.12-beta.5 → 1.4.12-beta.7
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/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-table/UTable.vue +64 -9
- 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/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.7",
|
|
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
|
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[];
|
|
@@ -889,7 +911,7 @@ function getDateDividerConfig(row: Row, isSelected: boolean) {
|
|
|
889
911
|
: bodyDateDividerAttrs.value.config;
|
|
890
912
|
|
|
891
913
|
return getMergedConfig({
|
|
892
|
-
defaultConfig
|
|
914
|
+
defaultConfig,
|
|
893
915
|
globalConfig: getDateDividerData(row.rowDate).config,
|
|
894
916
|
}) as UDividerConfig;
|
|
895
917
|
}
|
|
@@ -917,7 +939,9 @@ function getRowSearchMatchColumns(row: FlatRow): Set<string> | undefined {
|
|
|
917
939
|
}
|
|
918
940
|
|
|
919
941
|
function getRowActiveSearchMatchColumn(row: FlatRow): string | undefined {
|
|
920
|
-
if (!activeMatch.value || activeMatch.value.rowId !== row.id)
|
|
942
|
+
if (!activeMatch.value || activeMatch.value.rowId !== row.id) {
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
921
945
|
|
|
922
946
|
return activeMatch.value.columnKey;
|
|
923
947
|
}
|
|
@@ -1036,6 +1060,8 @@ const {
|
|
|
1036
1060
|
bodyCellSearchMatchTextAttrs,
|
|
1037
1061
|
bodyCellSearchMatchActiveAttrs,
|
|
1038
1062
|
bodyCellSearchMatchTextActiveAttrs,
|
|
1063
|
+
skeletonCellAttrs,
|
|
1064
|
+
skeletonCheckboxAttrs,
|
|
1039
1065
|
} = useUI<Config>(defaultConfig, mutatedProps);
|
|
1040
1066
|
|
|
1041
1067
|
/* Plain object — inner refs are already reactive. */
|
|
@@ -1171,7 +1197,7 @@ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
|
|
|
1171
1197
|
</template>
|
|
1172
1198
|
</div>
|
|
1173
1199
|
|
|
1174
|
-
<ULoaderProgress :loading="
|
|
1200
|
+
<ULoaderProgress :loading="progressLoading" v-bind="stickyHeaderLoaderAttrs" />
|
|
1175
1201
|
</div>
|
|
1176
1202
|
|
|
1177
1203
|
<div
|
|
@@ -1203,7 +1229,7 @@ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
|
|
|
1203
1229
|
-->
|
|
1204
1230
|
<slot name="header-actions" :selected-rows="localSelectedRows" />
|
|
1205
1231
|
|
|
1206
|
-
<ULoaderProgress :loading="
|
|
1232
|
+
<ULoaderProgress :loading="progressLoading" v-bind="stickyHeaderLoaderAttrs" />
|
|
1207
1233
|
</div>
|
|
1208
1234
|
|
|
1209
1235
|
<div
|
|
@@ -1235,7 +1261,7 @@ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
|
|
|
1235
1261
|
-->
|
|
1236
1262
|
<slot name="header-actions" :selected-rows="localSelectedRows" />
|
|
1237
1263
|
|
|
1238
|
-
<ULoaderProgress :loading="
|
|
1264
|
+
<ULoaderProgress :loading="progressLoading" v-bind="stickyHeaderLoaderAttrs" />
|
|
1239
1265
|
</div>
|
|
1240
1266
|
|
|
1241
1267
|
<div ref="table-wrapper" v-bind="tableWrapperAttrs" @scroll="virtualScroll.onScroll">
|
|
@@ -1293,7 +1319,12 @@ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
|
|
|
1293
1319
|
@binding {number} index
|
|
1294
1320
|
-->
|
|
1295
1321
|
<slot
|
|
1296
|
-
v-if="
|
|
1322
|
+
v-if="
|
|
1323
|
+
hasSlotContent($slots[`header-${column.key}`], {
|
|
1324
|
+
column,
|
|
1325
|
+
index,
|
|
1326
|
+
})
|
|
1327
|
+
"
|
|
1297
1328
|
:name="`header-${column.key}`"
|
|
1298
1329
|
:column="column"
|
|
1299
1330
|
:index="index"
|
|
@@ -1305,7 +1336,7 @@ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
|
|
|
1305
1336
|
</th>
|
|
1306
1337
|
</tr>
|
|
1307
1338
|
|
|
1308
|
-
<ULoaderProgress :loading="
|
|
1339
|
+
<ULoaderProgress :loading="progressLoading" v-bind="headerLoaderAttrs" />
|
|
1309
1340
|
</thead>
|
|
1310
1341
|
|
|
1311
1342
|
<tbody
|
|
@@ -1356,6 +1387,30 @@ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
|
|
|
1356
1387
|
</tr>
|
|
1357
1388
|
</tbody>
|
|
1358
1389
|
|
|
1390
|
+
<tbody v-else-if="showSkeletonLoading" v-bind="bodyAttrs">
|
|
1391
|
+
<tr
|
|
1392
|
+
v-for="rowIndex in skeletonRowIndexes"
|
|
1393
|
+
:key="`skeleton-row-${rowIndex}`"
|
|
1394
|
+
v-bind="bodyRowAttrs"
|
|
1395
|
+
>
|
|
1396
|
+
<td v-if="selectable" v-bind="bodyCellCheckboxAttrs">
|
|
1397
|
+
<USkeleton v-bind="skeletonCheckboxAttrs" />
|
|
1398
|
+
</td>
|
|
1399
|
+
|
|
1400
|
+
<td
|
|
1401
|
+
v-for="(column, columnIndex) in visibleColumns"
|
|
1402
|
+
:key="`${column.key}-skeleton-${rowIndex}`"
|
|
1403
|
+
v-bind="bodyCellBaseAttrs"
|
|
1404
|
+
:style="getStickyColumnStyle(column)"
|
|
1405
|
+
>
|
|
1406
|
+
<USkeleton
|
|
1407
|
+
v-bind="skeletonCellAttrs"
|
|
1408
|
+
:class="getSkeletonWidthClass(columnIndex, rowIndex)"
|
|
1409
|
+
/>
|
|
1410
|
+
</td>
|
|
1411
|
+
</tr>
|
|
1412
|
+
</tbody>
|
|
1413
|
+
|
|
1359
1414
|
<tbody v-else>
|
|
1360
1415
|
<tr>
|
|
1361
1416
|
<td :colspan="colsCount" v-bind="bodyEmptyStateCellAttrs">
|
package/ui.data-table/config.ts
CHANGED
|
@@ -13,7 +13,11 @@ export default /*tw*/ {
|
|
|
13
13
|
},
|
|
14
14
|
compoundVariants: [
|
|
15
15
|
{ stickedHeader: true, actionsHeader: true, class: "rounded-none" },
|
|
16
|
-
{
|
|
16
|
+
{
|
|
17
|
+
stickedHeader: true,
|
|
18
|
+
actionsHeader: false,
|
|
19
|
+
class: "border-muted bg-default",
|
|
20
|
+
},
|
|
17
21
|
],
|
|
18
22
|
},
|
|
19
23
|
stickyHeaderCell: "{>headerCellBase} flex-none whitespace-nowrap",
|
|
@@ -111,6 +115,8 @@ export default /*tw*/ {
|
|
|
111
115
|
},
|
|
112
116
|
bodyEmptyState: "{UEmpty} my-8",
|
|
113
117
|
bodyEmptyStateCell: "",
|
|
118
|
+
skeletonCell: "{USkeleton} h-4 rounded-small",
|
|
119
|
+
skeletonCheckbox: "{USkeleton} size-4 rounded-small",
|
|
114
120
|
footer: {
|
|
115
121
|
base: "group/footer border-t border-solid border-muted",
|
|
116
122
|
variants: {
|
|
@@ -147,6 +153,9 @@ export default /*tw*/ {
|
|
|
147
153
|
stickyHeader: false,
|
|
148
154
|
stickyFooter: false,
|
|
149
155
|
loading: false,
|
|
156
|
+
skeletonLoading: false,
|
|
157
|
+
skeletonRows: 5,
|
|
158
|
+
skeletonWidths: ["w-11/12", "w-4/5", "w-3/4", "w-2/3", "w-1/2"],
|
|
150
159
|
textEllipsis: false,
|
|
151
160
|
search: "",
|
|
152
161
|
searchMatch: -1,
|
|
@@ -182,6 +182,36 @@ Loading.parameters = {
|
|
|
182
182
|
},
|
|
183
183
|
};
|
|
184
184
|
|
|
185
|
+
export const SkeletonLoading = DefaultTemplate.bind({});
|
|
186
|
+
SkeletonLoading.args = {
|
|
187
|
+
rows: [],
|
|
188
|
+
skeletonLoading: true,
|
|
189
|
+
};
|
|
190
|
+
SkeletonLoading.parameters = {
|
|
191
|
+
docs: {
|
|
192
|
+
description: {
|
|
193
|
+
story:
|
|
194
|
+
"Use `skeletonLoading` while the first request is pending. The table keeps its structure " +
|
|
195
|
+
"and renders cell skeletons instead of the empty state.",
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export const SkeletonLoadingConfigured = DefaultTemplate.bind({});
|
|
201
|
+
SkeletonLoadingConfigured.args = {
|
|
202
|
+
rows: [],
|
|
203
|
+
skeletonLoading: true,
|
|
204
|
+
skeletonRows: 3,
|
|
205
|
+
skeletonWidths: ["w-11/12", "w-2/3", "w-1/2"],
|
|
206
|
+
};
|
|
207
|
+
SkeletonLoadingConfigured.parameters = {
|
|
208
|
+
docs: {
|
|
209
|
+
description: {
|
|
210
|
+
story: "Configure skeleton row count and cell widths for a single table with props.",
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
185
215
|
export const EmptyCellLabel = DefaultTemplate.bind({});
|
|
186
216
|
EmptyCellLabel.args = {
|
|
187
217
|
emptyCellLabel: "NO DATA",
|
|
@@ -7,6 +7,7 @@ import UTableRow from "../UTableRow.vue";
|
|
|
7
7
|
import UEmpty from "../../ui.container-empty/UEmpty.vue";
|
|
8
8
|
import ULoaderProgress from "../../ui.loader-progress/ULoaderProgress.vue";
|
|
9
9
|
import UDivider from "../../ui.container-divider/UDivider.vue";
|
|
10
|
+
import USkeleton from "../../ui.skeleton/USkeleton.vue";
|
|
10
11
|
import {
|
|
11
12
|
LoaderProgressSymbol,
|
|
12
13
|
createLoaderProgress,
|
|
@@ -149,6 +150,29 @@ describe("UTable.vue", () => {
|
|
|
149
150
|
expect(emptyComponent.exists()).toBe(true);
|
|
150
151
|
});
|
|
151
152
|
|
|
153
|
+
it("Skeleton loading – renders skeleton body instead of empty state", () => {
|
|
154
|
+
const component = mountUTable(getDefaultProps({ rows: [], skeletonLoading: true }));
|
|
155
|
+
|
|
156
|
+
expect(component.findComponent(UEmpty).exists()).toBe(false);
|
|
157
|
+
expect(component.findAllComponents(USkeleton).length).toBeGreaterThan(0);
|
|
158
|
+
expect(component.find("table").exists()).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("Skeleton loading – applies skeleton rows and widths from props", () => {
|
|
162
|
+
const component = mountUTable(
|
|
163
|
+
getDefaultProps({
|
|
164
|
+
rows: [],
|
|
165
|
+
skeletonLoading: true,
|
|
166
|
+
skeletonRows: 2,
|
|
167
|
+
skeletonWidths: ["w-1/3"],
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(component.findAll("tbody tr")).toHaveLength(2);
|
|
172
|
+
expect(component.findAllComponents(USkeleton)).toHaveLength(defaultColumns.length * 2);
|
|
173
|
+
expect(component.findComponent(USkeleton).classes()).toContain("w-1/3");
|
|
174
|
+
});
|
|
175
|
+
|
|
152
176
|
it("Selectable – renders select all checkbox when selectable is true", () => {
|
|
153
177
|
const component = mountUTable(getDefaultProps({ selectable: true }));
|
|
154
178
|
|
|
@@ -221,6 +245,20 @@ describe("UTable.vue", () => {
|
|
|
221
245
|
expect(loader.props("loading")).toBe(false);
|
|
222
246
|
});
|
|
223
247
|
|
|
248
|
+
it("Skeleton loading – hides progress loader while skeleton is shown", () => {
|
|
249
|
+
const component = mountUTable(
|
|
250
|
+
getDefaultProps({
|
|
251
|
+
rows: [],
|
|
252
|
+
loading: true,
|
|
253
|
+
skeletonLoading: true,
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const loader = component.findComponent(ULoaderProgress);
|
|
258
|
+
|
|
259
|
+
expect(loader.props("loading")).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
|
|
224
262
|
it("Data Test – applies correct data-test attributes", () => {
|
|
225
263
|
const dataTest = "test-table";
|
|
226
264
|
|
|
@@ -644,7 +682,14 @@ describe("UTable.vue", () => {
|
|
|
644
682
|
name: "Parent Row",
|
|
645
683
|
email: "parent@example.com",
|
|
646
684
|
role: "Admin",
|
|
647
|
-
row: [
|
|
685
|
+
row: [
|
|
686
|
+
{
|
|
687
|
+
id: "1-1",
|
|
688
|
+
name: "Child",
|
|
689
|
+
email: "child@example.com",
|
|
690
|
+
role: "User",
|
|
691
|
+
},
|
|
692
|
+
],
|
|
648
693
|
},
|
|
649
694
|
];
|
|
650
695
|
|
|
@@ -710,7 +755,14 @@ describe("UTable.vue", () => {
|
|
|
710
755
|
name: "Parent Row",
|
|
711
756
|
email: "parent@example.com",
|
|
712
757
|
role: "Admin",
|
|
713
|
-
row: [
|
|
758
|
+
row: [
|
|
759
|
+
{
|
|
760
|
+
id: "1-1",
|
|
761
|
+
name: "Child",
|
|
762
|
+
email: "child@example.com",
|
|
763
|
+
role: "User",
|
|
764
|
+
},
|
|
765
|
+
],
|
|
714
766
|
},
|
|
715
767
|
];
|
|
716
768
|
|
|
@@ -792,7 +844,14 @@ describe("UTable.vue", () => {
|
|
|
792
844
|
name: "Parent Row",
|
|
793
845
|
email: "parent@example.com",
|
|
794
846
|
role: "Admin",
|
|
795
|
-
row: [
|
|
847
|
+
row: [
|
|
848
|
+
{
|
|
849
|
+
id: "1-1",
|
|
850
|
+
name: "Child",
|
|
851
|
+
email: "child@example.com",
|
|
852
|
+
role: "User",
|
|
853
|
+
},
|
|
854
|
+
],
|
|
796
855
|
};
|
|
797
856
|
|
|
798
857
|
const component = mountUTable(getDefaultProps({ rows: [expandableRow] }));
|
|
@@ -813,7 +872,14 @@ describe("UTable.vue", () => {
|
|
|
813
872
|
name: "Parent Row",
|
|
814
873
|
email: "parent@example.com",
|
|
815
874
|
role: "Admin",
|
|
816
|
-
row: [
|
|
875
|
+
row: [
|
|
876
|
+
{
|
|
877
|
+
id: "1-1",
|
|
878
|
+
name: "Child",
|
|
879
|
+
email: "child@example.com",
|
|
880
|
+
role: "User",
|
|
881
|
+
},
|
|
882
|
+
],
|
|
817
883
|
};
|
|
818
884
|
|
|
819
885
|
const component = mountUTable(
|
|
@@ -879,7 +945,12 @@ describe("UTable.vue", () => {
|
|
|
879
945
|
email: "child1@example.com",
|
|
880
946
|
role: "User",
|
|
881
947
|
row: [
|
|
882
|
-
{
|
|
948
|
+
{
|
|
949
|
+
id: "1-1-1",
|
|
950
|
+
name: "Grandchild",
|
|
951
|
+
email: "grandchild@example.com",
|
|
952
|
+
role: "Guest",
|
|
953
|
+
},
|
|
883
954
|
],
|
|
884
955
|
},
|
|
885
956
|
],
|
|
@@ -902,7 +973,14 @@ describe("UTable.vue", () => {
|
|
|
902
973
|
name: "Parent Row",
|
|
903
974
|
email: "parent@example.com",
|
|
904
975
|
role: "Admin",
|
|
905
|
-
row: [
|
|
976
|
+
row: [
|
|
977
|
+
{
|
|
978
|
+
id: "1-1",
|
|
979
|
+
name: "Child",
|
|
980
|
+
email: "child@example.com",
|
|
981
|
+
role: "User",
|
|
982
|
+
},
|
|
983
|
+
],
|
|
906
984
|
};
|
|
907
985
|
|
|
908
986
|
const component = mountUTable(getDefaultProps({ rows: [expandableRow] }));
|
package/ui.data-table/types.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import defaultConfig from "./config";
|
|
2
|
-
|
|
3
2
|
import type { Ref } from "vue";
|
|
4
3
|
import type { ComponentConfig, UnknownObject } from "../types";
|
|
5
4
|
import type { Config as UDividerConfig } from "../ui.container-divider/types";
|
|
@@ -117,6 +116,21 @@ export interface Props {
|
|
|
117
116
|
*/
|
|
118
117
|
loading?: boolean;
|
|
119
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Show skeleton body for the first table load instead of the regular loader.
|
|
121
|
+
*/
|
|
122
|
+
skeletonLoading?: boolean;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Number of skeleton rows shown during initial loading.
|
|
126
|
+
*/
|
|
127
|
+
skeletonRows?: number;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Skeleton cell width classes used to vary the loading placeholder layout.
|
|
131
|
+
*/
|
|
132
|
+
skeletonWidths?: string[];
|
|
133
|
+
|
|
120
134
|
/**
|
|
121
135
|
* Enable virtual scrolling for large datasets.
|
|
122
136
|
*/
|
package/ui.form-input/UInput.vue
CHANGED
|
@@ -302,6 +302,22 @@ describe("UInput.vue", () => {
|
|
|
302
302
|
|
|
303
303
|
expect(component.get("input").attributes("data-test")).toBe(dataTestValue);
|
|
304
304
|
});
|
|
305
|
+
|
|
306
|
+
it("Data Test – forwards data-test to the label so the error block is targetable", () => {
|
|
307
|
+
const dataTestValue = "test-input";
|
|
308
|
+
|
|
309
|
+
const component = mount(UInput, {
|
|
310
|
+
props: {
|
|
311
|
+
dataTest: dataTestValue,
|
|
312
|
+
error: "This is an error",
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const errorElement = component.find(`[data-test="${dataTestValue}-label-error"]`);
|
|
317
|
+
|
|
318
|
+
expect(errorElement.exists()).toBe(true);
|
|
319
|
+
expect(errorElement.text()).toBe("This is an error");
|
|
320
|
+
});
|
|
305
321
|
});
|
|
306
322
|
|
|
307
323
|
describe("Slots", () => {
|
|
@@ -13,12 +13,13 @@ import type { Props as UTabsProps, SetUTabsSelectedItem } from "../ui.navigation
|
|
|
13
13
|
|
|
14
14
|
defineOptions({ inheritAttrs: false });
|
|
15
15
|
|
|
16
|
-
const setUTabsSelectedItem = inject<SetUTabsSelectedItem>("setUTabsSelectedItem");
|
|
17
|
-
const getUTabsSelectedItem = inject("getUTabsSelectedItem");
|
|
18
|
-
const getUTabsScrollable = inject<UTabsProps["scrollable"]>("getUTabsScrollable");
|
|
19
|
-
const getUTabsSquare = inject<UTabsProps["square"]>("getUTabsSquare");
|
|
20
|
-
const getUTabsBlock = inject<UTabsProps["block"]>("getUTabsBlock");
|
|
16
|
+
const setUTabsSelectedItem = inject<SetUTabsSelectedItem | null>("setUTabsSelectedItem", null);
|
|
17
|
+
const getUTabsSelectedItem = inject("getUTabsSelectedItem", null);
|
|
18
|
+
const getUTabsScrollable = inject<UTabsProps["scrollable"]>("getUTabsScrollable", false);
|
|
19
|
+
const getUTabsSquare = inject<UTabsProps["square"]>("getUTabsSquare", false);
|
|
20
|
+
const getUTabsBlock = inject<UTabsProps["block"]>("getUTabsBlock", false);
|
|
21
21
|
const getUTabsSize = inject<UTabsProps["size"]>("getUTabsSize", "md");
|
|
22
|
+
const getUTabsPreventTabClick = inject<() => boolean>("getUTabsPreventTabClick", () => false);
|
|
22
23
|
|
|
23
24
|
const props = withDefaults(defineProps<Props>(), {
|
|
24
25
|
...getDefaults<Props, Config>(defaultConfig, COMPONENT_NAME),
|
|
@@ -34,12 +35,16 @@ const size = computed(() => toValue(getUTabsSize));
|
|
|
34
35
|
const block = computed(() => toValue(getUTabsBlock));
|
|
35
36
|
const square = computed(() => toValue(getUTabsSquare));
|
|
36
37
|
const scrollable = computed(() => toValue(getUTabsScrollable));
|
|
37
|
-
const isActive = computed(() =>
|
|
38
|
+
const isActive = computed(() => {
|
|
39
|
+
if (!setUTabsSelectedItem) return true;
|
|
40
|
+
|
|
41
|
+
return toValue(getUTabsSelectedItem) === props.value;
|
|
42
|
+
});
|
|
38
43
|
|
|
39
44
|
async function onClickSetValue() {
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
if (toValue(getUTabsPreventTabClick) || props.disabled || !setUTabsSelectedItem) return;
|
|
46
|
+
|
|
47
|
+
setUTabsSelectedItem(props.value ?? "");
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
defineExpose({
|
|
@@ -149,6 +149,20 @@ describe("UTab.vue", () => {
|
|
|
149
149
|
|
|
150
150
|
// Active state tests
|
|
151
151
|
describe("Active state", () => {
|
|
152
|
+
it("Active – applies active classes when rendered without UTabs", () => {
|
|
153
|
+
const expectedClass = "border-primary";
|
|
154
|
+
|
|
155
|
+
const component = mount(UTab, {
|
|
156
|
+
props: {
|
|
157
|
+
label: "Tab Item",
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const button = component.findComponent(UButton);
|
|
162
|
+
|
|
163
|
+
expect(button.attributes("class")).toContain(expectedClass);
|
|
164
|
+
});
|
|
165
|
+
|
|
152
166
|
it("Active – applies active classes when tab is selected", () => {
|
|
153
167
|
const value = "tab1";
|
|
154
168
|
const expectedClass = "border-primary";
|
|
@@ -7,7 +7,7 @@ import { getDefaults } from "../utils/ui";
|
|
|
7
7
|
import UTab from "../ui.navigation-tab/UTab.vue";
|
|
8
8
|
import UButton from "../ui.button/UButton.vue";
|
|
9
9
|
|
|
10
|
-
import { COMPONENT_NAME, SCROLL_OFFSET } from "./constants";
|
|
10
|
+
import { COMPONENT_NAME, SCROLL_OFFSET, DRAG_THRESHOLD, DRAG_CLICK_SUPPRESS_MS } from "./constants";
|
|
11
11
|
import defaultConfig from "./config";
|
|
12
12
|
|
|
13
13
|
import type { Props, Config } from "./types";
|
|
@@ -37,6 +37,15 @@ const wrapperRef = useTemplateRef<HTMLDivElement>("wrapper");
|
|
|
37
37
|
const scrollContainerRef = useTemplateRef<HTMLDivElement | null>("scroll-container");
|
|
38
38
|
const showLeftArrow = ref(false);
|
|
39
39
|
const showRightArrow = ref(false);
|
|
40
|
+
const isDragging = ref(false);
|
|
41
|
+
const preventTabClick = ref(false);
|
|
42
|
+
|
|
43
|
+
let isPointerDown = false;
|
|
44
|
+
let dragAxis: "x" | "y" | null = null;
|
|
45
|
+
let startX = 0;
|
|
46
|
+
let startY = 0;
|
|
47
|
+
let startScrollLeft = 0;
|
|
48
|
+
let suppressClickTimer: ReturnType<typeof setTimeout> | null = null;
|
|
40
49
|
|
|
41
50
|
function checkScroll() {
|
|
42
51
|
if (!scrollContainerRef.value) return;
|
|
@@ -59,17 +68,148 @@ function scrollNext() {
|
|
|
59
68
|
scrollContainerRef.value.scrollBy({ left: SCROLL_OFFSET, behavior: "smooth" });
|
|
60
69
|
}
|
|
61
70
|
|
|
71
|
+
function isScrollableOverflow() {
|
|
72
|
+
if (!scrollContainerRef.value) return false;
|
|
73
|
+
|
|
74
|
+
return scrollContainerRef.value.scrollWidth > scrollContainerRef.value.clientWidth;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function clearSuppressClickTimer() {
|
|
78
|
+
if (!suppressClickTimer) return;
|
|
79
|
+
|
|
80
|
+
clearTimeout(suppressClickTimer);
|
|
81
|
+
suppressClickTimer = null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function stopDragListeners() {
|
|
85
|
+
document.removeEventListener("pointermove", onPointerMove);
|
|
86
|
+
document.removeEventListener("pointerup", onPointerUp);
|
|
87
|
+
document.removeEventListener("pointercancel", onPointerUp);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resetDragState() {
|
|
91
|
+
isPointerDown = false;
|
|
92
|
+
dragAxis = null;
|
|
93
|
+
isDragging.value = false;
|
|
94
|
+
document.body.style.cursor = "";
|
|
95
|
+
document.body.style.userSelect = "";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function suppressTabClick() {
|
|
99
|
+
preventTabClick.value = true;
|
|
100
|
+
clearSuppressClickTimer();
|
|
101
|
+
|
|
102
|
+
suppressClickTimer = setTimeout(() => {
|
|
103
|
+
preventTabClick.value = false;
|
|
104
|
+
suppressClickTimer = null;
|
|
105
|
+
}, DRAG_CLICK_SUPPRESS_MS);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function onPointerDown(event: PointerEvent) {
|
|
109
|
+
if (!props.scrollable || event.button > 0 || !isScrollableOverflow()) return;
|
|
110
|
+
|
|
111
|
+
isPointerDown = true;
|
|
112
|
+
dragAxis = null;
|
|
113
|
+
startX = event.clientX;
|
|
114
|
+
startY = event.clientY;
|
|
115
|
+
startScrollLeft = scrollContainerRef.value?.scrollLeft ?? 0;
|
|
116
|
+
|
|
117
|
+
document.addEventListener("pointermove", onPointerMove, { passive: false });
|
|
118
|
+
document.addEventListener("pointerup", onPointerUp);
|
|
119
|
+
document.addEventListener("pointercancel", onPointerUp);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function onPointerMove(event: PointerEvent) {
|
|
123
|
+
if (!isPointerDown || !scrollContainerRef.value) return;
|
|
124
|
+
|
|
125
|
+
if (event.pointerType === "mouse" && event.buttons === 0) {
|
|
126
|
+
onPointerUp();
|
|
127
|
+
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const deltaX = event.clientX - startX;
|
|
132
|
+
const deltaY = event.clientY - startY;
|
|
133
|
+
|
|
134
|
+
if (!dragAxis) {
|
|
135
|
+
if (Math.abs(deltaX) < DRAG_THRESHOLD && Math.abs(deltaY) < DRAG_THRESHOLD) return;
|
|
136
|
+
|
|
137
|
+
dragAxis = Math.abs(deltaX) >= Math.abs(deltaY) ? "x" : "y";
|
|
138
|
+
|
|
139
|
+
if (dragAxis === "y") {
|
|
140
|
+
stopDragListeners();
|
|
141
|
+
resetDragState();
|
|
142
|
+
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
isDragging.value = true;
|
|
147
|
+
document.body.style.cursor = "move";
|
|
148
|
+
document.body.style.userSelect = "none";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (dragAxis !== "x") return;
|
|
152
|
+
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
scrollContainerRef.value.scrollLeft = startScrollLeft - deltaX;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function onPointerUp() {
|
|
158
|
+
const wasDragging = isDragging.value;
|
|
159
|
+
|
|
160
|
+
stopDragListeners();
|
|
161
|
+
resetDragState();
|
|
162
|
+
|
|
163
|
+
if (wasDragging) {
|
|
164
|
+
suppressTabClick();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function onClickCapture(event: MouseEvent) {
|
|
169
|
+
if (!preventTabClick.value) return;
|
|
170
|
+
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
event.stopPropagation();
|
|
173
|
+
preventTabClick.value = false;
|
|
174
|
+
clearSuppressClickTimer();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function getHorizontalWheelDelta(event: WheelEvent) {
|
|
178
|
+
if (Math.abs(event.deltaX) >= Math.abs(event.deltaY)) {
|
|
179
|
+
return event.deltaX;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return event.shiftKey ? event.deltaY : 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function onWheel(event: WheelEvent) {
|
|
186
|
+
if (!props.scrollable || !scrollContainerRef.value || !isScrollableOverflow()) return;
|
|
187
|
+
|
|
188
|
+
const deltaX = getHorizontalWheelDelta(event);
|
|
189
|
+
|
|
190
|
+
if (!deltaX) return;
|
|
191
|
+
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
scrollContainerRef.value.scrollLeft += deltaX;
|
|
194
|
+
}
|
|
195
|
+
|
|
62
196
|
onMounted(() => {
|
|
63
197
|
if (scrollContainerRef.value) {
|
|
64
198
|
scrollContainerRef.value.addEventListener("scroll", checkScroll, { passive: true });
|
|
199
|
+
scrollContainerRef.value.addEventListener("wheel", onWheel, { passive: false });
|
|
65
200
|
|
|
66
201
|
checkScroll();
|
|
67
202
|
}
|
|
68
203
|
});
|
|
69
204
|
|
|
70
205
|
onUnmounted(() => {
|
|
206
|
+
stopDragListeners();
|
|
207
|
+
resetDragState();
|
|
208
|
+
clearSuppressClickTimer();
|
|
209
|
+
|
|
71
210
|
if (scrollContainerRef.value) {
|
|
72
211
|
scrollContainerRef.value.removeEventListener("scroll", checkScroll);
|
|
212
|
+
scrollContainerRef.value.removeEventListener("wheel", onWheel);
|
|
73
213
|
}
|
|
74
214
|
});
|
|
75
215
|
|
|
@@ -79,6 +219,7 @@ provide("getUTabsSquare", () => props.square);
|
|
|
79
219
|
provide("getUTabsScrollable", () => props.scrollable);
|
|
80
220
|
provide("getUTabsSelectedItem", () => selectedItem.value);
|
|
81
221
|
provide("setUTabsSelectedItem", (value: string) => (selectedItem.value = value));
|
|
222
|
+
provide("getUTabsPreventTabClick", () => preventTabClick.value);
|
|
82
223
|
|
|
83
224
|
defineExpose({
|
|
84
225
|
/**
|
|
@@ -97,6 +238,7 @@ const {
|
|
|
97
238
|
config,
|
|
98
239
|
wrapperAttrs,
|
|
99
240
|
tabsAttrs,
|
|
241
|
+
dragAttrs,
|
|
100
242
|
tabAttrs,
|
|
101
243
|
prevAttrs,
|
|
102
244
|
nextAttrs,
|
|
@@ -117,7 +259,15 @@ const {
|
|
|
117
259
|
</slot>
|
|
118
260
|
</div>
|
|
119
261
|
|
|
120
|
-
<div
|
|
262
|
+
<div
|
|
263
|
+
ref="scroll-container"
|
|
264
|
+
v-bind="tabsAttrs"
|
|
265
|
+
:class="isDragging && dragAttrs.class"
|
|
266
|
+
:data-test="getDataTest()"
|
|
267
|
+
@scroll="checkScroll"
|
|
268
|
+
@pointerdown="onPointerDown"
|
|
269
|
+
@click.capture="onClickCapture"
|
|
270
|
+
>
|
|
121
271
|
<!-- @slot Use it to add the UTab component. -->
|
|
122
272
|
<slot>
|
|
123
273
|
<UTab
|
|
@@ -4,10 +4,11 @@ export default /*tw*/ {
|
|
|
4
4
|
base: "flex border-b border-default w-full",
|
|
5
5
|
variants: {
|
|
6
6
|
scrollable: {
|
|
7
|
-
true: "overflow-hidden flex-nowrap
|
|
7
|
+
true: "overflow-hidden flex-nowrap touch-pan-y select-none",
|
|
8
8
|
},
|
|
9
9
|
},
|
|
10
10
|
},
|
|
11
|
+
drag: "icon-drag cursor-move scroll-auto! *:pointer-events-none",
|
|
11
12
|
tab: "{UTab}",
|
|
12
13
|
prev: "",
|
|
13
14
|
next: "",
|
|
@@ -89,6 +89,14 @@ Scrollable.args = {
|
|
|
89
89
|
options: getOptionsArray(),
|
|
90
90
|
scrollable: true,
|
|
91
91
|
};
|
|
92
|
+
Scrollable.parameters = {
|
|
93
|
+
docs: {
|
|
94
|
+
description: {
|
|
95
|
+
story:
|
|
96
|
+
"Scroll overflowing tabs with the arrow buttons, by dragging the tab list, or with horizontal wheel.",
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
};
|
|
92
100
|
|
|
93
101
|
export const Block = DefaultTemplate.bind({});
|
|
94
102
|
Block.args = { block: true };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { nextTick } from "vue";
|
|
1
2
|
import { mount } from "@vue/test-utils";
|
|
2
3
|
import { describe, it, expect } from "vitest";
|
|
3
4
|
|
|
@@ -7,6 +8,25 @@ import UButton from "../../ui.button/UButton.vue";
|
|
|
7
8
|
|
|
8
9
|
import type { Props, UTabsOption } from "../types";
|
|
9
10
|
|
|
11
|
+
function dispatchPointer(
|
|
12
|
+
target: EventTarget,
|
|
13
|
+
type: "pointerdown" | "pointermove" | "pointerup",
|
|
14
|
+
clientX: number,
|
|
15
|
+
extra: PointerEventInit = {},
|
|
16
|
+
) {
|
|
17
|
+
target.dispatchEvent(
|
|
18
|
+
new PointerEvent(type, {
|
|
19
|
+
bubbles: true,
|
|
20
|
+
cancelable: true,
|
|
21
|
+
clientX,
|
|
22
|
+
clientY: 0,
|
|
23
|
+
pointerId: 1,
|
|
24
|
+
pointerType: "mouse",
|
|
25
|
+
...extra,
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
10
30
|
describe("UTabs.vue", () => {
|
|
11
31
|
// Global options definition
|
|
12
32
|
const options: UTabsOption[] = [
|
|
@@ -94,7 +114,7 @@ describe("UTabs.vue", () => {
|
|
|
94
114
|
const tabsContainer = component.find(`[vl-key="tabs"]`);
|
|
95
115
|
|
|
96
116
|
// Check that the container has the scrollable class
|
|
97
|
-
expect(tabsContainer.classes()).toContain("
|
|
117
|
+
expect(tabsContainer.classes()).toContain("overflow-hidden");
|
|
98
118
|
});
|
|
99
119
|
|
|
100
120
|
it("Scroll – shows scroll buttons when scrollable and content overflows", async () => {
|
|
@@ -134,6 +154,198 @@ describe("UTabs.vue", () => {
|
|
|
134
154
|
expect(nextButton).toBeDefined();
|
|
135
155
|
});
|
|
136
156
|
|
|
157
|
+
it("Scrollable – scrolls the tab list on pointer drag", async () => {
|
|
158
|
+
const manyOptions: UTabsOption[] = Array.from({ length: 10 }, (_, i) => ({
|
|
159
|
+
value: `tab${i}`,
|
|
160
|
+
label: `Tab ${i}`,
|
|
161
|
+
}));
|
|
162
|
+
|
|
163
|
+
const component = mount(UTabs, {
|
|
164
|
+
props: {
|
|
165
|
+
options: manyOptions,
|
|
166
|
+
scrollable: true,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const tabsContainer = component.find(`[vl-key="tabs"]`);
|
|
171
|
+
const element = tabsContainer.element;
|
|
172
|
+
|
|
173
|
+
let scrollLeft = 0;
|
|
174
|
+
|
|
175
|
+
Object.defineProperty(element, "scrollWidth", { configurable: true, value: 1000 });
|
|
176
|
+
Object.defineProperty(element, "clientWidth", { configurable: true, value: 300 });
|
|
177
|
+
Object.defineProperty(element, "scrollLeft", {
|
|
178
|
+
configurable: true,
|
|
179
|
+
get: () => scrollLeft,
|
|
180
|
+
set: (value: number) => {
|
|
181
|
+
scrollLeft = value;
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
dispatchPointer(element, "pointerdown", 200, { button: 0 });
|
|
186
|
+
dispatchPointer(document, "pointermove", 120, { buttons: 1 });
|
|
187
|
+
|
|
188
|
+
await nextTick();
|
|
189
|
+
|
|
190
|
+
expect(scrollLeft).toBe(80);
|
|
191
|
+
expect(tabsContainer.classes()).toContain("cursor-move");
|
|
192
|
+
expect(tabsContainer.classes()).toContain("icon-drag");
|
|
193
|
+
|
|
194
|
+
dispatchPointer(document, "pointerup", 120);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("Scrollable – does not select a tab after a drag gesture", async () => {
|
|
198
|
+
const manyOptions: UTabsOption[] = Array.from({ length: 10 }, (_, i) => ({
|
|
199
|
+
value: `tab${i}`,
|
|
200
|
+
label: `Tab ${i}`,
|
|
201
|
+
}));
|
|
202
|
+
|
|
203
|
+
const component = mount(UTabs, {
|
|
204
|
+
props: {
|
|
205
|
+
options: manyOptions,
|
|
206
|
+
modelValue: "tab0",
|
|
207
|
+
scrollable: true,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const tabsContainer = component.find(`[vl-key="tabs"]`);
|
|
212
|
+
const element = tabsContainer.element;
|
|
213
|
+
|
|
214
|
+
Object.defineProperty(element, "scrollWidth", { configurable: true, value: 1000 });
|
|
215
|
+
Object.defineProperty(element, "clientWidth", { configurable: true, value: 300 });
|
|
216
|
+
Object.defineProperty(element, "scrollLeft", {
|
|
217
|
+
configurable: true,
|
|
218
|
+
writable: true,
|
|
219
|
+
value: 0,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
dispatchPointer(element, "pointerdown", 200, { button: 0 });
|
|
223
|
+
dispatchPointer(document, "pointermove", 120, { buttons: 1 });
|
|
224
|
+
dispatchPointer(document, "pointerup", 120);
|
|
225
|
+
|
|
226
|
+
await component.findAllComponents(UTab)[1].trigger("click");
|
|
227
|
+
|
|
228
|
+
expect(component.emitted("update:modelValue")).toBeFalsy();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("Scrollable – scrolls the tab list on horizontal wheel", () => {
|
|
232
|
+
const manyOptions: UTabsOption[] = Array.from({ length: 10 }, (_, i) => ({
|
|
233
|
+
value: `tab${i}`,
|
|
234
|
+
label: `Tab ${i}`,
|
|
235
|
+
}));
|
|
236
|
+
|
|
237
|
+
const component = mount(UTabs, {
|
|
238
|
+
props: {
|
|
239
|
+
options: manyOptions,
|
|
240
|
+
scrollable: true,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const element = component.find(`[vl-key="tabs"]`).element;
|
|
245
|
+
|
|
246
|
+
let scrollLeft = 0;
|
|
247
|
+
|
|
248
|
+
Object.defineProperty(element, "scrollWidth", { configurable: true, value: 1000 });
|
|
249
|
+
Object.defineProperty(element, "clientWidth", { configurable: true, value: 300 });
|
|
250
|
+
Object.defineProperty(element, "scrollLeft", {
|
|
251
|
+
configurable: true,
|
|
252
|
+
get: () => scrollLeft,
|
|
253
|
+
set: (value: number) => {
|
|
254
|
+
scrollLeft = value;
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
element.dispatchEvent(
|
|
259
|
+
new WheelEvent("wheel", {
|
|
260
|
+
deltaX: 40,
|
|
261
|
+
deltaY: 0,
|
|
262
|
+
bubbles: true,
|
|
263
|
+
cancelable: true,
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
expect(scrollLeft).toBe(40);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("Scrollable – does not scroll the tab list on vertical wheel", () => {
|
|
271
|
+
const manyOptions: UTabsOption[] = Array.from({ length: 10 }, (_, i) => ({
|
|
272
|
+
value: `tab${i}`,
|
|
273
|
+
label: `Tab ${i}`,
|
|
274
|
+
}));
|
|
275
|
+
|
|
276
|
+
const component = mount(UTabs, {
|
|
277
|
+
props: {
|
|
278
|
+
options: manyOptions,
|
|
279
|
+
scrollable: true,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const element = component.find(`[vl-key="tabs"]`).element;
|
|
284
|
+
|
|
285
|
+
let scrollLeft = 0;
|
|
286
|
+
|
|
287
|
+
Object.defineProperty(element, "scrollWidth", { configurable: true, value: 1000 });
|
|
288
|
+
Object.defineProperty(element, "clientWidth", { configurable: true, value: 300 });
|
|
289
|
+
Object.defineProperty(element, "scrollLeft", {
|
|
290
|
+
configurable: true,
|
|
291
|
+
get: () => scrollLeft,
|
|
292
|
+
set: (value: number) => {
|
|
293
|
+
scrollLeft = value;
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
element.dispatchEvent(
|
|
298
|
+
new WheelEvent("wheel", {
|
|
299
|
+
deltaX: 0,
|
|
300
|
+
deltaY: 40,
|
|
301
|
+
bubbles: true,
|
|
302
|
+
cancelable: true,
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
expect(scrollLeft).toBe(0);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("Scrollable – scrolls the tab list on shift+wheel", () => {
|
|
310
|
+
const manyOptions: UTabsOption[] = Array.from({ length: 10 }, (_, i) => ({
|
|
311
|
+
value: `tab${i}`,
|
|
312
|
+
label: `Tab ${i}`,
|
|
313
|
+
}));
|
|
314
|
+
|
|
315
|
+
const component = mount(UTabs, {
|
|
316
|
+
props: {
|
|
317
|
+
options: manyOptions,
|
|
318
|
+
scrollable: true,
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const element = component.find(`[vl-key="tabs"]`).element;
|
|
323
|
+
|
|
324
|
+
let scrollLeft = 0;
|
|
325
|
+
|
|
326
|
+
Object.defineProperty(element, "scrollWidth", { configurable: true, value: 1000 });
|
|
327
|
+
Object.defineProperty(element, "clientWidth", { configurable: true, value: 300 });
|
|
328
|
+
Object.defineProperty(element, "scrollLeft", {
|
|
329
|
+
configurable: true,
|
|
330
|
+
get: () => scrollLeft,
|
|
331
|
+
set: (value: number) => {
|
|
332
|
+
scrollLeft = value;
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
element.dispatchEvent(
|
|
337
|
+
new WheelEvent("wheel", {
|
|
338
|
+
deltaX: 0,
|
|
339
|
+
deltaY: 40,
|
|
340
|
+
shiftKey: true,
|
|
341
|
+
bubbles: true,
|
|
342
|
+
cancelable: true,
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
expect(scrollLeft).toBe(40);
|
|
347
|
+
});
|
|
348
|
+
|
|
137
349
|
it("Block – provides block value to tabs", () => {
|
|
138
350
|
const block = true;
|
|
139
351
|
|