vueless 1.4.12-beta.6 → 1.4.12-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -228,6 +228,16 @@ export function useUI<T>(defaultConfig: T, mutatedProps?: MutatedProps, topLevel
228
228
  const keysAttrs: KeysAttrs<T> = {};
229
229
  const attrsRefs: Record<string, Ref<KeyAttrs>> = {};
230
230
 
231
+ /**
232
+ * Structural signature per key. The watcher below fires on any config/prop/class
233
+ * change and would otherwise re-mint every key's attrs object — even keys whose
234
+ * classes are identical — invalidating downstream reactive readers (e.g. every
235
+ * body row of a table on an unrelated sticky toggle). We skip the `.value` write
236
+ * when the new object is structurally equal to the last one, so the ref keeps its
237
+ * identity and dependents are not re-rendered.
238
+ */
239
+ const attrsSignatures: Record<string, string> = {};
240
+
231
241
  for (const key in config.value) {
232
242
  if (isSystemKey(key)) continue;
233
243
 
@@ -266,12 +276,20 @@ export function useUI<T>(defaultConfig: T, mutatedProps?: MutatedProps, topLevel
266
276
  /* Delete value key to prevent v-model overwrite. */
267
277
  delete commonAttrs.value;
268
278
 
269
- attrsRefs[key].value = {
279
+ const nextValue: KeyAttrs = {
270
280
  ...commonAttrs,
271
281
  class: cx([...data.extendsClasses, classes, commonAttrs.class]),
272
282
  config: data.mergedNestedConfig,
273
283
  ...data.mergedDefaults,
274
284
  };
285
+
286
+ /* Keep the previous ref identity when nothing changed — see attrsSignatures. */
287
+ const signature = JSON.stringify(nextValue);
288
+
289
+ if (attrsSignatures[key] === signature) continue;
290
+
291
+ attrsSignatures[key] = signature;
292
+ attrsRefs[key].value = nextValue;
275
293
  }
276
294
  },
277
295
  { immediate: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vueless",
3
- "version": "1.4.12-beta.6",
3
+ "version": "1.4.12-beta.8",
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",
@@ -1064,7 +1064,7 @@ const {
1064
1064
  skeletonCheckboxAttrs,
1065
1065
  } = useUI<Config>(defaultConfig, mutatedProps);
1066
1066
 
1067
- /* Plain object — inner refs are already reactive. */
1067
+ /* Plain object — inner refs are already reactive and identity-stable (see useUI). */
1068
1068
  const tableRowAttrs = {
1069
1069
  bodyCellContentAttrs,
1070
1070
  bodyCellCheckboxAttrs,
@@ -1111,8 +1111,40 @@ function renderDateDividerRow(row: FlatRow, rowIndex: number): VNode | null {
1111
1111
  ]);
1112
1112
  }
1113
1113
 
1114
+ /**
1115
+ * Per-row VNode memo cache. Toggling one checkbox invalidates the `selectedRowIds`
1116
+ * computed, which re-runs the body render function. Without memoization every row's
1117
+ * VNode would be rebuilt and diffed on each toggle (200+ rows → visible lag). We cache
1118
+ * each row's VNode keyed by row id and only rebuild it when an input that actually
1119
+ * affects that row changes — so an unrelated row keeps the same VNode reference and
1120
+ * Vue skips it entirely during patch.
1121
+ */
1122
+ const rowVNodeCache = new Map<RowId, { signature: string; row: FlatRow; vnode: VNode }>();
1123
+
1124
+ function getRowSignature(row: FlatRow, rowIndex: number): string {
1125
+ return [
1126
+ rowIndex,
1127
+ Number(isRowSelected(row)),
1128
+ Number(expandedRowsSet.value.has(row.id)),
1129
+ Number(isRowVisible(row)),
1130
+ props.selectable ? 1 : 0,
1131
+ props.search || "",
1132
+ getRowActiveSearchMatchColumn(row) || "",
1133
+ [...(getRowSearchMatchColumns(row) || [])].join(","),
1134
+ ].join("|");
1135
+ }
1136
+
1114
1137
  function renderTableRow(row: FlatRow, rowIndex: number): VNode {
1115
- return h(
1138
+ const signature = getRowSignature(row, rowIndex);
1139
+ const cached = rowVNodeCache.get(row.id);
1140
+
1141
+ // `row` identity guards against stale data: `flatTableRows` yields fresh row
1142
+ // objects whenever `props.rows` changes, so a new reference means new cell data.
1143
+ if (cached && cached.row === row && cached.signature === signature) {
1144
+ return cached.vnode;
1145
+ }
1146
+
1147
+ const vnode = h(
1116
1148
  UTableRow,
1117
1149
  {
1118
1150
  key: row.id,
@@ -1138,13 +1170,48 @@ function renderTableRow(row: FlatRow, rowIndex: number): VNode {
1138
1170
  } as unknown as UTableRowProps,
1139
1171
  slots,
1140
1172
  );
1173
+
1174
+ rowVNodeCache.set(row.id, { signature, row, vnode });
1175
+
1176
+ return vnode;
1141
1177
  }
1142
1178
 
1179
+ /* Column layout / config changes affect every row — invalidate the whole cache. */
1180
+ watch(
1181
+ [
1182
+ normalizedColumns,
1183
+ config,
1184
+ columnPositions,
1185
+ () => props.textEllipsis,
1186
+ () => props.emptyCellLabel,
1187
+ ],
1188
+ () => rowVNodeCache.clear(),
1189
+ );
1190
+
1191
+ /* Drop cache entries for rows that no longer exist (filters, pagination reset). */
1192
+ watch(flatTableRows, (rows) => {
1193
+ const liveIds = new Set(rows.map((row) => row.id));
1194
+
1195
+ for (const id of rowVNodeCache.keys()) {
1196
+ if (!liveIds.has(id)) rowVNodeCache.delete(id);
1197
+ }
1198
+ });
1199
+
1143
1200
  function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
1144
1201
  return [renderDateDividerRow(row, rowIndex), renderTableRow(row, rowIndex)].filter(
1145
1202
  Boolean,
1146
1203
  ) as VNode[];
1147
1204
  }
1205
+
1206
+ /**
1207
+ * Stable functional component for the body rows. Defined once so its type
1208
+ * identity never changes across parent re-renders — a previous inline `:is`
1209
+ * arrow created a new type on every render, forcing Vue to unmount and rebuild
1210
+ * the entire tbody (e.g. on a sticky-header toggle). Reading `renderedRows`
1211
+ * through the closure keeps it reactive while row keys drive reconciliation.
1212
+ */
1213
+ const BodyRows = () =>
1214
+ renderedRows.value.map((row, rowIndex) => renderRowTemplate(row, rowIndex)).flat();
1148
1215
  </script>
1149
1216
 
1150
1217
  <template>
@@ -1362,9 +1429,7 @@ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
1362
1429
  />
1363
1430
  </tr>
1364
1431
 
1365
- <component
1366
- :is="() => renderedRows.map((row, rowIndex) => renderRowTemplate(row, rowIndex)).flat()"
1367
- />
1432
+ <component :is="BodyRows" />
1368
1433
 
1369
1434
  <tr v-if="props.virtualScroll && virtualScroll.bottomSpacerHeight.value > 0">
1370
1435
  <td
@@ -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";
@@ -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 ref="scroll-container" v-bind="tabsAttrs" :data-test="getDataTest()" @scroll="checkScroll">
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 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;
@@ -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("scroll-smooth");
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
 
@@ -31,7 +31,7 @@ export interface Props {
31
31
  size?: "2xs" | "xs" | "sm" | "md" | "lg" | "xl";
32
32
 
33
33
  /**
34
- * Make the Tabs scrollable.
34
+ * Make the Tabs scrollable via arrow buttons, dragging, or horizontal wheel.
35
35
  */
36
36
  scrollable?: boolean;
37
37