vueless 1.4.12-beta.0 → 1.4.12-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -400,6 +400,7 @@ function renderNestedFirstCell(value: Cell, key: string, cellIndex: number): VNo
400
400
 
401
401
  function renderTableCell(value: Cell, key: string, index: number): VNode {
402
402
  const keyStr = String(key);
403
+ const column = props.columns.find((col) => col.key === key) ?? props.columns[index];
403
404
 
404
405
  const nestedCellNode = isNestedFirstCell(index)
405
406
  ? renderNestedFirstCell(value, key, index)
@@ -414,12 +415,12 @@ function renderTableCell(value: Cell, key: string, index: number): VNode {
414
415
  ...props.attrs.bodyCellBaseAttrs.value,
415
416
  class: cx([
416
417
  props.attrs.bodyCellBaseAttrs.value.class,
417
- props.columns[index].tdClass,
418
+ column.tdClass,
418
419
  getCellClasses(props.row, keyStr),
419
- getStickyColumnClass(props.columns[index]),
420
+ getStickyColumnClass(column),
420
421
  getSearchMatchCellClass(keyStr),
421
422
  ]),
422
- style: getStickyColumnStyle(props.columns[index]),
423
+ style: getStickyColumnStyle(column),
423
424
  "data-cell-key": key,
424
425
  "data-test": getDataTest(`${key}-cell`),
425
426
  },
@@ -13,7 +13,11 @@ export default /*tw*/ {
13
13
  },
14
14
  compoundVariants: [
15
15
  { stickedHeader: true, actionsHeader: true, class: "rounded-none" },
16
- { stickedHeader: true, actionsHeader: false, class: "border-muted bg-default" },
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: [{ id: "1-1", name: "Child", email: "child@example.com", role: "User" }],
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: [{ id: "1-1", name: "Child", email: "child@example.com", role: "User" }],
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: [{ id: "1-1", name: "Child", email: "child@example.com", role: "User" }],
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: [{ id: "1-1", name: "Child", email: "child@example.com", role: "User" }],
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
- { id: "1-1-1", name: "Grandchild", email: "grandchild@example.com", role: "Guest" },
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: [{ id: "1-1", name: "Child", email: "child@example.com", role: "User" }],
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] }));
@@ -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
  */
@@ -281,6 +281,7 @@ const {
281
281
  :align="labelAlign"
282
282
  centred
283
283
  v-bind="inputLabelAttrs"
284
+ :data-test="getDataTest('label')"
284
285
  >
285
286
  <template #label>
286
287
  <!--
@@ -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(() => 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 };