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.
Files changed (109) hide show
  1. package/README.md +1 -1
  2. package/composables/useUI.ts +19 -1
  3. package/index.d.ts +128 -0
  4. package/index.ts +128 -0
  5. package/package.json +10 -9
  6. package/ui.button/config.ts +1 -1
  7. package/ui.button-link/config.ts +1 -1
  8. package/ui.button-toggle/UToggle.vue +6 -4
  9. package/ui.button-toggle/storybook/stories.ts +4 -1
  10. package/ui.button-toggle/tests/UToggle.test-d.ts +48 -0
  11. package/ui.button-toggle/tests/UToggleTypes.vue +55 -0
  12. package/ui.button-toggle/types.ts +18 -6
  13. package/ui.container-accordion/types.ts +5 -2
  14. package/ui.container-collapsible/config.ts +1 -1
  15. package/ui.container-drawer/tests/UDrawer.test.ts +10 -17
  16. package/ui.container-modal/tests/UModal.test.ts +10 -17
  17. package/ui.data-list/UDataList.vue +48 -17
  18. package/ui.data-list/config.ts +2 -2
  19. package/ui.data-list/storybook/stories.ts +5 -2
  20. package/ui.data-list/tests/UDataList.test-d.ts +76 -0
  21. package/ui.data-list/tests/UDataList.test.ts +52 -3
  22. package/ui.data-list/tests/UDataListTypes.vue +110 -0
  23. package/ui.data-list/types.ts +37 -5
  24. package/ui.data-table/UTable.vue +202 -24
  25. package/ui.data-table/UTableRow.vue +4 -3
  26. package/ui.data-table/config.ts +11 -2
  27. package/ui.data-table/storybook/stories.ts +34 -1
  28. package/ui.data-table/tests/UTable.test-d.ts +179 -0
  29. package/ui.data-table/tests/UTable.test.ts +85 -7
  30. package/ui.data-table/tests/UTableEmptyColumnsTypes.vue +27 -0
  31. package/ui.data-table/tests/UTableTypes.vue +193 -0
  32. package/ui.data-table/types.ts +106 -9
  33. package/ui.data-table/utilTable.ts +1 -1
  34. package/ui.dropdown/UDropdown.vue +19 -8
  35. package/ui.dropdown/storybook/stories.ts +4 -1
  36. package/ui.dropdown/tests/UDropdown.test-d.ts +61 -0
  37. package/ui.dropdown/tests/UDropdown.test.ts +8 -1
  38. package/ui.dropdown/tests/UDropdownTypes.vue +111 -0
  39. package/ui.dropdown/types.ts +27 -3
  40. package/ui.dropdown-badge/UDropdownBadge.vue +9 -4
  41. package/ui.dropdown-badge/storybook/stories.ts +6 -1
  42. package/ui.dropdown-badge/tests/UDropdownBadge.test-d.ts +55 -0
  43. package/ui.dropdown-badge/tests/UDropdownBadge.test.ts +8 -1
  44. package/ui.dropdown-badge/tests/UDropdownBadgeTypes.vue +107 -0
  45. package/ui.dropdown-badge/types.ts +17 -3
  46. package/ui.dropdown-button/UDropdownButton.vue +9 -4
  47. package/ui.dropdown-button/storybook/stories.ts +6 -1
  48. package/ui.dropdown-button/tests/UDropdownButton.test-d.ts +57 -0
  49. package/ui.dropdown-button/tests/UDropdownButton.test.ts +8 -1
  50. package/ui.dropdown-button/tests/UDropdownButtonTypes.vue +107 -0
  51. package/ui.dropdown-button/types.ts +17 -3
  52. package/ui.dropdown-link/UDropdownLink.vue +9 -4
  53. package/ui.dropdown-link/config.ts +1 -1
  54. package/ui.dropdown-link/storybook/stories.ts +4 -1
  55. package/ui.dropdown-link/tests/UDropdownLink.test-d.ts +55 -0
  56. package/ui.dropdown-link/tests/UDropdownLink.test.ts +8 -1
  57. package/ui.dropdown-link/tests/UDropdownLinkTypes.vue +107 -0
  58. package/ui.dropdown-link/types.ts +17 -3
  59. package/ui.form-calendar/config.ts +1 -1
  60. package/ui.form-checkbox/config.ts +1 -1
  61. package/ui.form-checkbox/tests/UCheckbox.test.ts +4 -4
  62. package/ui.form-checkbox/types.ts +4 -2
  63. package/ui.form-checkbox-group/UCheckboxGroup.vue +8 -2
  64. package/ui.form-date-picker/UDatePicker.vue +11 -1
  65. package/ui.form-date-picker/tests/UDatePicker.test.ts +47 -0
  66. package/ui.form-input/UInput.vue +1 -0
  67. package/ui.form-input/config.ts +1 -1
  68. package/ui.form-input/tests/UInput.test.ts +16 -0
  69. package/ui.form-input-file/config.ts +1 -1
  70. package/ui.form-listbox/UListbox.vue +29 -12
  71. package/ui.form-listbox/config.ts +1 -1
  72. package/ui.form-listbox/storybook/stories.ts +4 -1
  73. package/ui.form-listbox/tests/UListbox.test-d.ts +63 -0
  74. package/ui.form-listbox/tests/UListboxTypes.vue +107 -0
  75. package/ui.form-listbox/types.ts +34 -2
  76. package/ui.form-radio/config.ts +1 -1
  77. package/ui.form-radio-group/URadioGroup.vue +15 -3
  78. package/ui.form-radio-group/config.ts +8 -3
  79. package/ui.form-radio-group/types.ts +2 -3
  80. package/ui.form-select/USelect.vue +49 -17
  81. package/ui.form-select/config.ts +4 -4
  82. package/ui.form-select/storybook/stories.ts +4 -1
  83. package/ui.form-select/tests/USelect.test-d.ts +76 -0
  84. package/ui.form-select/tests/USelect.test.ts +65 -1
  85. package/ui.form-select/tests/USelectTypes.vue +113 -0
  86. package/ui.form-select/types.ts +55 -4
  87. package/ui.form-switch/USwitch.vue +13 -12
  88. package/ui.form-switch/config.ts +2 -1
  89. package/ui.form-switch/tests/USwitch.test.ts +151 -0
  90. package/ui.form-textarea/config.ts +1 -1
  91. package/ui.image-avatar/types.ts +5 -2
  92. package/ui.image-avatar-group/types.ts +5 -2
  93. package/ui.image-icon/UIcon.vue +3 -2
  94. package/ui.image-icon/types.ts +2 -2
  95. package/ui.navigation-breadcrumbs/types.ts +5 -2
  96. package/ui.navigation-tab/UTab.vue +14 -9
  97. package/ui.navigation-tab/tests/UTab.test.ts +14 -0
  98. package/ui.navigation-tabs/UTabs.vue +158 -6
  99. package/ui.navigation-tabs/config.ts +2 -1
  100. package/ui.navigation-tabs/constants.ts +2 -0
  101. package/ui.navigation-tabs/storybook/stories.ts +12 -1
  102. package/ui.navigation-tabs/tests/UTabs.test-d.ts +50 -0
  103. package/ui.navigation-tabs/tests/UTabs.test.ts +213 -1
  104. package/ui.navigation-tabs/tests/UTabsTypes.vue +65 -0
  105. package/ui.navigation-tabs/types.ts +26 -4
  106. package/ui.skeleton-text/constants.ts +1 -1
  107. package/ui.text-badge/config.ts +1 -1
  108. package/utils/node/loaderIcon.js +1 -1
  109. package/v.tooltip/vTooltip.ts +1 -1
@@ -68,16 +68,19 @@ function toggle() {
68
68
  }
69
69
  }
70
70
 
71
- function onClickToggle(event: Event) {
72
- event.stopPropagation();
73
- toggle();
74
- }
75
-
76
- function onKeydownSpace() {
77
- toggle();
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 onClickWrapper() {
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">
@@ -6,7 +6,8 @@ export default /*tw*/ {
6
6
  },
7
7
  wrapper: {
8
8
  base: `
9
- flex items-center p-0.5 relative rounded-full outline-solid outline-transparent cursor-pointer transition focus-within:transition
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
  `,
@@ -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 ChipItem {
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 AvatarItem {
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.
@@ -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 "";
@@ -1,6 +1,6 @@
1
1
  import defaultConfig from "./config";
2
2
 
3
- import type { DefineComponent, SVGAttributes } from "vue";
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?: DefineComponent<SVGAttributes>;
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 UBreadcrumb extends ULinkProps {
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(() => toValue(getUTabsSelectedItem) === props.value);
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 (!props.disabled && setUTabsSelectedItem) {
41
- setUTabsSelectedItem(props.value ?? "");
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, Config>(defaultConfig, COMPONENT_NAME),
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 ref="scroll-container" v-bind="tabsAttrs" :data-test="getDataTest()" @scroll="checkScroll">
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 scroll-smooth",
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: "",
@@ -5,3 +5,5 @@
5
5
  export const COMPONENT_NAME = "UTabs";
6
6
 
7
7
  export const SCROLL_OFFSET = 200;
8
+ export const DRAG_THRESHOLD = 5;
9
+ export const DRAG_CLICK_SUPPRESS_MS = 300;