vueless 1.4.12-beta.2 → 1.4.12-beta.21
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/README.md +1 -1
- package/composables/useUI.ts +19 -1
- package/index.d.ts +128 -0
- package/index.ts +128 -0
- package/package.json +10 -9
- package/ui.button/config.ts +1 -1
- package/ui.button-link/config.ts +1 -1
- package/ui.button-toggle/UToggle.vue +6 -4
- package/ui.button-toggle/storybook/stories.ts +4 -1
- package/ui.button-toggle/tests/UToggle.test-d.ts +48 -0
- package/ui.button-toggle/tests/UToggleTypes.vue +55 -0
- package/ui.button-toggle/types.ts +18 -6
- package/ui.container-accordion/types.ts +5 -2
- package/ui.container-collapsible/config.ts +1 -1
- 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 +48 -17
- package/ui.data-list/config.ts +2 -2
- package/ui.data-list/storybook/stories.ts +5 -2
- package/ui.data-list/tests/UDataList.test-d.ts +76 -0
- package/ui.data-list/tests/UDataList.test.ts +52 -3
- package/ui.data-list/tests/UDataListTypes.vue +110 -0
- package/ui.data-list/types.ts +37 -5
- package/ui.data-table/UTable.vue +202 -24
- package/ui.data-table/UTableRow.vue +4 -3
- package/ui.data-table/config.ts +11 -2
- package/ui.data-table/storybook/stories.ts +34 -1
- package/ui.data-table/tests/UTable.test-d.ts +179 -0
- package/ui.data-table/tests/UTable.test.ts +85 -7
- package/ui.data-table/tests/UTableEmptyColumnsTypes.vue +27 -0
- package/ui.data-table/tests/UTableTypes.vue +193 -0
- package/ui.data-table/types.ts +106 -9
- package/ui.data-table/utilTable.ts +1 -1
- package/ui.dropdown/UDropdown.vue +19 -8
- package/ui.dropdown/storybook/stories.ts +4 -1
- package/ui.dropdown/tests/UDropdown.test-d.ts +61 -0
- package/ui.dropdown/tests/UDropdown.test.ts +8 -1
- package/ui.dropdown/tests/UDropdownTypes.vue +111 -0
- package/ui.dropdown/types.ts +27 -3
- package/ui.dropdown-badge/UDropdownBadge.vue +9 -4
- package/ui.dropdown-badge/storybook/stories.ts +6 -1
- package/ui.dropdown-badge/tests/UDropdownBadge.test-d.ts +55 -0
- package/ui.dropdown-badge/tests/UDropdownBadge.test.ts +8 -1
- package/ui.dropdown-badge/tests/UDropdownBadgeTypes.vue +107 -0
- package/ui.dropdown-badge/types.ts +17 -3
- package/ui.dropdown-button/UDropdownButton.vue +9 -4
- package/ui.dropdown-button/storybook/stories.ts +6 -1
- package/ui.dropdown-button/tests/UDropdownButton.test-d.ts +57 -0
- package/ui.dropdown-button/tests/UDropdownButton.test.ts +8 -1
- package/ui.dropdown-button/tests/UDropdownButtonTypes.vue +107 -0
- package/ui.dropdown-button/types.ts +17 -3
- package/ui.dropdown-link/UDropdownLink.vue +9 -4
- package/ui.dropdown-link/config.ts +1 -1
- package/ui.dropdown-link/storybook/stories.ts +4 -1
- package/ui.dropdown-link/tests/UDropdownLink.test-d.ts +55 -0
- package/ui.dropdown-link/tests/UDropdownLink.test.ts +8 -1
- package/ui.dropdown-link/tests/UDropdownLinkTypes.vue +107 -0
- package/ui.dropdown-link/types.ts +17 -3
- package/ui.form-calendar/config.ts +1 -1
- package/ui.form-checkbox/config.ts +1 -1
- package/ui.form-checkbox/tests/UCheckbox.test.ts +4 -4
- package/ui.form-checkbox/types.ts +4 -2
- package/ui.form-checkbox-group/UCheckboxGroup.vue +8 -2
- package/ui.form-date-picker/UDatePicker.vue +11 -1
- package/ui.form-date-picker/tests/UDatePicker.test.ts +47 -0
- package/ui.form-input/UInput.vue +1 -0
- package/ui.form-input/config.ts +1 -1
- package/ui.form-input/tests/UInput.test.ts +16 -0
- package/ui.form-input-file/config.ts +1 -1
- package/ui.form-listbox/UListbox.vue +29 -12
- package/ui.form-listbox/config.ts +1 -1
- package/ui.form-listbox/storybook/stories.ts +4 -1
- package/ui.form-listbox/tests/UListbox.test-d.ts +63 -0
- package/ui.form-listbox/tests/UListboxTypes.vue +107 -0
- package/ui.form-listbox/types.ts +34 -2
- package/ui.form-radio/config.ts +1 -1
- package/ui.form-radio-group/URadioGroup.vue +15 -3
- package/ui.form-radio-group/config.ts +8 -3
- package/ui.form-radio-group/types.ts +2 -3
- package/ui.form-select/USelect.vue +49 -17
- package/ui.form-select/config.ts +4 -4
- package/ui.form-select/storybook/stories.ts +4 -1
- package/ui.form-select/tests/USelect.test-d.ts +76 -0
- package/ui.form-select/tests/USelect.test.ts +65 -1
- package/ui.form-select/tests/USelectTypes.vue +113 -0
- package/ui.form-select/types.ts +55 -4
- package/ui.form-switch/USwitch.vue +13 -12
- package/ui.form-switch/config.ts +2 -1
- package/ui.form-switch/tests/USwitch.test.ts +151 -0
- package/ui.form-textarea/config.ts +1 -1
- package/ui.image-avatar/types.ts +5 -2
- package/ui.image-avatar-group/types.ts +5 -2
- package/ui.image-icon/UIcon.vue +3 -2
- package/ui.image-icon/types.ts +2 -2
- package/ui.navigation-breadcrumbs/types.ts +5 -2
- package/ui.navigation-tab/UTab.vue +14 -9
- package/ui.navigation-tab/tests/UTab.test.ts +14 -0
- package/ui.navigation-tabs/UTabs.vue +158 -6
- package/ui.navigation-tabs/config.ts +2 -1
- package/ui.navigation-tabs/constants.ts +2 -0
- package/ui.navigation-tabs/storybook/stories.ts +12 -1
- package/ui.navigation-tabs/tests/UTabs.test-d.ts +50 -0
- package/ui.navigation-tabs/tests/UTabs.test.ts +213 -1
- package/ui.navigation-tabs/tests/UTabsTypes.vue +65 -0
- package/ui.navigation-tabs/types.ts +26 -4
- package/ui.skeleton-text/constants.ts +1 -1
- package/ui.text-badge/config.ts +1 -1
- package/utils/node/loaderIcon.js +1 -1
- package/v.tooltip/vTooltip.ts +1 -1
|
@@ -68,16 +68,19 @@ function toggle() {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
function
|
|
77
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Toggles when the visible track is clicked. Clicks forwarded from the label
|
|
73
|
+
* arrive on the hidden checkbox (which already toggles via v-model), so skip
|
|
74
|
+
* those to avoid a double toggle.
|
|
75
|
+
*/
|
|
76
|
+
function onClickWrapper(event: MouseEvent) {
|
|
77
|
+
if ((event.target as HTMLElement)?.tagName !== "INPUT") {
|
|
78
|
+
toggle();
|
|
79
|
+
}
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
function
|
|
82
|
+
function onKeydownToggle(event: Event) {
|
|
83
|
+
event.preventDefault();
|
|
81
84
|
toggle();
|
|
82
85
|
}
|
|
83
86
|
|
|
@@ -119,7 +122,6 @@ const {
|
|
|
119
122
|
:disabled="disabled"
|
|
120
123
|
v-bind="switchLabelAttrs"
|
|
121
124
|
:data-test="getDataTest()"
|
|
122
|
-
@click="onClickToggle"
|
|
123
125
|
>
|
|
124
126
|
<template #label>
|
|
125
127
|
<!--
|
|
@@ -141,9 +143,9 @@ const {
|
|
|
141
143
|
ref="wrapper"
|
|
142
144
|
tabindex="0"
|
|
143
145
|
v-bind="wrapperAttrs"
|
|
144
|
-
@keydown.enter="onKeydownSpace"
|
|
145
|
-
@keydown.space.prevent="onKeydownSpace"
|
|
146
146
|
@click="onClickWrapper"
|
|
147
|
+
@keydown.enter="onKeydownToggle"
|
|
148
|
+
@keydown.space="onKeydownToggle"
|
|
147
149
|
>
|
|
148
150
|
<input
|
|
149
151
|
:id="elementId"
|
|
@@ -154,7 +156,6 @@ const {
|
|
|
154
156
|
:aria-labelledby="inputAriaLabelledBy"
|
|
155
157
|
:aria-label="!hasLabel ? localeMessages.switch : undefined"
|
|
156
158
|
v-bind="inputAttrs"
|
|
157
|
-
@click="onClickToggle"
|
|
158
159
|
/>
|
|
159
160
|
|
|
160
161
|
<span v-bind="circleAttrs">
|
package/ui.form-switch/config.ts
CHANGED
|
@@ -6,7 +6,8 @@ export default /*tw*/ {
|
|
|
6
6
|
},
|
|
7
7
|
wrapper: {
|
|
8
8
|
base: `
|
|
9
|
-
flex items-center p-0.5 relative
|
|
9
|
+
flex items-center p-0.5 relative cursor-pointer transition focus-within:transition
|
|
10
|
+
rounded-full outline-solid outline-medium outline-transparent
|
|
10
11
|
focus-visible:outline focus-visible:outline-medium focus-visible:outline-offset-2 focus-visible:outline-{color}
|
|
11
12
|
`,
|
|
12
13
|
variants: {
|
|
@@ -13,6 +13,7 @@ describe("USwitch.vue", () => {
|
|
|
13
13
|
const modelValue = true;
|
|
14
14
|
|
|
15
15
|
const component = mount(USwitch, {
|
|
16
|
+
attachTo: document.body,
|
|
16
17
|
props: {
|
|
17
18
|
modelValue,
|
|
18
19
|
"onUpdate:modelValue": (e) => component.setProps({ modelValue: e }),
|
|
@@ -28,6 +29,8 @@ describe("USwitch.vue", () => {
|
|
|
28
29
|
await inputElement.trigger("click");
|
|
29
30
|
|
|
30
31
|
expect(component.emitted("update:modelValue")![1][0]).toBe(true);
|
|
32
|
+
|
|
33
|
+
component.unmount();
|
|
31
34
|
});
|
|
32
35
|
|
|
33
36
|
it("Label – passes label to ULabel component", () => {
|
|
@@ -199,6 +202,154 @@ describe("USwitch.vue", () => {
|
|
|
199
202
|
});
|
|
200
203
|
});
|
|
201
204
|
|
|
205
|
+
describe("Toggle behavior", () => {
|
|
206
|
+
it("Label click – plain-text label toggles exactly once", async () => {
|
|
207
|
+
const component = mount(USwitch, {
|
|
208
|
+
attachTo: document.body,
|
|
209
|
+
props: {
|
|
210
|
+
modelValue: false,
|
|
211
|
+
label: "Test Label",
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await component.find("label").trigger("click");
|
|
216
|
+
|
|
217
|
+
const emitted = component.emitted("update:modelValue");
|
|
218
|
+
|
|
219
|
+
expect(emitted).toHaveLength(1);
|
|
220
|
+
expect(emitted![0][0]).toBe(true);
|
|
221
|
+
|
|
222
|
+
component.unmount();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("Label slot click – #label slot content toggles exactly once", async () => {
|
|
226
|
+
const component = mount(USwitch, {
|
|
227
|
+
attachTo: document.body,
|
|
228
|
+
props: {
|
|
229
|
+
modelValue: false,
|
|
230
|
+
},
|
|
231
|
+
slots: {
|
|
232
|
+
label: "Custom Label Content",
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await component.find("label").trigger("click");
|
|
237
|
+
|
|
238
|
+
const emitted = component.emitted("update:modelValue");
|
|
239
|
+
|
|
240
|
+
expect(emitted).toHaveLength(1);
|
|
241
|
+
expect(emitted![0][0]).toBe(true);
|
|
242
|
+
|
|
243
|
+
component.unmount();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// The track (wrapper) is a sibling of the <label>, outside its `for` forwarding,
|
|
247
|
+
// so its own @click="toggle" is the only path — clicking it must toggle exactly once.
|
|
248
|
+
it("Track click – wrapper toggles exactly once", async () => {
|
|
249
|
+
const component = mount(USwitch, {
|
|
250
|
+
attachTo: document.body,
|
|
251
|
+
props: {
|
|
252
|
+
modelValue: false,
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await component.get("[vl-key='wrapper']").trigger("click");
|
|
257
|
+
|
|
258
|
+
const emitted = component.emitted("update:modelValue");
|
|
259
|
+
|
|
260
|
+
expect(emitted).toHaveLength(1);
|
|
261
|
+
expect(emitted![0][0]).toBe(true);
|
|
262
|
+
|
|
263
|
+
component.unmount();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("Track click – disabled wrapper emits nothing", async () => {
|
|
267
|
+
const component = mount(USwitch, {
|
|
268
|
+
attachTo: document.body,
|
|
269
|
+
props: {
|
|
270
|
+
modelValue: false,
|
|
271
|
+
disabled: true,
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await component.get("[vl-key='wrapper']").trigger("click");
|
|
276
|
+
|
|
277
|
+
expect(component.emitted("update:modelValue")).toBeUndefined();
|
|
278
|
+
|
|
279
|
+
component.unmount();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("Keyboard – Enter toggles exactly once", async () => {
|
|
283
|
+
const component = mount(USwitch, {
|
|
284
|
+
attachTo: document.body,
|
|
285
|
+
props: {
|
|
286
|
+
modelValue: false,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
await component.get("[vl-key='wrapper']").trigger("keydown.enter");
|
|
291
|
+
|
|
292
|
+
const emitted = component.emitted("update:modelValue");
|
|
293
|
+
|
|
294
|
+
expect(emitted).toHaveLength(1);
|
|
295
|
+
expect(emitted![0][0]).toBe(true);
|
|
296
|
+
|
|
297
|
+
component.unmount();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("Keyboard – Space toggles exactly once", async () => {
|
|
301
|
+
const component = mount(USwitch, {
|
|
302
|
+
attachTo: document.body,
|
|
303
|
+
props: {
|
|
304
|
+
modelValue: false,
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
await component.get("[vl-key='wrapper']").trigger("keydown.space");
|
|
309
|
+
|
|
310
|
+
const emitted = component.emitted("update:modelValue");
|
|
311
|
+
|
|
312
|
+
expect(emitted).toHaveLength(1);
|
|
313
|
+
expect(emitted![0][0]).toBe(true);
|
|
314
|
+
|
|
315
|
+
component.unmount();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("Disabled – label click emits nothing", async () => {
|
|
319
|
+
const component = mount(USwitch, {
|
|
320
|
+
attachTo: document.body,
|
|
321
|
+
props: {
|
|
322
|
+
modelValue: false,
|
|
323
|
+
label: "Test Label",
|
|
324
|
+
disabled: true,
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
await component.find("label").trigger("click");
|
|
329
|
+
|
|
330
|
+
expect(component.emitted("update:modelValue")).toBeUndefined();
|
|
331
|
+
|
|
332
|
+
component.unmount();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("Disabled – keyboard emits nothing", async () => {
|
|
336
|
+
const component = mount(USwitch, {
|
|
337
|
+
attachTo: document.body,
|
|
338
|
+
props: {
|
|
339
|
+
modelValue: false,
|
|
340
|
+
disabled: true,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
await component.get("[vl-key='wrapper']").trigger("keydown.enter");
|
|
345
|
+
await component.get("[vl-key='wrapper']").trigger("keydown.space");
|
|
346
|
+
|
|
347
|
+
expect(component.emitted("update:modelValue")).toBeUndefined();
|
|
348
|
+
|
|
349
|
+
component.unmount();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
202
353
|
describe("Slots", () => {
|
|
203
354
|
it("Label – renders custom content from label slot", () => {
|
|
204
355
|
const customLabelContent = "Custom Label Content";
|
|
@@ -15,7 +15,7 @@ export default /*tw*/ {
|
|
|
15
15
|
wrapper: {
|
|
16
16
|
base: `
|
|
17
17
|
flex px-3 py-2 gap-3 w-full bg-default transition
|
|
18
|
-
rounded-medium border border-solid border-default outline-solid outline-transparent
|
|
18
|
+
rounded-medium border border-solid border-default outline-solid outline-small outline-transparent
|
|
19
19
|
hover:border-lifted hover:focus-within:border-primary focus-within:border-primary
|
|
20
20
|
focus-within:outline focus-within:outline-small focus-within:outline-primary focus-within:transition
|
|
21
21
|
`,
|
package/ui.image-avatar/types.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import defaultConfig from "./config";
|
|
2
2
|
|
|
3
|
-
import type { ComponentConfig } from "../types";
|
|
3
|
+
import type { ComponentConfig, UnknownObject } from "../types";
|
|
4
4
|
|
|
5
5
|
export type Config = typeof defaultConfig;
|
|
6
6
|
|
|
7
|
-
export interface
|
|
7
|
+
export interface BaseChipItem {
|
|
8
8
|
icon?: string;
|
|
9
9
|
color?: Props["color"];
|
|
10
10
|
xPosition?: "left" | "right";
|
|
@@ -12,6 +12,9 @@ export interface ChipItem {
|
|
|
12
12
|
inset?: boolean;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/* Any object shape is a valid chip, only reserved chip keys are type checked. */
|
|
16
|
+
export type ChipItem = BaseChipItem & (object | UnknownObject);
|
|
17
|
+
|
|
15
18
|
export interface Props {
|
|
16
19
|
/**
|
|
17
20
|
* Avatar label (username, nickname, etc.).
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import defaultConfig from "./config";
|
|
2
2
|
|
|
3
|
-
import type { ComponentConfig } from "../types";
|
|
3
|
+
import type { ComponentConfig, UnknownObject } from "../types";
|
|
4
4
|
import type { ChipItem } from "../ui.image-avatar/types";
|
|
5
5
|
|
|
6
6
|
export type Config = typeof defaultConfig;
|
|
7
7
|
|
|
8
|
-
export interface
|
|
8
|
+
export interface BaseAvatarItem {
|
|
9
9
|
src?: string;
|
|
10
10
|
label?: string;
|
|
11
11
|
color?: string;
|
|
@@ -13,6 +13,9 @@ export interface AvatarItem {
|
|
|
13
13
|
chip?: ChipItem;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/* Any object shape is a valid avatar, only reserved avatar keys are type checked. */
|
|
17
|
+
export type AvatarItem = BaseAvatarItem & (object | UnknownObject);
|
|
18
|
+
|
|
16
19
|
export interface Props {
|
|
17
20
|
/**
|
|
18
21
|
* Avatar items.
|
package/ui.image-icon/UIcon.vue
CHANGED
|
@@ -11,13 +11,14 @@ import defaultConfig from "./config";
|
|
|
11
11
|
|
|
12
12
|
import USkeleton from "../ui.skeleton/USkeleton.vue";
|
|
13
13
|
|
|
14
|
-
import type { AsyncComponentLoader, ComponentPublicInstance } from "vue";
|
|
14
|
+
import type { AsyncComponentLoader, ComponentOptions, ComponentPublicInstance } from "vue";
|
|
15
15
|
import type { Props, Config } from "./types";
|
|
16
16
|
|
|
17
17
|
defineOptions({ inheritAttrs: false });
|
|
18
18
|
|
|
19
19
|
const props = withDefaults(defineProps<Props>(), {
|
|
20
20
|
...getDefaults<Props, Config>(defaultConfig, COMPONENT_NAME),
|
|
21
|
+
src: undefined,
|
|
21
22
|
});
|
|
22
23
|
|
|
23
24
|
const emit = defineEmits([
|
|
@@ -53,7 +54,7 @@ const dynamicComponent = computed(() => {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
const name = props.name;
|
|
56
|
-
const src = props.src;
|
|
57
|
+
const src = props.src as ComponentOptions | undefined;
|
|
57
58
|
|
|
58
59
|
/* Edge case */
|
|
59
60
|
if (!src && !name) return "";
|
package/ui.image-icon/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import defaultConfig from "./config";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { Component, SVGAttributes } from "vue";
|
|
4
4
|
|
|
5
5
|
import type { ComponentConfig } from "../types";
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ export interface Props {
|
|
|
17
17
|
/**
|
|
18
18
|
* Icon source (svg as a vue component).
|
|
19
19
|
*/
|
|
20
|
-
src?:
|
|
20
|
+
src?: Component<SVGAttributes>;
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Icon color.
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import defaultConfig from "./config";
|
|
2
|
-
import type { ComponentConfig } from "../types";
|
|
2
|
+
import type { ComponentConfig, UnknownObject } from "../types";
|
|
3
3
|
import type { Props as ULinkProps } from "../ui.button-link/types";
|
|
4
4
|
|
|
5
5
|
export type Config = typeof defaultConfig;
|
|
6
6
|
|
|
7
|
-
export interface
|
|
7
|
+
export interface BaseBreadcrumb extends ULinkProps {
|
|
8
8
|
icon?: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/* Any object shape is a valid breadcrumb, only reserved breadcrumb keys are type checked. */
|
|
12
|
+
export type UBreadcrumb = BaseBreadcrumb & (object | UnknownObject);
|
|
13
|
+
|
|
11
14
|
export interface Props {
|
|
12
15
|
/**
|
|
13
16
|
* Array of links.
|
|
@@ -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";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
1
|
+
<script setup lang="ts" generic="TItem extends UTabsOption">
|
|
2
2
|
import { ref, computed, provide, onMounted, onUnmounted, useTemplateRef } from "vue";
|
|
3
3
|
|
|
4
4
|
import { useUI } from "../composables/useUI";
|
|
@@ -7,19 +7,21 @@ 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
|
-
import type { Props, Config } from "./types";
|
|
13
|
+
import type { Props, Config, UTabsOption, UTabsSlots } from "./types";
|
|
14
14
|
|
|
15
15
|
defineOptions({ inheritAttrs: false });
|
|
16
16
|
|
|
17
|
-
const props = withDefaults(defineProps<Props
|
|
18
|
-
...getDefaults<Props
|
|
17
|
+
const props = withDefaults(defineProps<Props<TItem>>(), {
|
|
18
|
+
...getDefaults<Props<TItem>, Config>(defaultConfig, COMPONENT_NAME),
|
|
19
19
|
modelValue: "",
|
|
20
20
|
options: () => [],
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
+
defineSlots<UTabsSlots<TItem>>();
|
|
24
|
+
|
|
23
25
|
const emit = defineEmits([
|
|
24
26
|
/**
|
|
25
27
|
* Triggers when the selected tab changes.
|
|
@@ -37,6 +39,15 @@ const wrapperRef = useTemplateRef<HTMLDivElement>("wrapper");
|
|
|
37
39
|
const scrollContainerRef = useTemplateRef<HTMLDivElement | null>("scroll-container");
|
|
38
40
|
const showLeftArrow = ref(false);
|
|
39
41
|
const showRightArrow = ref(false);
|
|
42
|
+
const isDragging = ref(false);
|
|
43
|
+
const preventTabClick = ref(false);
|
|
44
|
+
|
|
45
|
+
let isPointerDown = false;
|
|
46
|
+
let dragAxis: "x" | "y" | null = null;
|
|
47
|
+
let startX = 0;
|
|
48
|
+
let startY = 0;
|
|
49
|
+
let startScrollLeft = 0;
|
|
50
|
+
let suppressClickTimer: ReturnType<typeof setTimeout> | null = null;
|
|
40
51
|
|
|
41
52
|
function checkScroll() {
|
|
42
53
|
if (!scrollContainerRef.value) return;
|
|
@@ -59,17 +70,148 @@ function scrollNext() {
|
|
|
59
70
|
scrollContainerRef.value.scrollBy({ left: SCROLL_OFFSET, behavior: "smooth" });
|
|
60
71
|
}
|
|
61
72
|
|
|
73
|
+
function isScrollableOverflow() {
|
|
74
|
+
if (!scrollContainerRef.value) return false;
|
|
75
|
+
|
|
76
|
+
return scrollContainerRef.value.scrollWidth > scrollContainerRef.value.clientWidth;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function clearSuppressClickTimer() {
|
|
80
|
+
if (!suppressClickTimer) return;
|
|
81
|
+
|
|
82
|
+
clearTimeout(suppressClickTimer);
|
|
83
|
+
suppressClickTimer = null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function stopDragListeners() {
|
|
87
|
+
document.removeEventListener("pointermove", onPointerMove);
|
|
88
|
+
document.removeEventListener("pointerup", onPointerUp);
|
|
89
|
+
document.removeEventListener("pointercancel", onPointerUp);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resetDragState() {
|
|
93
|
+
isPointerDown = false;
|
|
94
|
+
dragAxis = null;
|
|
95
|
+
isDragging.value = false;
|
|
96
|
+
document.body.style.cursor = "";
|
|
97
|
+
document.body.style.userSelect = "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function suppressTabClick() {
|
|
101
|
+
preventTabClick.value = true;
|
|
102
|
+
clearSuppressClickTimer();
|
|
103
|
+
|
|
104
|
+
suppressClickTimer = setTimeout(() => {
|
|
105
|
+
preventTabClick.value = false;
|
|
106
|
+
suppressClickTimer = null;
|
|
107
|
+
}, DRAG_CLICK_SUPPRESS_MS);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function onPointerDown(event: PointerEvent) {
|
|
111
|
+
if (!props.scrollable || event.button > 0 || !isScrollableOverflow()) return;
|
|
112
|
+
|
|
113
|
+
isPointerDown = true;
|
|
114
|
+
dragAxis = null;
|
|
115
|
+
startX = event.clientX;
|
|
116
|
+
startY = event.clientY;
|
|
117
|
+
startScrollLeft = scrollContainerRef.value?.scrollLeft ?? 0;
|
|
118
|
+
|
|
119
|
+
document.addEventListener("pointermove", onPointerMove, { passive: false });
|
|
120
|
+
document.addEventListener("pointerup", onPointerUp);
|
|
121
|
+
document.addEventListener("pointercancel", onPointerUp);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function onPointerMove(event: PointerEvent) {
|
|
125
|
+
if (!isPointerDown || !scrollContainerRef.value) return;
|
|
126
|
+
|
|
127
|
+
if (event.pointerType === "mouse" && event.buttons === 0) {
|
|
128
|
+
onPointerUp();
|
|
129
|
+
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const deltaX = event.clientX - startX;
|
|
134
|
+
const deltaY = event.clientY - startY;
|
|
135
|
+
|
|
136
|
+
if (!dragAxis) {
|
|
137
|
+
if (Math.abs(deltaX) < DRAG_THRESHOLD && Math.abs(deltaY) < DRAG_THRESHOLD) return;
|
|
138
|
+
|
|
139
|
+
dragAxis = Math.abs(deltaX) >= Math.abs(deltaY) ? "x" : "y";
|
|
140
|
+
|
|
141
|
+
if (dragAxis === "y") {
|
|
142
|
+
stopDragListeners();
|
|
143
|
+
resetDragState();
|
|
144
|
+
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
isDragging.value = true;
|
|
149
|
+
document.body.style.cursor = "move";
|
|
150
|
+
document.body.style.userSelect = "none";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (dragAxis !== "x") return;
|
|
154
|
+
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
scrollContainerRef.value.scrollLeft = startScrollLeft - deltaX;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function onPointerUp() {
|
|
160
|
+
const wasDragging = isDragging.value;
|
|
161
|
+
|
|
162
|
+
stopDragListeners();
|
|
163
|
+
resetDragState();
|
|
164
|
+
|
|
165
|
+
if (wasDragging) {
|
|
166
|
+
suppressTabClick();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function onClickCapture(event: MouseEvent) {
|
|
171
|
+
if (!preventTabClick.value) return;
|
|
172
|
+
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
event.stopPropagation();
|
|
175
|
+
preventTabClick.value = false;
|
|
176
|
+
clearSuppressClickTimer();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getHorizontalWheelDelta(event: WheelEvent) {
|
|
180
|
+
if (Math.abs(event.deltaX) >= Math.abs(event.deltaY)) {
|
|
181
|
+
return event.deltaX;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return event.shiftKey ? event.deltaY : 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function onWheel(event: WheelEvent) {
|
|
188
|
+
if (!props.scrollable || !scrollContainerRef.value || !isScrollableOverflow()) return;
|
|
189
|
+
|
|
190
|
+
const deltaX = getHorizontalWheelDelta(event);
|
|
191
|
+
|
|
192
|
+
if (!deltaX) return;
|
|
193
|
+
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
scrollContainerRef.value.scrollLeft += deltaX;
|
|
196
|
+
}
|
|
197
|
+
|
|
62
198
|
onMounted(() => {
|
|
63
199
|
if (scrollContainerRef.value) {
|
|
64
200
|
scrollContainerRef.value.addEventListener("scroll", checkScroll, { passive: true });
|
|
201
|
+
scrollContainerRef.value.addEventListener("wheel", onWheel, { passive: false });
|
|
65
202
|
|
|
66
203
|
checkScroll();
|
|
67
204
|
}
|
|
68
205
|
});
|
|
69
206
|
|
|
70
207
|
onUnmounted(() => {
|
|
208
|
+
stopDragListeners();
|
|
209
|
+
resetDragState();
|
|
210
|
+
clearSuppressClickTimer();
|
|
211
|
+
|
|
71
212
|
if (scrollContainerRef.value) {
|
|
72
213
|
scrollContainerRef.value.removeEventListener("scroll", checkScroll);
|
|
214
|
+
scrollContainerRef.value.removeEventListener("wheel", onWheel);
|
|
73
215
|
}
|
|
74
216
|
});
|
|
75
217
|
|
|
@@ -79,6 +221,7 @@ provide("getUTabsSquare", () => props.square);
|
|
|
79
221
|
provide("getUTabsScrollable", () => props.scrollable);
|
|
80
222
|
provide("getUTabsSelectedItem", () => selectedItem.value);
|
|
81
223
|
provide("setUTabsSelectedItem", (value: string) => (selectedItem.value = value));
|
|
224
|
+
provide("getUTabsPreventTabClick", () => preventTabClick.value);
|
|
82
225
|
|
|
83
226
|
defineExpose({
|
|
84
227
|
/**
|
|
@@ -97,6 +240,7 @@ const {
|
|
|
97
240
|
config,
|
|
98
241
|
wrapperAttrs,
|
|
99
242
|
tabsAttrs,
|
|
243
|
+
dragAttrs,
|
|
100
244
|
tabAttrs,
|
|
101
245
|
prevAttrs,
|
|
102
246
|
nextAttrs,
|
|
@@ -117,7 +261,15 @@ const {
|
|
|
117
261
|
</slot>
|
|
118
262
|
</div>
|
|
119
263
|
|
|
120
|
-
<div
|
|
264
|
+
<div
|
|
265
|
+
ref="scroll-container"
|
|
266
|
+
v-bind="tabsAttrs"
|
|
267
|
+
:class="isDragging && dragAttrs.class"
|
|
268
|
+
:data-test="getDataTest()"
|
|
269
|
+
@scroll="checkScroll"
|
|
270
|
+
@pointerdown="onPointerDown"
|
|
271
|
+
@click.capture="onClickCapture"
|
|
272
|
+
>
|
|
121
273
|
<!-- @slot Use it to add the UTab component. -->
|
|
122
274
|
<slot>
|
|
123
275
|
<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: "",
|