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.
- package/README.md +1 -1
- package/index.d.ts +128 -0
- package/index.ts +128 -0
- package/package.json +10 -9
- package/ui.button/config.ts +1 -1
- package/ui.button-link/config.ts +1 -1
- package/ui.button-toggle/UToggle.vue +6 -4
- package/ui.button-toggle/storybook/stories.ts +4 -1
- package/ui.button-toggle/tests/UToggle.test-d.ts +48 -0
- package/ui.button-toggle/tests/UToggleTypes.vue +55 -0
- package/ui.button-toggle/types.ts +18 -6
- package/ui.container-accordion/types.ts +5 -2
- package/ui.container-collapsible/config.ts +1 -1
- package/ui.data-list/UDataList.vue +48 -17
- package/ui.data-list/storybook/stories.ts +4 -1
- package/ui.data-list/tests/UDataList.test-d.ts +76 -0
- package/ui.data-list/tests/UDataList.test.ts +50 -1
- package/ui.data-list/tests/UDataListTypes.vue +110 -0
- package/ui.data-list/types.ts +37 -5
- package/ui.data-table/UTable.vue +68 -10
- package/ui.data-table/UTableRow.vue +4 -3
- package/ui.data-table/config.ts +1 -1
- package/ui.data-table/storybook/stories.ts +4 -1
- package/ui.data-table/tests/UTable.test-d.ts +179 -0
- package/ui.data-table/tests/UTable.test.ts +1 -1
- package/ui.data-table/tests/UTableEmptyColumnsTypes.vue +27 -0
- package/ui.data-table/tests/UTableTypes.vue +193 -0
- package/ui.data-table/types.ts +91 -8
- package/ui.data-table/utilTable.ts +1 -1
- package/ui.dropdown/UDropdown.vue +19 -8
- package/ui.dropdown/storybook/stories.ts +4 -1
- package/ui.dropdown/tests/UDropdown.test-d.ts +61 -0
- package/ui.dropdown/tests/UDropdown.test.ts +8 -1
- package/ui.dropdown/tests/UDropdownTypes.vue +111 -0
- package/ui.dropdown/types.ts +27 -3
- package/ui.dropdown-badge/UDropdownBadge.vue +9 -4
- package/ui.dropdown-badge/storybook/stories.ts +6 -1
- package/ui.dropdown-badge/tests/UDropdownBadge.test-d.ts +55 -0
- package/ui.dropdown-badge/tests/UDropdownBadge.test.ts +8 -1
- package/ui.dropdown-badge/tests/UDropdownBadgeTypes.vue +107 -0
- package/ui.dropdown-badge/types.ts +17 -3
- package/ui.dropdown-button/UDropdownButton.vue +9 -4
- package/ui.dropdown-button/storybook/stories.ts +6 -1
- package/ui.dropdown-button/tests/UDropdownButton.test-d.ts +57 -0
- package/ui.dropdown-button/tests/UDropdownButton.test.ts +8 -1
- package/ui.dropdown-button/tests/UDropdownButtonTypes.vue +107 -0
- package/ui.dropdown-button/types.ts +17 -3
- package/ui.dropdown-link/UDropdownLink.vue +9 -4
- package/ui.dropdown-link/config.ts +1 -1
- package/ui.dropdown-link/storybook/stories.ts +4 -1
- package/ui.dropdown-link/tests/UDropdownLink.test-d.ts +55 -0
- package/ui.dropdown-link/tests/UDropdownLink.test.ts +8 -1
- package/ui.dropdown-link/tests/UDropdownLinkTypes.vue +107 -0
- package/ui.dropdown-link/types.ts +17 -3
- package/ui.form-calendar/config.ts +1 -1
- package/ui.form-checkbox/config.ts +1 -1
- package/ui.form-checkbox/tests/UCheckbox.test.ts +4 -4
- package/ui.form-checkbox/types.ts +4 -2
- package/ui.form-checkbox-group/UCheckboxGroup.vue +8 -2
- package/ui.form-date-picker/UDatePicker.vue +11 -1
- package/ui.form-date-picker/tests/UDatePicker.test.ts +47 -0
- package/ui.form-input/config.ts +1 -1
- package/ui.form-input-file/config.ts +1 -1
- package/ui.form-listbox/UListbox.vue +29 -12
- package/ui.form-listbox/config.ts +1 -1
- package/ui.form-listbox/storybook/stories.ts +4 -1
- package/ui.form-listbox/tests/UListbox.test-d.ts +63 -0
- package/ui.form-listbox/tests/UListboxTypes.vue +107 -0
- package/ui.form-listbox/types.ts +34 -2
- package/ui.form-radio/config.ts +1 -1
- package/ui.form-radio-group/URadioGroup.vue +15 -3
- package/ui.form-radio-group/config.ts +8 -3
- package/ui.form-radio-group/types.ts +2 -3
- package/ui.form-select/USelect.vue +49 -17
- package/ui.form-select/config.ts +4 -4
- package/ui.form-select/storybook/stories.ts +4 -1
- package/ui.form-select/tests/USelect.test-d.ts +76 -0
- package/ui.form-select/tests/USelect.test.ts +65 -1
- package/ui.form-select/tests/USelectTypes.vue +113 -0
- package/ui.form-select/types.ts +55 -4
- package/ui.form-switch/USwitch.vue +13 -12
- package/ui.form-switch/config.ts +2 -1
- package/ui.form-switch/tests/USwitch.test.ts +151 -0
- package/ui.form-textarea/config.ts +1 -1
- package/ui.image-avatar/types.ts +5 -2
- package/ui.image-avatar-group/types.ts +5 -2
- package/ui.image-icon/UIcon.vue +3 -2
- package/ui.image-icon/types.ts +2 -2
- package/ui.navigation-breadcrumbs/types.ts +5 -2
- package/ui.navigation-tabs/UTabs.vue +6 -4
- package/ui.navigation-tabs/storybook/stories.ts +4 -1
- package/ui.navigation-tabs/tests/UTabs.test-d.ts +50 -0
- package/ui.navigation-tabs/tests/UTabsTypes.vue +65 -0
- package/ui.navigation-tabs/types.ts +25 -3
- package/ui.skeleton-text/constants.ts +1 -1
- package/ui.text-badge/config.ts +1 -1
- package/utils/node/loaderIcon.js +1 -1
- 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
|
|
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
|
+
});
|
|
@@ -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>
|
package/ui.data-table/types.ts
CHANGED
|
@@ -25,20 +25,30 @@ export interface RowData {
|
|
|
25
25
|
[key: string]: Cell;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export interface
|
|
28
|
+
export interface BaseDateDivider {
|
|
29
29
|
date: Date | string;
|
|
30
30
|
label?: string;
|
|
31
31
|
config?: ComponentConfig<UDividerConfig>;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
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?:
|
|
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
|
-
|
|
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
|
|
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:
|
|
84
|
+
columns: readonly KeyedColumn<TCol>[];
|
|
68
85
|
|
|
69
86
|
/**
|
|
70
87
|
* Table rows data.
|
|
71
88
|
*/
|
|
72
|
-
rows:
|
|
89
|
+
rows: TRow[];
|
|
73
90
|
|
|
74
91
|
/**
|
|
75
92
|
* Selected rows.
|
|
76
93
|
*/
|
|
77
|
-
selectedRows?:
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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="
|
|
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
|
|
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
|
}
|