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
@@ -9,11 +9,6 @@ import type { Props } from "../types";
9
9
  describe("UDrawer", () => {
10
10
  const modelValue = true;
11
11
 
12
- // Wait for an async component to load
13
- function sleep(ms: number = 0) {
14
- return new Promise((resolve) => setTimeout(resolve, ms));
15
- }
16
-
17
12
  // Props tests
18
13
  describe("Props", () => {
19
14
  it("ModelValue – renders when true", () => {
@@ -150,10 +145,10 @@ describe("UDrawer", () => {
150
145
  expect(innerWrapper.attributes("class")).toContain(expectedClass);
151
146
  });
152
147
 
153
- it("CloseOnOverlay – closes drawer when overlay is clicked", () => {
148
+ it("CloseOnOverlay – closes drawer when overlay is clicked", async () => {
154
149
  const closeOnOverlay = [true, false];
155
150
 
156
- closeOnOverlay.forEach(async (value) => {
151
+ for (const value of closeOnOverlay) {
157
152
  const component = mount(UDrawer, {
158
153
  props: {
159
154
  modelValue,
@@ -167,18 +162,17 @@ describe("UDrawer", () => {
167
162
 
168
163
  await innerWrapper.trigger("mousedown");
169
164
  await innerWrapper.trigger("click");
170
- await sleep(1000);
171
165
 
172
- const drawer = component.find('[vl-key="drawer"]');
166
+ const closeRequests = component.emitted("update:modelValue");
173
167
 
174
- expect(drawer.exists()).toBe(!value);
175
- });
168
+ expect(value ? closeRequests?.at(-1) : closeRequests).toEqual(value ? [false] : undefined);
169
+ }
176
170
  });
177
171
 
178
- it("CloseOnEsc – closes drawer when escape key is pressed", () => {
172
+ it("CloseOnEsc – closes drawer when escape key is pressed", async () => {
179
173
  const closeOnEsc = [true, false];
180
174
 
181
- closeOnEsc.forEach(async (value) => {
175
+ for (const value of closeOnEsc) {
182
176
  const component = mount(UDrawer, {
183
177
  props: {
184
178
  modelValue,
@@ -189,12 +183,11 @@ describe("UDrawer", () => {
189
183
  const wrapper = component.find("[vl-key='wrapper']");
190
184
 
191
185
  await wrapper.trigger("keydown", { key: "Escape" });
192
- await sleep(1000);
193
186
 
194
- const drawer = component.find('[vl-key="drawer"]');
187
+ const closeRequests = component.emitted("update:modelValue");
195
188
 
196
- expect(drawer.exists()).toBe(!value);
197
- });
189
+ expect(value ? closeRequests?.at(-1) : closeRequests).toEqual(value ? [false] : undefined);
190
+ }
198
191
  });
199
192
 
200
193
  it("DataTest – applies the correct data-test attribute", () => {
@@ -34,11 +34,6 @@ const mountWithRouter = (component: unknown, options: UnknownObject) => {
34
34
  describe("UModal", () => {
35
35
  const modelValue = true;
36
36
 
37
- // Wait for an async component to load
38
- function sleep(ms: number = 0) {
39
- return new Promise((resolve) => setTimeout(resolve, ms));
40
- }
41
-
42
37
  // Props tests
43
38
  describe("Props", () => {
44
39
  // ModelValue prop
@@ -189,10 +184,10 @@ describe("UModal", () => {
189
184
  });
190
185
 
191
186
  // CloseOnOverlay prop
192
- it("renders with closeOnOverlay prop", () => {
187
+ it("renders with closeOnOverlay prop", async () => {
193
188
  const closeOnOverlay = [true, false];
194
189
 
195
- closeOnOverlay.forEach(async (value) => {
190
+ for (const value of closeOnOverlay) {
196
191
  const component = mount(UModal, {
197
192
  props: {
198
193
  modelValue,
@@ -206,19 +201,18 @@ describe("UModal", () => {
206
201
 
207
202
  await innerWrapper.trigger("mousedown");
208
203
  await innerWrapper.trigger("click");
209
- await sleep(1000);
210
204
 
211
- const modal = component.find('[vl-key="modal"]');
205
+ const closeRequests = component.emitted("update:modelValue");
212
206
 
213
- expect(modal.exists()).toBe(!value);
214
- });
207
+ expect(value ? closeRequests?.at(-1) : closeRequests).toEqual(value ? [false] : undefined);
208
+ }
215
209
  });
216
210
 
217
211
  // CloseOnEsc prop
218
- it("renders with closeOnEsc prop", () => {
212
+ it("renders with closeOnEsc prop", async () => {
219
213
  const closeOnEsc = [true, false];
220
214
 
221
- closeOnEsc.forEach(async (value) => {
215
+ for (const value of closeOnEsc) {
222
216
  const component = mount(UModal, {
223
217
  props: {
224
218
  modelValue,
@@ -229,12 +223,11 @@ describe("UModal", () => {
229
223
  const wrapper = component.find("[vl-key='wrapper']");
230
224
 
231
225
  await wrapper.trigger("keydown", { key: "Escape" });
232
- await sleep(1000);
233
226
 
234
- const modal = component.find('[vl-key="modal"]');
227
+ const closeRequests = component.emitted("update:modelValue");
235
228
 
236
- expect(modal.exists()).toBe(!value);
237
- });
229
+ expect(value ? closeRequests?.at(-1) : closeRequests).toEqual(value ? [false] : undefined);
230
+ }
238
231
  });
239
232
 
240
233
  // Inner prop
@@ -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>
@@ -2,7 +2,7 @@ export default /*tw*/ {
2
2
  wrapper: "",
3
3
  draggable: "",
4
4
  draggableDrag: "bg-lifted",
5
- draggableGhost: "bg-lifted/50",
5
+ draggableGhost: "data-list-ghost",
6
6
  itemWrapper: "group/item-wrapper",
7
7
  item: {
8
8
  base: `
@@ -57,7 +57,7 @@ export default /*tw*/ {
57
57
  animationDuration: 200,
58
58
  forceFallback: false,
59
59
  fallbackOnBody: false,
60
- fallbackClass: "sortable-fallback",
60
+ fallbackClass: "shadow-sm",
61
61
  /* icons */
62
62
  dragIcon: "drag_indicator",
63
63
  },
@@ -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";
@@ -122,7 +125,7 @@ export const ForceFallback = DefaultTemplate.bind({});
122
125
  ForceFallback.args = {
123
126
  forceFallback: true,
124
127
  fallbackOnBody: true,
125
- fallbackClass: "sortable-fallback",
128
+ fallbackClass: "opacity-100!",
126
129
  };
127
130
  ForceFallback.parameters = {
128
131
  docs: {
@@ -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 },
@@ -227,7 +227,7 @@ describe("UDataList.vue", () => {
227
227
  expect(draggableComponent.vm.$attrs["fallback-class"]).toBe(fallbackClass);
228
228
  });
229
229
 
230
- it("Fallback Class – defaults to sortable-fallback", () => {
230
+ it("Fallback Class – defaults to shadow-sm", () => {
231
231
  const component = mount(UDataList, {
232
232
  props: {
233
233
  list: defaultList,
@@ -236,7 +236,7 @@ describe("UDataList.vue", () => {
236
236
 
237
237
  const draggableComponent = component.findComponent(draggable);
238
238
 
239
- expect(draggableComponent.vm.$attrs["fallback-class"]).toBe("sortable-fallback");
239
+ expect(draggableComponent.vm.$attrs["fallback-class"]).toBe("shadow-sm");
240
240
  });
241
241
 
242
242
  it("Nesting – renders nested items when children array is present", async () => {
@@ -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
  */