vueless 1.4.12-beta.8 → 1.5.0

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 (98) hide show
  1. package/README.md +1 -1
  2. package/index.d.ts +128 -0
  3. package/index.ts +128 -0
  4. package/package.json +10 -9
  5. package/ui.button/config.ts +1 -1
  6. package/ui.button-link/config.ts +1 -1
  7. package/ui.button-toggle/UToggle.vue +6 -4
  8. package/ui.button-toggle/storybook/stories.ts +4 -1
  9. package/ui.button-toggle/tests/UToggle.test-d.ts +48 -0
  10. package/ui.button-toggle/tests/UToggleTypes.vue +55 -0
  11. package/ui.button-toggle/types.ts +18 -6
  12. package/ui.container-accordion/types.ts +5 -2
  13. package/ui.container-collapsible/config.ts +1 -1
  14. package/ui.data-list/UDataList.vue +48 -17
  15. package/ui.data-list/storybook/stories.ts +4 -1
  16. package/ui.data-list/tests/UDataList.test-d.ts +76 -0
  17. package/ui.data-list/tests/UDataList.test.ts +50 -1
  18. package/ui.data-list/tests/UDataListTypes.vue +110 -0
  19. package/ui.data-list/types.ts +37 -5
  20. package/ui.data-table/UTable.vue +68 -10
  21. package/ui.data-table/UTableRow.vue +4 -3
  22. package/ui.data-table/config.ts +1 -1
  23. package/ui.data-table/storybook/stories.ts +4 -1
  24. package/ui.data-table/tests/UTable.test-d.ts +179 -0
  25. package/ui.data-table/tests/UTable.test.ts +1 -1
  26. package/ui.data-table/tests/UTableEmptyColumnsTypes.vue +27 -0
  27. package/ui.data-table/tests/UTableTypes.vue +193 -0
  28. package/ui.data-table/types.ts +91 -8
  29. package/ui.data-table/utilTable.ts +1 -1
  30. package/ui.dropdown/UDropdown.vue +19 -8
  31. package/ui.dropdown/storybook/stories.ts +4 -1
  32. package/ui.dropdown/tests/UDropdown.test-d.ts +61 -0
  33. package/ui.dropdown/tests/UDropdown.test.ts +8 -1
  34. package/ui.dropdown/tests/UDropdownTypes.vue +111 -0
  35. package/ui.dropdown/types.ts +27 -3
  36. package/ui.dropdown-badge/UDropdownBadge.vue +9 -4
  37. package/ui.dropdown-badge/storybook/stories.ts +6 -1
  38. package/ui.dropdown-badge/tests/UDropdownBadge.test-d.ts +55 -0
  39. package/ui.dropdown-badge/tests/UDropdownBadge.test.ts +8 -1
  40. package/ui.dropdown-badge/tests/UDropdownBadgeTypes.vue +107 -0
  41. package/ui.dropdown-badge/types.ts +17 -3
  42. package/ui.dropdown-button/UDropdownButton.vue +9 -4
  43. package/ui.dropdown-button/storybook/stories.ts +6 -1
  44. package/ui.dropdown-button/tests/UDropdownButton.test-d.ts +57 -0
  45. package/ui.dropdown-button/tests/UDropdownButton.test.ts +8 -1
  46. package/ui.dropdown-button/tests/UDropdownButtonTypes.vue +107 -0
  47. package/ui.dropdown-button/types.ts +17 -3
  48. package/ui.dropdown-link/UDropdownLink.vue +9 -4
  49. package/ui.dropdown-link/config.ts +1 -1
  50. package/ui.dropdown-link/storybook/stories.ts +4 -1
  51. package/ui.dropdown-link/tests/UDropdownLink.test-d.ts +55 -0
  52. package/ui.dropdown-link/tests/UDropdownLink.test.ts +8 -1
  53. package/ui.dropdown-link/tests/UDropdownLinkTypes.vue +107 -0
  54. package/ui.dropdown-link/types.ts +17 -3
  55. package/ui.form-calendar/config.ts +1 -1
  56. package/ui.form-checkbox/config.ts +1 -1
  57. package/ui.form-checkbox/tests/UCheckbox.test.ts +4 -4
  58. package/ui.form-checkbox/types.ts +4 -2
  59. package/ui.form-checkbox-group/UCheckboxGroup.vue +8 -2
  60. package/ui.form-date-picker/UDatePicker.vue +11 -1
  61. package/ui.form-date-picker/tests/UDatePicker.test.ts +47 -0
  62. package/ui.form-input/config.ts +1 -1
  63. package/ui.form-input-file/config.ts +1 -1
  64. package/ui.form-listbox/UListbox.vue +29 -12
  65. package/ui.form-listbox/config.ts +1 -1
  66. package/ui.form-listbox/storybook/stories.ts +4 -1
  67. package/ui.form-listbox/tests/UListbox.test-d.ts +63 -0
  68. package/ui.form-listbox/tests/UListboxTypes.vue +107 -0
  69. package/ui.form-listbox/types.ts +34 -2
  70. package/ui.form-radio/config.ts +1 -1
  71. package/ui.form-radio-group/URadioGroup.vue +15 -3
  72. package/ui.form-radio-group/config.ts +8 -3
  73. package/ui.form-radio-group/types.ts +2 -3
  74. package/ui.form-select/USelect.vue +49 -17
  75. package/ui.form-select/config.ts +4 -4
  76. package/ui.form-select/storybook/stories.ts +4 -1
  77. package/ui.form-select/tests/USelect.test-d.ts +76 -0
  78. package/ui.form-select/tests/USelect.test.ts +65 -1
  79. package/ui.form-select/tests/USelectTypes.vue +113 -0
  80. package/ui.form-select/types.ts +55 -4
  81. package/ui.form-switch/USwitch.vue +13 -12
  82. package/ui.form-switch/config.ts +2 -1
  83. package/ui.form-switch/tests/USwitch.test.ts +151 -0
  84. package/ui.form-textarea/config.ts +1 -1
  85. package/ui.image-avatar/types.ts +5 -2
  86. package/ui.image-avatar-group/types.ts +5 -2
  87. package/ui.image-icon/UIcon.vue +3 -2
  88. package/ui.image-icon/types.ts +2 -2
  89. package/ui.navigation-breadcrumbs/types.ts +5 -2
  90. package/ui.navigation-tabs/UTabs.vue +6 -4
  91. package/ui.navigation-tabs/storybook/stories.ts +4 -1
  92. package/ui.navigation-tabs/tests/UTabs.test-d.ts +50 -0
  93. package/ui.navigation-tabs/tests/UTabsTypes.vue +65 -0
  94. package/ui.navigation-tabs/types.ts +25 -3
  95. package/ui.skeleton-text/constants.ts +1 -1
  96. package/ui.text-badge/config.ts +1 -1
  97. package/utils/node/loaderIcon.js +1 -1
  98. package/v.tooltip/vTooltip.ts +1 -1
@@ -1,4 +1,4 @@
1
- <script setup lang="ts">
1
+ <script setup lang="ts" generic="TItem extends DataListItem">
2
2
  import { useTemplateRef } from "vue";
3
3
  import draggable from "vuedraggable/src/vuedraggable";
4
4
 
@@ -13,15 +13,18 @@ import { COMPONENT_NAME } from "./constants";
13
13
  import defaultConfig from "./config";
14
14
  import { useComponentLocaleMessages } from "../composables/useComponentLocaleMassages";
15
15
 
16
- import type { Props, DataListItem, Config } from "./types";
16
+ import type { UnknownObject } from "../types";
17
+ import type { Props, DataListItem, SlotItem, UDataListSlots, Config } from "./types";
17
18
 
18
19
  defineOptions({ inheritAttrs: false });
19
20
 
20
- const props = withDefaults(defineProps<Props>(), {
21
- ...getDefaults<Props, Config>(defaultConfig, COMPONENT_NAME),
21
+ const props = withDefaults(defineProps<Props<TItem>>(), {
22
+ ...getDefaults<Props<TItem>, Config>(defaultConfig, COMPONENT_NAME),
22
23
  list: () => [],
23
24
  });
24
25
 
26
+ defineSlots<UDataListSlots<TItem>>();
27
+
25
28
  const emit = defineEmits([
26
29
  /**
27
30
  * Triggers when item is sorted (after drag).
@@ -42,6 +45,12 @@ function isCrossed(element: DataListItem) {
42
45
  return Boolean(element.crossed);
43
46
  }
44
47
 
48
+ function hasNestedList(element: DataListItem) {
49
+ return Boolean(
50
+ element.children?.length || (props.nestedKey && (element as UnknownObject)[props.nestedKey]),
51
+ );
52
+ }
53
+
45
54
  function onDragEnd() {
46
55
  const sortData = prepareSortData(props.list);
47
56
 
@@ -57,7 +66,7 @@ function prepareSortData(list: DataListItem[] = [], parentValue: string | number
57
66
  if (hasItemChildren) {
58
67
  const childrenItem = prepareSortData(
59
68
  item.children,
60
- item[props.valueKey] as string | number | null,
69
+ (item as UnknownObject)[props.valueKey] as string | number | null,
61
70
  );
62
71
 
63
72
  childrenItem.forEach((item) => sortData.push(item));
@@ -181,36 +190,58 @@ const {
181
190
  </div>
182
191
 
183
192
  <UDataList
184
- v-if="element.children?.length"
193
+ v-if="hasNestedList(element)"
185
194
  hide-empty-state-for-nesting
186
195
  :list="element.children"
187
196
  :group="group"
197
+ :size="size"
198
+ :label-key="labelKey"
199
+ :value-key="valueKey"
200
+ :nested-key="nestedKey"
201
+ :animation-duration="animationDuration"
202
+ :force-fallback="forceFallback"
203
+ :fallback-on-body="fallbackOnBody"
204
+ :fallback-class="fallbackClass"
188
205
  v-bind="nestedAttrs"
206
+ :class="!element.children?.length && 'min-h-4'"
189
207
  :data-test="getDataTest('table')"
190
208
  @drag-sort="onDragEnd"
191
209
  >
192
- <!-- @vue-ignore -->
193
- <template #label="slotProps: { item: DataListItem; crossed: boolean }">
210
+ <!-- The nested list is built from `element.children` (`DataListItem[]`, not `TItem[]`),
211
+ so it yields `SlotItem<DataListItem>`. `SlotItem` is all-optional, so forwarding it
212
+ as `SlotItem<TItem>` promises nothing a deep child may lack. -->
213
+ <template #drag="slotProps">
214
+ <!--
215
+ @slot Use it to add something instead of the drag icon.
216
+ @binding {object} item
217
+ @binding {string} icon-name
218
+ -->
219
+ <slot
220
+ name="drag"
221
+ :item="slotProps.item as SlotItem<TItem>"
222
+ :icon-name="slotProps.iconName"
223
+ />
224
+ </template>
225
+
226
+ <template #label="slotProps">
194
227
  <!--
195
228
  @slot Use it to modify label.
196
229
  @binding {object} item
197
230
  @binding {boolean} crossed
198
231
  -->
199
- <slot name="label" :item="slotProps.item" :crossed="slotProps.crossed">
200
- <div
201
- v-bind="slotProps.crossed ? labelCrossedAttrs : labelAttrs"
202
- v-text="slotProps.item[labelKey]"
203
- />
204
- </slot>
232
+ <slot
233
+ name="label"
234
+ :item="slotProps.item as SlotItem<TItem>"
235
+ :crossed="slotProps.crossed"
236
+ />
205
237
  </template>
206
238
 
207
- <!-- @vue-ignore -->
208
- <template #actions="slotProps: { item: DataListItem }">
239
+ <template #actions="slotProps">
209
240
  <!--
210
241
  @slot Use it to add custom actions.
211
242
  @binding {object} item
212
243
  -->
213
- <slot name="actions" :item="slotProps.item" />
244
+ <slot name="actions" :item="slotProps.item as SlotItem<TItem>" />
214
245
  </template>
215
246
  </UDataList>
216
247
  </div>
@@ -6,7 +6,7 @@ import {
6
6
  getDocsDescription,
7
7
  } from "../../utils/storybook";
8
8
 
9
- import UDataList from "../../ui.data-list/UDataList.vue";
9
+ import UDataListComponent from "../../ui.data-list/UDataList.vue";
10
10
  import UIcon from "../../ui.image-icon/UIcon.vue";
11
11
  import UButton from "../../ui.button/UButton.vue";
12
12
  import URow from "../../ui.container-row/URow.vue";
@@ -19,6 +19,9 @@ import tooltip from "../../v.tooltip/vTooltip";
19
19
  import type { Meta, StoryFn } from "@storybook/vue3-vite";
20
20
  import type { Props, DataListItem } from "../types";
21
21
 
22
+ /* Generic SFCs are typed as a function, which drops the compiler-generated `__name`. */
23
+ const UDataList = UDataListComponent as typeof UDataListComponent & { __name: string };
24
+
22
25
  interface UDataListArgs extends Props {
23
26
  slotTemplate?: string;
24
27
  enum: "size";
@@ -0,0 +1,76 @@
1
+ import { describe, it, expectTypeOf } from "vitest";
2
+
3
+ import type { Props, DataListItem, SlotItem, UDataListSlots } from "../types";
4
+
5
+ interface UserItem {
6
+ value: number;
7
+ label: string;
8
+ owner: string;
9
+ }
10
+
11
+ type LabelSlot = NonNullable<UDataListSlots<UserItem>["label"]>;
12
+ type LabelProps = Parameters<LabelSlot>[0];
13
+
14
+ describe("UDataList types", () => {
15
+ it("infers the caller's item shape in the label slot", () => {
16
+ expectTypeOf<LabelProps["item"]["label"]>().toEqualTypeOf<string | undefined>();
17
+ expectTypeOf<LabelProps["item"]["owner"]>().toEqualTypeOf<string | undefined>();
18
+ expectTypeOf<LabelProps["crossed"]>().toEqualTypeOf<boolean>();
19
+ });
20
+
21
+ it("does not expose undeclared item properties to a slot", () => {
22
+ /* An index signature would make any key legal, so `item.nonExistent` must not resolve. */
23
+ type HasIndexSignature = string extends keyof LabelProps["item"] ? true : false;
24
+
25
+ expectTypeOf<HasIndexSignature>().toEqualTypeOf<false>();
26
+ expectTypeOf<keyof LabelProps["item"]>().not.toEqualTypeOf<string>();
27
+ });
28
+
29
+ it("keeps the reserved item keys available", () => {
30
+ expectTypeOf<SlotItem<UserItem>["crossed"]>().toEqualTypeOf<boolean | undefined>();
31
+ expectTypeOf<SlotItem<UserItem>["actions"]>().toEqualTypeOf<boolean | undefined>();
32
+ expectTypeOf<SlotItem<UserItem>["children"]>().toEqualTypeOf<DataListItem[] | undefined>();
33
+ });
34
+
35
+ it("types the drag and actions slots", () => {
36
+ type DragProps = Parameters<NonNullable<UDataListSlots<UserItem>["drag"]>>[0];
37
+ type ActionsProps = Parameters<NonNullable<UDataListSlots<UserItem>["actions"]>>[0];
38
+
39
+ expectTypeOf<DragProps["iconName"]>().toEqualTypeOf<string>();
40
+ expectTypeOf<DragProps["item"]["label"]>().toEqualTypeOf<string | undefined>();
41
+ expectTypeOf<ActionsProps["item"]["owner"]>().toEqualTypeOf<string | undefined>();
42
+ });
43
+
44
+ it("types the empty slot", () => {
45
+ type EmptyProps = Parameters<NonNullable<UDataListSlots<UserItem>["empty"]>>[0];
46
+
47
+ expectTypeOf<EmptyProps["emptyTitle"]>().toEqualTypeOf<string>();
48
+ expectTypeOf<EmptyProps["emptyDescription"]>().toEqualTypeOf<string>();
49
+ });
50
+
51
+ it("does not promise the caller's members at depth >= 1", () => {
52
+ /* The component renders itself with `:list="element.children"`, and `children` is
53
+ * `DataListItem[]`, not `TItem[]` — so a nested child need not carry the parent's members.
54
+ * A runtime probe over 3 levels confirmed the grandchild arrives without `owner`. */
55
+ expectTypeOf<SlotItem<UserItem>["owner"]>().toEqualTypeOf<string | undefined>();
56
+
57
+ /* The grandchild shape the probe actually observed must be assignable to every item slot. */
58
+ type Grandchild = { value: number; label: string; extraOnly: boolean };
59
+
60
+ type DragProps = Parameters<NonNullable<UDataListSlots<UserItem>["drag"]>>[0];
61
+ type ActionsProps = Parameters<NonNullable<UDataListSlots<UserItem>["actions"]>>[0];
62
+
63
+ expectTypeOf<Grandchild>().toExtend<LabelProps["item"]>();
64
+ expectTypeOf<Grandchild>().toExtend<DragProps["item"]>();
65
+ expectTypeOf<Grandchild>().toExtend<ActionsProps["item"]>();
66
+
67
+ /* `nestedKey` can open a nested list from a source `children` does not constrain, so even
68
+ * an item carrying none of the caller's members must be admitted. */
69
+ expectTypeOf<{ value: number }>().toExtend<LabelProps["item"]>();
70
+ });
71
+
72
+ it("resolves Props with no type argument", () => {
73
+ expectTypeOf<Props>().toEqualTypeOf<Props<DataListItem>>();
74
+ expectTypeOf<Props<UserItem>["list"]>().toEqualTypeOf<UserItem[] | undefined>();
75
+ });
76
+ });
@@ -9,7 +9,7 @@ import draggable from "vuedraggable";
9
9
  import type { Props, DataListItem } from "../types";
10
10
 
11
11
  describe("UDataList.vue", () => {
12
- const defaultList: DataListItem[] = [
12
+ const defaultList = [
13
13
  { value: 1, label: "Item 1" },
14
14
  { value: 2, label: "Item 2", crossed: true },
15
15
  { value: 3, label: "Item 3", actions: false },
@@ -253,6 +253,55 @@ describe("UDataList.vue", () => {
253
253
  expect(nestedComponent.props("list")).toEqual(nestedList[0].children);
254
254
  });
255
255
 
256
+ it("Nesting – renders empty nested drop-zone for childless items when nestedKey is truthy", () => {
257
+ const component = mount(UDataList, {
258
+ props: {
259
+ list: [{ value: 1, label: "Leaf", canNest: true }],
260
+ nestedKey: "canNest",
261
+ },
262
+ });
263
+
264
+ const nestedComponent = component.findComponent("[vl-key='nested']");
265
+
266
+ expect(nestedComponent.exists()).toBe(true);
267
+ });
268
+
269
+ it("Nesting – does not render drop-zone for childless items when nestedKey value is falsy", () => {
270
+ const component = mount(UDataList, {
271
+ props: {
272
+ list: [{ value: 1, label: "Leaf", canNest: false }],
273
+ nestedKey: "canNest",
274
+ },
275
+ });
276
+
277
+ const nestedComponent = component.findComponent("[vl-key='nested']");
278
+
279
+ expect(nestedComponent.exists()).toBe(false);
280
+ });
281
+
282
+ it("Nesting – emits dragged item with parentValue of the leaf after drop into empty drop-zone", async () => {
283
+ const leaf = { value: 1, label: "Leaf", canNest: true, children: [] as DataListItem[] };
284
+ const list: DataListItem[] = [leaf, { value: 2, label: "Dragged", canNest: true }];
285
+
286
+ const component = mount(UDataList, {
287
+ props: { list, nestedKey: "canNest" },
288
+ });
289
+
290
+ // Simulate vuedraggable moving item 2 into the leaf's (previously empty) nested list.
291
+ list.splice(1, 1);
292
+ leaf.children!.push({ value: 2, label: "Dragged", canNest: true });
293
+
294
+ await component.findComponent(draggable).vm.$emit("end");
295
+
296
+ const sortData = component.emitted("dragSort")?.[0]?.[0] as {
297
+ value: number;
298
+ parentValue: unknown;
299
+ }[];
300
+ const dragged = sortData.find((item) => item.value === 2);
301
+
302
+ expect(dragged?.parentValue).toBe(leaf.value);
303
+ });
304
+
256
305
  it("Data Test – applies correct data-test attribute", () => {
257
306
  const dataTest = "test-data-list";
258
307
 
@@ -0,0 +1,110 @@
1
+ <!-- Type-level fixture: checked by `npm run ts:check`, never mounted.
2
+ Proves slot props infer from the `list` prop at a real call site. -->
3
+ <template>
4
+ <UDataList :list="list">
5
+ <!-- A nested child may lack the parent's members, so they are optional;
6
+ the reserved keys (`crossed`, `actions`, `children`) are optional already. -->
7
+ <template #label="{ item, crossed }">
8
+ {{ expectOptionalString(item.label) }}
9
+ {{ expectOptionalString(item.owner) }}
10
+ {{ expectOptionalNumber(item.value) }}
11
+ {{ expectBoolean(crossed) }}
12
+ </template>
13
+
14
+ <template #drag="{ item, iconName }">
15
+ {{ expectOptionalString(item.label) }}
16
+ {{ expectString(iconName) }}
17
+ </template>
18
+
19
+ <template #actions="{ item }">
20
+ {{ expectOptionalString(item.owner) }}
21
+ </template>
22
+
23
+ <template #empty="{ emptyTitle, emptyDescription }">
24
+ {{ expectString(emptyTitle) }}
25
+ {{ expectString(emptyDescription) }}
26
+ </template>
27
+ </UDataList>
28
+
29
+ <!-- A list from a non-literal source still exposes the declared item shape. -->
30
+ <UDataList :list="typedList">
31
+ <template #label="{ item }">{{ expectOptionalString(item.label) }}</template>
32
+ </UDataList>
33
+
34
+ <UDataList :list="list">
35
+ <template #label="{ item }">
36
+ <!-- @vue-expect-error `nonExistent` is not a key of the inferred item. -->
37
+ {{ item.nonExistent }}
38
+ </template>
39
+ </UDataList>
40
+
41
+ <!-- Regression guard: an item slot must never promise the caller's members as present.
42
+ The component renders ITSELF with `:list="element.children"`, and `children` is
43
+ `DataListItem[]`, not `TItem[]` — a runtime probe over 3 levels observed the
44
+ grandchild arriving with `{ value, label, extraOnly }` and NO `owner`. -->
45
+ <UDataList :list="deepList">
46
+ <template #label="{ item }">
47
+ <!-- @vue-expect-error a grandchild at depth 2 need not carry `owner`. -->
48
+ {{ expectString(item.owner) }}
49
+ </template>
50
+ <template #drag="{ item }">
51
+ <!-- @vue-expect-error a nested child need not carry `label`. -->
52
+ {{ expectString(item.label) }}
53
+ </template>
54
+ <template #actions="{ item }">
55
+ <!-- @vue-expect-error a nested child need not carry `value`. -->
56
+ {{ expectNumber(item.value) }}
57
+ </template>
58
+ </UDataList>
59
+ </template>
60
+
61
+ <script setup lang="ts">
62
+ import { ref } from "vue";
63
+
64
+ import UDataList from "../UDataList.vue";
65
+
66
+ interface UserItem {
67
+ value: number;
68
+ label: string;
69
+ owner: string;
70
+ }
71
+
72
+ const list = [{ value: 1, label: "a", owner: "alice" }];
73
+ const typedList = ref<UserItem[]>([]);
74
+
75
+ /* A parent whose grandchild lacks the parent's members — exactly what the probe observed. */
76
+ const deepList = [
77
+ {
78
+ value: 1,
79
+ label: "Parent",
80
+ owner: "alice",
81
+ children: [
82
+ {
83
+ value: 11,
84
+ label: "Child",
85
+ children: [{ value: 111, label: "Grandchild", extraOnly: true }],
86
+ },
87
+ ],
88
+ },
89
+ ];
90
+
91
+ function expectString(value: string) {
92
+ return value;
93
+ }
94
+
95
+ function expectNumber(value: number) {
96
+ return value;
97
+ }
98
+
99
+ function expectBoolean(value: boolean) {
100
+ return value;
101
+ }
102
+
103
+ function expectOptionalString(value: string | undefined) {
104
+ return value;
105
+ }
106
+
107
+ function expectOptionalNumber(value: number | undefined) {
108
+ return value;
109
+ }
110
+ </script>
@@ -2,7 +2,7 @@ import defaultConfig from "./config";
2
2
 
3
3
  import DraggableContext from "vuedraggable";
4
4
 
5
- import type { ComponentConfig, UnknownType } from "../types";
5
+ import type { ComponentConfig, UnknownObject } from "../types";
6
6
 
7
7
  export type Config = typeof defaultConfig;
8
8
 
@@ -11,18 +11,44 @@ export interface DragMoveEvent extends DragEvent {
11
11
  relatedContext: typeof DraggableContext | null;
12
12
  }
13
13
 
14
- export interface DataListItem {
14
+ export interface BaseDataListItem {
15
15
  crossed?: boolean;
16
16
  actions?: boolean;
17
17
  children?: DataListItem[];
18
- [key: string]: UnknownType | DataListItem[];
19
18
  }
20
19
 
21
- export interface Props {
20
+ /* Any object shape is a valid item, only reserved item keys are type checked. */
21
+ export type DataListItem = BaseDataListItem & (object | UnknownObject);
22
+
23
+ /* Drops `DataListItem`'s index signature so slot items only expose statically known keys. */
24
+ type KnownKeys<T> = {
25
+ [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K];
26
+ };
27
+
28
+ /**
29
+ * Item shape exposed to slots: the caller's item plus the reserved item keys.
30
+ *
31
+ * `TItem`'s own keys are optional. The component renders itself for nested children
32
+ * (`:list="element.children"`), and `BaseDataListItem.children` is `DataListItem[]`, not `TItem[]`
33
+ * — so a child need not carry the parent's members. `nestedKey` can open a nested list from a
34
+ * different source entirely. `BaseDataListItem`'s keys are optional already, so nothing is
35
+ * promised that can be absent.
36
+ */
37
+ export type SlotItem<TItem extends DataListItem = DataListItem> = Partial<KnownKeys<TItem>> &
38
+ BaseDataListItem;
39
+
40
+ export interface UDataListSlots<TItem extends DataListItem = DataListItem> {
41
+ empty?: (props: { emptyTitle: string; emptyDescription: string }) => unknown;
42
+ drag?: (props: { item: SlotItem<TItem>; iconName: string }) => unknown;
43
+ label?: (props: { item: SlotItem<TItem>; crossed: boolean }) => unknown;
44
+ actions?: (props: { item: SlotItem<TItem> }) => unknown;
45
+ }
46
+
47
+ export interface Props<TItem extends DataListItem = DataListItem> {
22
48
  /**
23
49
  * Data item options.
24
50
  */
25
- list?: DataListItem[];
51
+ list?: TItem[];
26
52
 
27
53
  /**
28
54
  * Group name.
@@ -70,6 +96,12 @@ export interface Props {
70
96
  */
71
97
  hideEmptyStateForNesting?: boolean;
72
98
 
99
+ /**
100
+ * Item key whose truthy value renders an empty nested drop-zone,
101
+ * so a deeper level can be created by drag even without children.
102
+ */
103
+ nestedKey?: string;
104
+
73
105
  /**
74
106
  * Component config object.
75
107
  */
@@ -1,4 +1,4 @@
1
- <script setup lang="ts">
1
+ <script setup lang="ts" generic="TRow extends TableRow, TCol extends string = string">
2
2
  import {
3
3
  shallowRef,
4
4
  ref,
@@ -47,13 +47,16 @@ import type {
47
47
  FlatRow,
48
48
  ColumnObject,
49
49
  SearchMatch,
50
+ TableRow,
50
51
  UTableRowProps,
52
+ UTableSlots,
53
+ SelectedSlotRow,
51
54
  } from "./types";
52
55
 
53
56
  defineOptions({ inheritAttrs: false });
54
57
 
55
- const props = withDefaults(defineProps<Props>(), {
56
- ...getDefaults<Props, Config>(defaultConfig, COMPONENT_NAME),
58
+ const props = withDefaults(defineProps<Props<TRow, TCol>>(), {
59
+ ...getDefaults<Props<TRow, TCol>, Config>(defaultConfig, COMPONENT_NAME),
57
60
  columns: () => [],
58
61
  rows: () => [],
59
62
  dateDivider: () => [],
@@ -112,6 +115,8 @@ const emit = defineEmits([
112
115
  "search",
113
116
  ]);
114
117
 
118
+ defineSlots<UTableSlots<TRow, TCol>>();
119
+
115
120
  const slots = useSlots();
116
121
 
117
122
  const selectAll = ref(false);
@@ -145,7 +150,7 @@ const sortedRows: ComputedRef<FlatRow[]> = computed(() => {
145
150
  typeof column === "object" ? column.key : column,
146
151
  );
147
152
 
148
- const keyOrder = new Map(headerKeys.map((key, i) => [key, i]));
153
+ const keyOrder = new Map<string, number>(headerKeys.map((key, i) => [key, i]));
149
154
 
150
155
  return flatTableRows.value.map((row) => {
151
156
  const entries = Object.entries(row);
@@ -171,6 +176,11 @@ const isFooterSticky = computed(() => {
171
176
 
172
177
  const normalizedColumns = computed(() => normalizeColumns(props.columns));
173
178
 
179
+ /* Slot keys are narrowed to `TCol` for callers; internal dispatch is driven by runtime strings. */
180
+ function headerSlotName(key: string) {
181
+ return `header-${key}` as `header-${TCol}`;
182
+ }
183
+
174
184
  const visibleColumns = computed(() => {
175
185
  return normalizedColumns.value.filter((column) => column.isShown !== false);
176
186
  });
@@ -208,11 +218,14 @@ const isCheckedMoreOneTableItems = computed(() => {
208
218
  return Boolean(localSelectedRows.value.length);
209
219
  });
210
220
 
221
+ /* Select-all includes nested children, which need not carry `TRow`'s members. */
222
+ const selectedRowsSlotValue = computed(() => localSelectedRows.value as SelectedSlotRow<TRow>[]);
223
+
211
224
  const tableRowWidthStyle = computed(() => ({
212
225
  width: `${tableWidth.value / PX_IN_REM}rem`,
213
226
  }));
214
227
 
215
- const flatTableRows = computed(() => getFlatRows(props.rows));
228
+ const flatTableRows = computed(() => getFlatRows(props.rows as Row[]));
216
229
 
217
230
  const visibleFlatRows = computed(() => {
218
231
  const expanded = expandedRowsSet.value;
@@ -532,7 +545,7 @@ onBeforeUnmount(() => {
532
545
 
533
546
  function onChangeSelectedRows() {
534
547
  if (!isEqual(props.selectedRows, localSelectedRows.value)) {
535
- localSelectedRows.value = props.selectedRows;
548
+ localSelectedRows.value = props.selectedRows as Row[];
536
549
  }
537
550
  }
538
551
 
@@ -838,6 +851,50 @@ function onBodyDoubleClick(event: MouseEvent) {
838
851
  onDoubleClickRow(rowData);
839
852
  }
840
853
 
854
+ // Detect double-tap on touch devices and dispatch a native `dblclick`,
855
+ // which bubbles to the tbody and triggers onBodyDoubleClick.
856
+ const DOUBLE_TAP_DELAY = 300;
857
+ const DOUBLE_TAP_MAX_MOVE = 24;
858
+
859
+ let lastTapTime = 0;
860
+ let lastTapRow: Element | null = null;
861
+ let lastTapX = 0;
862
+ let lastTapY = 0;
863
+
864
+ function onBodyTouchEnd(event: TouchEvent) {
865
+ if (event.changedTouches.length !== 1) return;
866
+
867
+ const [touch] = event.changedTouches;
868
+ const target = touch.target as HTMLElement | null;
869
+ const row = target?.closest("tr[data-row-id]") ?? null;
870
+
871
+ if (!row) {
872
+ lastTapRow = null;
873
+
874
+ return;
875
+ }
876
+
877
+ const now = event.timeStamp;
878
+ const isSameRow = row === lastTapRow;
879
+ const isQuick = now - lastTapTime < DOUBLE_TAP_DELAY;
880
+ const isNear =
881
+ Math.abs(touch.clientX - lastTapX) < DOUBLE_TAP_MAX_MOVE &&
882
+ Math.abs(touch.clientY - lastTapY) < DOUBLE_TAP_MAX_MOVE;
883
+
884
+ if (isSameRow && isQuick && isNear) {
885
+ lastTapTime = 0;
886
+ lastTapRow = null;
887
+ row.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true }));
888
+
889
+ return;
890
+ }
891
+
892
+ lastTapTime = now;
893
+ lastTapRow = row;
894
+ lastTapX = touch.clientX;
895
+ lastTapY = touch.clientY;
896
+ }
897
+
841
898
  function onChangeSelectAll(selectAll: boolean) {
842
899
  if (selectAll && canSelectAll.value) {
843
900
  localSelectedRows.value = [...flatTableRows.value];
@@ -1256,7 +1313,7 @@ const BodyRows = () =>
1256
1313
  @binding {object} column
1257
1314
  @binding {number} index
1258
1315
  -->
1259
- <slot :name="`header-${column.key}`" :column="column" :index="index" />
1316
+ <slot :name="headerSlotName(column.key)" :column="column" :index="index" />
1260
1317
  </template>
1261
1318
 
1262
1319
  <template v-else>
@@ -1294,7 +1351,7 @@ const BodyRows = () =>
1294
1351
  @slot Use it to add action buttons within the actions header, which appear when rows are selected.
1295
1352
  @binding {array} selected-rows
1296
1353
  -->
1297
- <slot name="header-actions" :selected-rows="localSelectedRows" />
1354
+ <slot name="header-actions" :selected-rows="selectedRowsSlotValue" />
1298
1355
 
1299
1356
  <ULoaderProgress :loading="progressLoading" v-bind="stickyHeaderLoaderAttrs" />
1300
1357
  </div>
@@ -1326,7 +1383,7 @@ const BodyRows = () =>
1326
1383
  @slot Use it to add action buttons within the actions header, which appear when rows are selected.
1327
1384
  @binding {array} selected-rows
1328
1385
  -->
1329
- <slot name="header-actions" :selected-rows="localSelectedRows" />
1386
+ <slot name="header-actions" :selected-rows="selectedRowsSlotValue" />
1330
1387
 
1331
1388
  <ULoaderProgress :loading="progressLoading" v-bind="stickyHeaderLoaderAttrs" />
1332
1389
  </div>
@@ -1392,7 +1449,7 @@ const BodyRows = () =>
1392
1449
  index,
1393
1450
  })
1394
1451
  "
1395
- :name="`header-${column.key}`"
1452
+ :name="headerSlotName(column.key)"
1396
1453
  :column="column"
1397
1454
  :index="index"
1398
1455
  />
@@ -1411,6 +1468,7 @@ const BodyRows = () =>
1411
1468
  v-bind="bodyAttrs"
1412
1469
  @click="onBodyClick"
1413
1470
  @dblclick="onBodyDoubleClick"
1471
+ @touchend="onBodyTouchEnd"
1414
1472
  >
1415
1473
  <tr
1416
1474
  v-if="hasBeforeFirstRowSlot"
@@ -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
  },
@@ -45,7 +45,7 @@ export default /*tw*/ {
45
45
  base: "min-w-full border-none text-medium w-auto table-auto border-separate border-spacing-0",
46
46
  variants: {
47
47
  virtualScroll: {
48
- true: "table-layout-fixed w-full",
48
+ true: "table-fixed w-full",
49
49
  },
50
50
  },
51
51
  },