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
@@ -12,7 +12,7 @@ import { getRandomId } from "../../utils/helper";
12
12
 
13
13
  import defaultConfig from "../config";
14
14
 
15
- import UTable from "../UTable.vue";
15
+ import UTableComponent from "../UTable.vue";
16
16
  import UButton from "../../ui.button/UButton.vue";
17
17
  import ULink from "../../ui.button-link/ULink.vue";
18
18
  import UNumber from "../../ui.text-number/UNumber.vue";
@@ -27,6 +27,9 @@ import tooltip from "../../v.tooltip/vTooltip";
27
27
  import type { Row, Props, ColumnObject } from "../types";
28
28
  import { StickySide } from "../types";
29
29
 
30
+ /* Generic SFCs are typed as a function, which drops the compiler-generated `__name`. */
31
+ const UTable = UTableComponent as typeof UTableComponent & { __name: string };
32
+
30
33
  interface UTableArgs extends Props {
31
34
  slotTemplate?: string;
32
35
  enum: "size";
@@ -0,0 +1,179 @@
1
+ import { describe, it, expectTypeOf } from "vitest";
2
+
3
+ import type {
4
+ Props,
5
+ RowId,
6
+ SelectedSlotRow,
7
+ SlotRow,
8
+ TableRow,
9
+ UTableDynamicSlots,
10
+ UTableSlots,
11
+ } from "../types";
12
+
13
+ interface UserRow {
14
+ id: number;
15
+ name: string;
16
+ qty: number;
17
+ }
18
+
19
+ type CellSlot = NonNullable<UTableSlots<UserRow>["cell-name"]>;
20
+ type CellProps = Parameters<CellSlot>[0];
21
+
22
+ /* The caller's own row members must all stay optional — see `SlotRow`. */
23
+ type RequiredKeys<T> = {
24
+ [K in keyof T]-?: object extends Pick<T, K> ? never : K;
25
+ }[keyof T];
26
+
27
+ describe("UTable types", () => {
28
+ it("infers the caller's row shape in a cell slot", () => {
29
+ expectTypeOf<CellProps["row"]["name"]>().toEqualTypeOf<string | undefined>();
30
+ expectTypeOf<CellProps["row"]["qty"]>().toEqualTypeOf<number | undefined>();
31
+ expectTypeOf<CellProps["index"]>().toEqualTypeOf<number>();
32
+ expectTypeOf<CellProps["cellIndex"]>().toEqualTypeOf<number>();
33
+ });
34
+
35
+ it("does not expose undeclared row properties to a cell slot", () => {
36
+ /* An index signature would make any key legal, so `row.nonExistent` must not resolve. */
37
+ type HasIndexSignature = string extends keyof CellProps["row"] ? true : false;
38
+
39
+ expectTypeOf<HasIndexSignature>().toEqualTypeOf<false>();
40
+ expectTypeOf<keyof CellProps["row"]>().not.toEqualTypeOf<string>();
41
+ });
42
+
43
+ it("keeps the row flattening metadata available", () => {
44
+ expectTypeOf<CellProps["row"]>().toHaveProperty("nestedLevel");
45
+ expectTypeOf<SlotRow<UserRow>["nestedLevel"]>().toEqualTypeOf<number>();
46
+ });
47
+
48
+ it("types the static slots", () => {
49
+ type SelectedRows = Parameters<NonNullable<UTableSlots<UserRow>["header-actions"]>>[0];
50
+
51
+ expectTypeOf<SelectedRows["selectedRows"]>().toEqualTypeOf<SelectedSlotRow<UserRow>[]>();
52
+
53
+ type Expand = NonNullable<UTableSlots<UserRow>["expand"]>;
54
+
55
+ expectTypeOf<Parameters<Expand>[0]["expanded"]>().toEqualTypeOf<boolean>();
56
+ });
57
+
58
+ it("does not promise the caller's members on selected rows", () => {
59
+ /* Select-all flattens nested children into the selection, and those need not carry
60
+ * `TRow`'s members — so the slot must not type them as guaranteed. */
61
+ expectTypeOf<SelectedSlotRow<UserRow>["name"]>().toEqualTypeOf<string | undefined>();
62
+ expectTypeOf<SelectedSlotRow<UserRow>["qty"]>().toEqualTypeOf<number | undefined>();
63
+
64
+ /* A nested child, as `getFlatRows` actually produces it, must be assignable. */
65
+ expectTypeOf<{
66
+ id: number;
67
+ nestedLevel: number;
68
+ parentRowId: number;
69
+ }>().toExtend<SelectedSlotRow<UserRow>>();
70
+
71
+ /* Flattening metadata stays guaranteed. */
72
+ expectTypeOf<SelectedSlotRow<UserRow>["nestedLevel"]>().toEqualTypeOf<number>();
73
+ });
74
+
75
+ it("does not promise the caller's members on any row slot", () => {
76
+ /* `getFlatRows` recurses into `row.row`, and a nested child is typed `TableRow`, not `TRow`.
77
+ * Every row slot receives those children verbatim; `nested-row` receives nothing else. */
78
+ type Expand = Parameters<NonNullable<UTableSlots<UserRow>["expand"]>>[0];
79
+ type NestedRow = Parameters<NonNullable<UTableSlots<UserRow>["nested-row"]>>[0];
80
+
81
+ expectTypeOf<CellProps["row"]["name"]>().toEqualTypeOf<string | undefined>();
82
+ expectTypeOf<Expand["row"]["name"]>().toEqualTypeOf<string | undefined>();
83
+ expectTypeOf<NestedRow["row"]["name"]>().toEqualTypeOf<string | undefined>();
84
+ expectTypeOf<NestedRow["row"]["qty"]>().toEqualTypeOf<number | undefined>();
85
+
86
+ /* The real nested-child shape recorded at these slots must be assignable to all of them. */
87
+ type NestedChild = {
88
+ id: number;
89
+ label: string;
90
+ nestedLevel: number;
91
+ parentRowId: number;
92
+ };
93
+
94
+ expectTypeOf<NestedChild>().toExtend<CellProps["row"]>();
95
+ expectTypeOf<NestedChild>().toExtend<Expand["row"]>();
96
+ expectTypeOf<NestedChild>().toExtend<NestedRow["row"]>();
97
+
98
+ /* Flattening metadata is always set by `getFlatRows`, so it stays guaranteed. */
99
+ expectTypeOf<Expand["row"]["nestedLevel"]>().toEqualTypeOf<number>();
100
+ expectTypeOf<NestedRow["row"]["nestedLevel"]>().toEqualTypeOf<number>();
101
+ expectTypeOf<NestedRow["row"]["id"]>().toExtend<RowId>();
102
+ expectTypeOf<undefined>().not.toExtend<NestedRow["row"]["id"]>();
103
+ });
104
+
105
+ it("resolves Props with no type argument", () => {
106
+ expectTypeOf<Props>().toEqualTypeOf<Props<TableRow>>();
107
+ expectTypeOf<Props<UserRow>["rows"]>().toEqualTypeOf<UserRow[]>();
108
+ });
109
+
110
+ it("narrows the dynamic slot keys to the known column keys", () => {
111
+ type Narrowed = keyof UTableDynamicSlots<UserRow, "name" | "qty">;
112
+
113
+ expectTypeOf<"cell-name">().toExtend<Narrowed>();
114
+ expectTypeOf<"header-qty">().toExtend<Narrowed>();
115
+ expectTypeOf<"cell-typo">().not.toExtend<Narrowed>();
116
+ expectTypeOf<"header-typo">().not.toExtend<Narrowed>();
117
+ });
118
+
119
+ it("accepts every dynamic slot key when the columns are not statically known", () => {
120
+ /* Columns from a `ref`, an API, or a plain `string[]` widen `TCol` to `string`. Erroring on
121
+ * a valid slot there would break existing callers, so the keys must stay open. */
122
+ type Loose = keyof UTableDynamicSlots<UserRow>;
123
+
124
+ expectTypeOf<"cell-name">().toExtend<Loose>();
125
+ expectTypeOf<"cell-anything-at-all">().toExtend<Loose>();
126
+ expectTypeOf<"header-whatever">().toExtend<Loose>();
127
+
128
+ expectTypeOf<UTableDynamicSlots<UserRow>>().toEqualTypeOf<
129
+ UTableDynamicSlots<UserRow, string>
130
+ >();
131
+ });
132
+
133
+ it("accepts every dynamic slot key when the columns are empty", () => {
134
+ /* An empty `columns` array infers `TCol` as `never`, which means "not statically known",
135
+ * not "no keys allowed". Without the `[TCol]` tuple guard the mapped type collapses to `{}`
136
+ * and every slot is rejected — the "columns load async, start empty" pattern breaks. */
137
+ type Empty = keyof UTableDynamicSlots<UserRow, never>;
138
+
139
+ /* `toEqualTypeOf`, not `toExtend`: the bug made `Empty` collapse to `never`, and `never`
140
+ * satisfies every `toExtend` assertion vacuously. */
141
+ expectTypeOf<Empty>().toEqualTypeOf<`cell-${string}` | `header-${string}`>();
142
+ expectTypeOf<Empty>().not.toEqualTypeOf<never>();
143
+
144
+ expectTypeOf<UTableDynamicSlots<UserRow, never>>().toEqualTypeOf<
145
+ UTableDynamicSlots<UserRow, string>
146
+ >();
147
+ });
148
+
149
+ it("keeps the row shape intact when the keys are narrowed", () => {
150
+ type NarrowCell = Parameters<NonNullable<UTableSlots<UserRow, "name" | "qty">["cell-name"]>>[0];
151
+
152
+ expectTypeOf<NarrowCell["row"]["name"]>().toEqualTypeOf<string | undefined>();
153
+ expectTypeOf<NarrowCell["row"]["nestedLevel"]>().toEqualTypeOf<number>();
154
+ });
155
+
156
+ it("keeps the slot payloads identical however the keys resolve", () => {
157
+ /* Narrowing must only change which keys exist, never what a slot receives. */
158
+ type WideCell = Parameters<NonNullable<UTableSlots<UserRow>["cell-name"]>>[0];
159
+ type NarrowCell = Parameters<NonNullable<UTableSlots<UserRow, "name" | "qty">["cell-name"]>>[0];
160
+ type EmptyCell = Parameters<NonNullable<UTableSlots<UserRow, never>["cell-name"]>>[0];
161
+
162
+ expectTypeOf<NarrowCell>().toEqualTypeOf<WideCell>();
163
+ expectTypeOf<EmptyCell>().toEqualTypeOf<WideCell>();
164
+
165
+ type WideHeader = Parameters<NonNullable<UTableSlots<UserRow>["header-name"]>>[0];
166
+ type EmptyHeader = Parameters<NonNullable<UTableSlots<UserRow, never>["header-name"]>>[0];
167
+
168
+ expectTypeOf<EmptyHeader>().toEqualTypeOf<WideHeader>();
169
+
170
+ /* No row slot may promise the caller's members, whatever `TCol` is. Only the flattening
171
+ * metadata stays required; `name`/`qty` must never appear among the required keys. */
172
+ expectTypeOf<RequiredKeys<EmptyCell["row"]>>().toEqualTypeOf<RequiredKeys<WideCell["row"]>>();
173
+ expectTypeOf<RequiredKeys<NarrowCell["row"]>>().toEqualTypeOf<RequiredKeys<WideCell["row"]>>();
174
+
175
+ type CallerKeys = Extract<RequiredKeys<SlotRow<UserRow>>, "name" | "qty">;
176
+
177
+ expectTypeOf<CallerKeys>().toEqualTypeOf<never>();
178
+ });
179
+ });
@@ -78,7 +78,7 @@ describe("UTable.vue", () => {
78
78
 
79
79
  function mountUTable(props: Props, options = {}) {
80
80
  return mount(UTable, {
81
- props,
81
+ props: { ...props },
82
82
  global: getGlobalOptions(),
83
83
  ...options,
84
84
  });
@@ -0,0 +1,27 @@
1
+ <!-- Type-level fixture: checked by `npm run ts:check`, never mounted.
2
+ Regression guard for an inline empty `columns` array, which infers `TCol` as `never`.
3
+ `never` means "not statically known", not "no keys allowed", so every `cell-*` / `header-*`
4
+ slot must still compile. This shipped broken once and rejected every slot.
5
+
6
+ It needs its own file: next to other `#cell-*` call sites (as in `UTableTypes.vue`) Vue
7
+ reuses a widened instantiation, and the guard stops proving anything. -->
8
+ <template>
9
+ <UTable :columns="[]" :rows="rows">
10
+ <template #cell-name="{ row }">{{ expectOptionalString(row.name) }}</template>
11
+ <template #header-name="{ column }">{{ expectString(column.key) }}</template>
12
+ </UTable>
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ import UTable from "../UTable.vue";
17
+
18
+ const rows = [{ id: 1, name: "a", qty: 2 }];
19
+
20
+ function expectString(value: string) {
21
+ return value;
22
+ }
23
+
24
+ function expectOptionalString(value: string | undefined) {
25
+ return value;
26
+ }
27
+ </script>
@@ -0,0 +1,193 @@
1
+ <!-- Type-level fixture: checked by `npm run ts:check`, never mounted.
2
+ Proves slot props infer from the `rows` prop at a real call site. -->
3
+ <template>
4
+ <UTable :columns="columns" :rows="rows">
5
+ <!-- A flattened nested child may lack the parent's members, so they are optional;
6
+ the flattening metadata (`id`, `nestedLevel`) is always set and stays guaranteed. -->
7
+ <template #cell-name="{ row, value, index, cellIndex }">
8
+ {{ expectOptionalString(row.name) }}
9
+ {{ expectOptionalNumber(row.qty) }}
10
+ {{ expectNumber(row.id) }}
11
+ {{ expectNumber(row.nestedLevel) }}
12
+ {{ expectNumber(index) }}
13
+ {{ expectNumber(cellIndex) }}
14
+ {{ value }}
15
+ </template>
16
+
17
+ <template #expand="{ row, expanded, index }">
18
+ {{ expectOptionalString(row.name) }}
19
+ {{ expectNumber(row.nestedLevel) }}
20
+ {{ expectBoolean(expanded) }}
21
+ {{ expectNumber(index) }}
22
+ </template>
23
+
24
+ <template #nested-row="{ row, nestedLevel }">
25
+ {{ expectOptionalString(row.name) }}
26
+ {{ expectNumber(row.nestedLevel) }}
27
+ {{ expectNumber(nestedLevel) }}
28
+ </template>
29
+
30
+ <template #header-actions="{ selectedRows }">
31
+ {{ expectNumber(selectedRows.length) }}
32
+ </template>
33
+
34
+ <template #header-name="{ column, index }">
35
+ {{ expectString(column.key) }}
36
+ {{ expectNumber(index) }}
37
+ </template>
38
+
39
+ <template #header-counter="{ total }">{{ expectNumber(total) }}</template>
40
+ <template #footer="{ colsCount }">{{ expectNumber(colsCount) }}</template>
41
+
42
+ <template #cell-qty="{ row }">
43
+ <!-- @vue-expect-error `nonExistent` is not a key of the inferred row. -->
44
+ {{ row.nonExistent }}
45
+ </template>
46
+
47
+ <!-- @vue-expect-error `typo` is not one of the literal column keys. -->
48
+ <template #cell-typo>{{ "" }}</template>
49
+
50
+ <!-- @vue-expect-error `typo` is not one of the literal column keys. -->
51
+ <template #header-typo>{{ "" }}</template>
52
+ </UTable>
53
+
54
+ <!-- Literal object columns narrow the slot keys the same way as literal strings. -->
55
+ <UTable :columns="objectColumns" :rows="rows">
56
+ <template #cell-name="{ row }">{{ expectOptionalString(row.name) }}</template>
57
+ <template #header-qty="{ column }">{{ expectString(column.key) }}</template>
58
+
59
+ <!-- @vue-expect-error `typo` is not one of the literal column keys. -->
60
+ <template #cell-typo>{{ "" }}</template>
61
+ </UTable>
62
+
63
+ <!-- Rows from a non-literal source still expose the declared row shape. -->
64
+ <UTable :columns="columns" :rows="typedRows">
65
+ <template #cell-name="{ row }">{{ expectOptionalString(row.name) }}</template>
66
+ </UTable>
67
+
68
+ <!-- Graceful degradation: columns from a `ref<Column[]>` cannot be narrowed, so EVERY
69
+ `cell-*` / `header-*` slot must still compile. A false error here would break
70
+ existing users, so it matters more than catching a typo. -->
71
+ <UTable :columns="dynamicColumns" :rows="rows">
72
+ <template #cell-name="{ row, index }">
73
+ {{ expectOptionalString(row.name) }}{{ expectNumber(index) }}
74
+ </template>
75
+ <template #cell-anything-at-all="{ row }">{{ expectOptionalNumber(row.qty) }}</template>
76
+ <template #header-name="{ column }">{{ expectString(column.key) }}</template>
77
+ <template #header-whatever="{ index }">{{ expectNumber(index) }}</template>
78
+ </UTable>
79
+
80
+ <!-- Same degradation for a plain `string[]` with no literal types. -->
81
+ <UTable :columns="looseColumns" :rows="rows">
82
+ <template #cell-name="{ row }">{{ expectOptionalString(row.name) }}</template>
83
+ <template #cell-unknown-key="{ row }">{{ expectOptionalNumber(row.qty) }}</template>
84
+ </UTable>
85
+
86
+ <!-- A `const` array without `as const` widens to `string[]`, so it degrades too. Narrowing
87
+ is opt-in via `as const`; it must never cost a false error on the plain form. -->
88
+ <UTable :columns="widenedColumns" :rows="rows">
89
+ <template #cell-name="{ row }">{{ expectOptionalString(row.name) }}</template>
90
+ <template #cell-not-a-column="{ row }">{{ expectOptionalNumber(row.qty) }}</template>
91
+ </UTable>
92
+
93
+ <!-- Regression guard: an EMPTY `columns` array infers `TCol` as `never`. That is "not statically
94
+ known", not "no keys allowed" — every `cell-*` / `header-*` slot must still compile.
95
+ "columns load async, start empty" is the common real-world pattern; this shipped broken once. -->
96
+ <UTable :columns="emptyRefColumns" :rows="rows">
97
+ <template #cell-name="{ row }">{{ expectOptionalString(row.name) }}</template>
98
+ <template #header-name="{ column }">{{ expectString(column.key) }}</template>
99
+ </UTable>
100
+
101
+ <UTable :columns="emptyInferredRefColumns" :rows="rows">
102
+ <template #cell-name="{ row }">{{ expectOptionalString(row.name) }}</template>
103
+ <template #header-name="{ column }">{{ expectString(column.key) }}</template>
104
+ </UTable>
105
+
106
+ <UTable :columns="emptyComputedColumns" :rows="rows">
107
+ <template #cell-name="{ row }">{{ expectOptionalString(row.name) }}</template>
108
+ <template #header-name="{ column }">{{ expectString(column.key) }}</template>
109
+ </UTable>
110
+
111
+ <UTable :columns="emptyConstColumns" :rows="rows">
112
+ <template #cell-name="{ row }">{{ expectOptionalString(row.name) }}</template>
113
+ <template #header-name="{ column }">{{ expectString(column.key) }}</template>
114
+ </UTable>
115
+
116
+ <!-- Regression guard: a row slot must never promise the caller's members as present.
117
+ `getFlatRows` recurses into `row.row`, and a nested child is typed `TableRow`, not `TRow` —
118
+ `#nested-row` in particular fires ONLY for such children. -->
119
+ <UTable :columns="columns" :rows="nestedRows">
120
+ <template #nested-row="{ row }">
121
+ <!-- @vue-expect-error a flattened nested child need not carry `name`. -->
122
+ {{ expectString(row.name) }}
123
+ </template>
124
+ <template #expand="{ row }">
125
+ <!-- @vue-expect-error a flattened nested child need not carry `qty`. -->
126
+ {{ expectNumber(row.qty) }}
127
+ </template>
128
+ <template #cell-name="{ row }">
129
+ <!-- @vue-expect-error a flattened nested child need not carry `name`. -->
130
+ {{ expectString(row.name) }}
131
+ </template>
132
+ <template #header-actions="{ selectedRows }">
133
+ <!-- @vue-expect-error select-all flattens nested children into the selection. -->
134
+ {{ expectString(selectedRows[0].name) }}
135
+ </template>
136
+ </UTable>
137
+ </template>
138
+
139
+ <script setup lang="ts">
140
+ import { computed, ref } from "vue";
141
+
142
+ import UTable from "../UTable.vue";
143
+
144
+ import type { Column } from "../types";
145
+
146
+ interface UserRow {
147
+ id: number;
148
+ name: string;
149
+ qty: number;
150
+ }
151
+
152
+ const columns = ["name", "qty"] as const;
153
+ const objectColumns = [{ key: "name" }, { key: "qty" }] as const;
154
+
155
+ /* Non-literal column sources — slot keys must degrade to `cell-${string}`. */
156
+ const dynamicColumns = ref<Column[]>([]);
157
+ const looseColumns: string[] = ["name", "qty"];
158
+ const widenedColumns = ["name", "qty"];
159
+
160
+ /* Empty column sources — `TCol` infers as `never` and must degrade, not collapse.
161
+ The inline `:columns="[]"` form lives in `UTableEmptyColumnsTypes.vue`: alongside other
162
+ `#cell-*` call sites here, Vue reuses a widened instantiation and the guard goes vacuous. */
163
+ const emptyRefColumns = ref<Column[]>([]);
164
+ const emptyInferredRefColumns = ref([]);
165
+ const emptyComputedColumns = computed(() => []);
166
+ const emptyConstColumns = [] as const;
167
+
168
+ const rows = [{ id: 1, name: "a", qty: 2 }];
169
+ const typedRows = ref<UserRow[]>([]);
170
+
171
+ /* A parent whose child lacks the parent's members — exactly what `getFlatRows` emits. */
172
+ const nestedRows = [{ id: 1, name: "parent", qty: 2, row: [{ id: 2, label: "child" }] }];
173
+
174
+ function expectString(value: string) {
175
+ return value;
176
+ }
177
+
178
+ function expectNumber(value: number) {
179
+ return value;
180
+ }
181
+
182
+ function expectBoolean(value: boolean) {
183
+ return value;
184
+ }
185
+
186
+ function expectOptionalString(value: string | undefined) {
187
+ return value;
188
+ }
189
+
190
+ function expectOptionalNumber(value: number | undefined) {
191
+ return value;
192
+ }
193
+ </script>
@@ -25,20 +25,30 @@ export interface RowData {
25
25
  [key: string]: Cell;
26
26
  }
27
27
 
28
- export interface DateDivider {
28
+ export interface BaseDateDivider {
29
29
  date: Date | string;
30
30
  label?: string;
31
31
  config?: ComponentConfig<UDividerConfig>;
32
32
  }
33
33
 
34
- export interface Row {
34
+ /* Any object shape is a valid divider, only reserved divider keys are type checked. */
35
+ export type DateDivider = BaseDateDivider & (object | UnknownObject);
36
+
37
+ export interface BaseRow {
35
38
  id: RowId;
36
39
  rowDate?: string | Date;
37
- row?: Row | Row[];
40
+ row?: TableRow | TableRow[];
38
41
  class?: string | ((row: Row) => string);
42
+ }
43
+
44
+ export interface Row extends BaseRow {
45
+ row?: Row | Row[];
39
46
  [key: string]: unknown;
40
47
  }
41
48
 
49
+ /* Any object shape is a valid row, only reserved row keys are type checked. */
50
+ export type TableRow = BaseRow & (object | UnknownObject);
51
+
42
52
  export interface FlatRow extends Row {
43
53
  parentRowId?: RowId;
44
54
  nestedLevel: number;
@@ -58,23 +68,30 @@ export interface ColumnObject {
58
68
  thClass?: string;
59
69
  }
60
70
 
61
- export type Column = ColumnObject | string;
71
+ /* Any object shape is a valid column, only reserved column keys are type checked. */
72
+ export type TableColumn = ColumnObject & (object | UnknownObject);
62
73
 
63
- export interface Props {
74
+ export type Column = TableColumn | string;
75
+
76
+ /* A column entry that keeps its key literal, so slot keys can be narrowed at the call site. */
77
+ export type KeyedColumn<TCol extends string> = TCol | (TableColumn & { key: TCol });
78
+
79
+ export interface Props<TRow extends TableRow = TableRow, TCol extends string = string> {
64
80
  /**
65
81
  * Table columns (headers).
82
+ * `readonly` so `as const` column lists keep their literal keys and narrow the cell slot names.
66
83
  */
67
- columns: Column[];
84
+ columns: readonly KeyedColumn<TCol>[];
68
85
 
69
86
  /**
70
87
  * Table rows data.
71
88
  */
72
- rows: Row[];
89
+ rows: TRow[];
73
90
 
74
91
  /**
75
92
  * Selected rows.
76
93
  */
77
- selectedRows?: Row[];
94
+ selectedRows?: TRow[];
78
95
 
79
96
  /**
80
97
  * Selected rows id.
@@ -177,6 +194,72 @@ export interface Props {
177
194
  dataTest?: string | null;
178
195
  }
179
196
 
197
+ /* Drops `Row`'s `[key: string]: unknown` so slot rows only expose statically known keys. */
198
+ type KnownKeys<T> = {
199
+ [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K];
200
+ };
201
+
202
+ /**
203
+ * Row shape exposed to slots: the caller's row plus the flattening metadata.
204
+ *
205
+ * `TRow`'s own keys are optional. Rows are flattened through `getFlatRows`, which recurses into
206
+ * `row.row` — and `BaseRow.row` is `TableRow | TableRow[]`, not `TRow`, so a nested child need not
207
+ * carry the parent's members. Every row slot (`cell-*`, `expand`, `nested-row`) receives those
208
+ * children verbatim; `nested-row` receives nothing else. `BaseRow`/`FlatRow` metadata
209
+ * (`id`, `nestedLevel`, …) is always set by the flattener, so it stays guaranteed.
210
+ */
211
+ export type SlotRow<TRow extends TableRow = TableRow> = Partial<KnownKeys<TRow>> &
212
+ KnownKeys<FlatRow>;
213
+
214
+ /* Selected rows carry the same guarantee: select-all flattens nested children into the selection. */
215
+ export type SelectedSlotRow<TRow extends TableRow = TableRow> = SlotRow<TRow>;
216
+
217
+ export interface UTableStaticSlots<TRow extends TableRow = TableRow> {
218
+ "header-counter"?: (props: { total: number }) => unknown;
219
+ "header-actions"?: (props: { selectedRows: SelectedSlotRow<TRow>[] }) => unknown;
220
+ "before-header"?: (props: { colsCount: number; classes?: string }) => unknown;
221
+ "after-last-row"?: (props: { colsCount: number; classes?: string }) => unknown;
222
+ "before-first-row"?: () => unknown;
223
+ "empty-state"?: () => unknown;
224
+ footer?: (props: { colsCount: number }) => unknown;
225
+ expand?: (props: { index: number; row: SlotRow<TRow>; expanded: boolean }) => unknown;
226
+ "nested-row"?: (props: { index: number; row: SlotRow<TRow>; nestedLevel: number }) => unknown;
227
+ }
228
+
229
+ /**
230
+ * Slot key suffix. Narrows to the literal column keys only when they are statically known —
231
+ * `columns` from a `ref`, an API, or a plain `string[]` widens `TCol` back to `string`, and an
232
+ * empty `columns` array infers `TCol` as `never`. Both mean "not statically known", so every
233
+ * `cell-*` / `header-*` slot must keep compiling.
234
+ *
235
+ * The `[TCol]` tuple wrapper is required: a naked `never` distributes to `never`, which would
236
+ * silently collapse the whole mapped type and reject every slot.
237
+ */
238
+ type SlotKeySuffix<TCol extends string> = [TCol] extends [never]
239
+ ? string
240
+ : string extends TCol
241
+ ? string
242
+ : TCol;
243
+
244
+ export type UTableDynamicSlots<TRow extends TableRow = TableRow, TCol extends string = string> = {
245
+ [K in `cell-${SlotKeySuffix<TCol>}`]?: (props: {
246
+ value: Cell;
247
+ row: SlotRow<TRow>;
248
+ index: number;
249
+ cellIndex: number;
250
+ }) => unknown;
251
+ } & {
252
+ [K in `header-${SlotKeySuffix<TCol>}`]?: (props: {
253
+ column: ColumnObject;
254
+ index: number;
255
+ }) => unknown;
256
+ };
257
+
258
+ export type UTableSlots<
259
+ TRow extends TableRow = TableRow,
260
+ TCol extends string = string,
261
+ > = UTableStaticSlots<TRow> & UTableDynamicSlots<TRow, TCol>;
262
+
180
263
  export interface UTableRowAttrs {
181
264
  bodyCellContentAttrs: Ref<UnknownObject>;
182
265
  bodyCellCheckboxAttrs: Ref<UnknownObject>;
@@ -1,6 +1,6 @@
1
1
  import type { Column, ColumnObject, FlatRow, Row, RowData, RowId } from "./types";
2
2
 
3
- export function normalizeColumns(columns: Column[]): ColumnObject[] {
3
+ export function normalizeColumns(columns: readonly Column[]): ColumnObject[] {
4
4
  return columns.map((column) =>
5
5
  typeof column === "string" ? { label: column, key: column } : column,
6
6
  );
@@ -1,4 +1,4 @@
1
- <script setup lang="ts">
1
+ <script setup lang="ts" generic="TOption extends ListboxOption">
2
2
  import { nextTick, computed, ref, useTemplateRef } from "vue";
3
3
  import { isEqual } from "lodash-es";
4
4
 
@@ -11,12 +11,13 @@ import UCollapsible from "../ui.container-collapsible/UCollapsible.vue";
11
11
  import defaultConfig from "./config";
12
12
  import { COMPONENT_NAME } from "./constants";
13
13
 
14
- import type { Props, Config } from "./types";
15
- import type { Option, SelectedValue } from "../ui.form-listbox/types";
14
+ import type { Props, Config, UDropdownSlots } from "./types";
15
+ import type { ComponentExposed } from "../types";
16
+ import type { Option, SelectedValue, ListboxOption } from "../ui.form-listbox/types";
16
17
 
17
18
  defineOptions({ inheritAttrs: false });
18
19
 
19
- const props = withDefaults(defineProps<Props>(), {
20
+ const props = withDefaults(defineProps<Props<TOption>>(), {
20
21
  ...getDefaults<Props, Config>(defaultConfig, COMPONENT_NAME),
21
22
  options: () => [],
22
23
  modelValue: "",
@@ -60,7 +61,10 @@ const emit = defineEmits([
60
61
  "update:search",
61
62
  ]);
62
63
 
63
- type UListboxRef = InstanceType<typeof UListbox>;
64
+ defineSlots<UDropdownSlots<TOption>>();
65
+
66
+ /* `UListbox` is generic, so its type is a function — `InstanceType` does not apply. */
67
+ type UListboxRef = ComponentExposed<typeof UListbox>;
64
68
  type UCollapsibleRef = InstanceType<typeof UCollapsible>;
65
69
 
66
70
  const isClickingOption = ref(false);
@@ -83,9 +87,12 @@ const dropdownSearch = computed({
83
87
  set: (value: string) => emit("update:search", value),
84
88
  });
85
89
 
90
+ /* Indexable view of the options, `labelKey` / `valueKey` lookups need an index signature. */
91
+ const localOptions = computed(() => props.options as Option[]);
92
+
86
93
  const selectedOptions = computed(() => {
87
94
  if (props.multiple) {
88
- return props.options.filter((option) => {
95
+ return localOptions.value.filter((option) => {
89
96
  return (
90
97
  option[props.valueKey] &&
91
98
  (dropdownValue.value as SelectedValue[]).find((selected) =>
@@ -96,12 +103,16 @@ const selectedOptions = computed(() => {
96
103
  }
97
104
 
98
105
  return [
99
- props.options.find(
106
+ localOptions.value.find(
100
107
  (option) => option[props.valueKey] && isEqual(option[props.valueKey], dropdownValue.value),
101
108
  ),
102
109
  ].filter((option) => !!option);
103
110
  });
104
111
 
112
+ /* `selectedOptions` filters `localOptions` (the indexable view of `props.options`) and injects
113
+ nothing, so every element is a `props.options` element — `TOption`, with its full guarantee. */
114
+ const slotSelectedOptions = computed(() => selectedOptions.value as TOption[]);
115
+
105
116
  const displayLabel = computed(() => {
106
117
  if (!props.labelDisplayCount || !selectedOptions.value.length) {
107
118
  return props.label;
@@ -253,7 +264,7 @@ const { getDataTest, dropdownAttrs, listboxAttrs } = useUI<Config>(
253
264
  :opened="opened"
254
265
  :display-label="displayLabel"
255
266
  :full-label="fullLabel"
256
- :selected-options="selectedOptions"
267
+ :selected-options="slotSelectedOptions"
257
268
  />
258
269
  </template>
259
270
 
@@ -6,7 +6,7 @@ import {
6
6
  getDocsDescription,
7
7
  } from "../../utils/storybook";
8
8
 
9
- import UDropdown from "../../ui.dropdown/UDropdown.vue";
9
+ import UDropdownComponent from "../../ui.dropdown/UDropdown.vue";
10
10
  import URow from "../../ui.container-row/URow.vue";
11
11
  import UCol from "../../ui.container-col/UCol.vue";
12
12
  import UButton from "../../ui.button/UButton.vue";
@@ -20,6 +20,9 @@ import UAvatar from "../../ui.image-avatar/UAvatar.vue";
20
20
  import type { Meta, StoryFn } from "@storybook/vue3-vite";
21
21
  import type { Props } from "../types";
22
22
 
23
+ /* Generic SFCs are typed as a function, which drops the compiler-generated `__name`. */
24
+ const UDropdown = UDropdownComponent as typeof UDropdownComponent & { __name: string };
25
+
23
26
  interface DefaultUDropdownArgs extends Props {
24
27
  slotTemplate?: string;
25
28
  }