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.
- package/README.md +1 -1
- package/composables/useUI.ts +19 -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.container-drawer/tests/UDrawer.test.ts +10 -17
- package/ui.container-modal/tests/UModal.test.ts +10 -17
- package/ui.data-list/UDataList.vue +48 -17
- package/ui.data-list/config.ts +2 -2
- package/ui.data-list/storybook/stories.ts +5 -2
- package/ui.data-list/tests/UDataList.test-d.ts +76 -0
- package/ui.data-list/tests/UDataList.test.ts +52 -3
- package/ui.data-list/tests/UDataListTypes.vue +110 -0
- package/ui.data-list/types.ts +37 -5
- package/ui.data-table/UTable.vue +202 -24
- package/ui.data-table/UTableRow.vue +4 -3
- package/ui.data-table/config.ts +11 -2
- package/ui.data-table/storybook/stories.ts +34 -1
- package/ui.data-table/tests/UTable.test-d.ts +179 -0
- package/ui.data-table/tests/UTable.test.ts +85 -7
- package/ui.data-table/tests/UTableEmptyColumnsTypes.vue +27 -0
- package/ui.data-table/tests/UTableTypes.vue +193 -0
- package/ui.data-table/types.ts +106 -9
- 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/UInput.vue +1 -0
- package/ui.form-input/config.ts +1 -1
- package/ui.form-input/tests/UInput.test.ts +16 -0
- 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-tab/UTab.vue +14 -9
- package/ui.navigation-tab/tests/UTab.test.ts +14 -0
- package/ui.navigation-tabs/UTabs.vue +158 -6
- package/ui.navigation-tabs/config.ts +2 -1
- package/ui.navigation-tabs/constants.ts +2 -0
- package/ui.navigation-tabs/storybook/stories.ts +12 -1
- package/ui.navigation-tabs/tests/UTabs.test-d.ts +50 -0
- package/ui.navigation-tabs/tests/UTabs.test.ts +213 -1
- package/ui.navigation-tabs/tests/UTabsTypes.vue +65 -0
- package/ui.navigation-tabs/types.ts +26 -4
- 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
package/ui.data-table/types.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import defaultConfig from "./config";
|
|
2
|
-
|
|
3
2
|
import type { Ref } from "vue";
|
|
4
3
|
import type { ComponentConfig, UnknownObject } from "../types";
|
|
5
4
|
import type { Config as UDividerConfig } from "../ui.container-divider/types";
|
|
@@ -26,20 +25,30 @@ export interface RowData {
|
|
|
26
25
|
[key: string]: Cell;
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
export interface
|
|
28
|
+
export interface BaseDateDivider {
|
|
30
29
|
date: Date | string;
|
|
31
30
|
label?: string;
|
|
32
31
|
config?: ComponentConfig<UDividerConfig>;
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
|
|
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 {
|
|
36
38
|
id: RowId;
|
|
37
39
|
rowDate?: string | Date;
|
|
38
|
-
row?:
|
|
40
|
+
row?: TableRow | TableRow[];
|
|
39
41
|
class?: string | ((row: Row) => string);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Row extends BaseRow {
|
|
45
|
+
row?: Row | Row[];
|
|
40
46
|
[key: string]: unknown;
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
/* Any object shape is a valid row, only reserved row keys are type checked. */
|
|
50
|
+
export type TableRow = BaseRow & (object | UnknownObject);
|
|
51
|
+
|
|
43
52
|
export interface FlatRow extends Row {
|
|
44
53
|
parentRowId?: RowId;
|
|
45
54
|
nestedLevel: number;
|
|
@@ -59,23 +68,30 @@ export interface ColumnObject {
|
|
|
59
68
|
thClass?: string;
|
|
60
69
|
}
|
|
61
70
|
|
|
62
|
-
|
|
71
|
+
/* Any object shape is a valid column, only reserved column keys are type checked. */
|
|
72
|
+
export type TableColumn = ColumnObject & (object | UnknownObject);
|
|
73
|
+
|
|
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 });
|
|
63
78
|
|
|
64
|
-
export interface Props {
|
|
79
|
+
export interface Props<TRow extends TableRow = TableRow, TCol extends string = string> {
|
|
65
80
|
/**
|
|
66
81
|
* Table columns (headers).
|
|
82
|
+
* `readonly` so `as const` column lists keep their literal keys and narrow the cell slot names.
|
|
67
83
|
*/
|
|
68
|
-
columns:
|
|
84
|
+
columns: readonly KeyedColumn<TCol>[];
|
|
69
85
|
|
|
70
86
|
/**
|
|
71
87
|
* Table rows data.
|
|
72
88
|
*/
|
|
73
|
-
rows:
|
|
89
|
+
rows: TRow[];
|
|
74
90
|
|
|
75
91
|
/**
|
|
76
92
|
* Selected rows.
|
|
77
93
|
*/
|
|
78
|
-
selectedRows?:
|
|
94
|
+
selectedRows?: TRow[];
|
|
79
95
|
|
|
80
96
|
/**
|
|
81
97
|
* Selected rows id.
|
|
@@ -117,6 +133,21 @@ export interface Props {
|
|
|
117
133
|
*/
|
|
118
134
|
loading?: boolean;
|
|
119
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Show skeleton body for the first table load instead of the regular loader.
|
|
138
|
+
*/
|
|
139
|
+
skeletonLoading?: boolean;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Number of skeleton rows shown during initial loading.
|
|
143
|
+
*/
|
|
144
|
+
skeletonRows?: number;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Skeleton cell width classes used to vary the loading placeholder layout.
|
|
148
|
+
*/
|
|
149
|
+
skeletonWidths?: string[];
|
|
150
|
+
|
|
120
151
|
/**
|
|
121
152
|
* Enable virtual scrolling for large datasets.
|
|
122
153
|
*/
|
|
@@ -163,6 +194,72 @@ export interface Props {
|
|
|
163
194
|
dataTest?: string | null;
|
|
164
195
|
}
|
|
165
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
|
+
|
|
166
263
|
export interface UTableRowAttrs {
|
|
167
264
|
bodyCellContentAttrs: Ref<UnknownObject>;
|
|
168
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
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { Props, UDropdownSlots } from "../types";
|
|
4
|
+
import type { ListboxOption, SlotOption } from "../../ui.form-listbox/types";
|
|
5
|
+
|
|
6
|
+
interface UserOption {
|
|
7
|
+
label: string;
|
|
8
|
+
value: string;
|
|
9
|
+
qty: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type OptionProps = Parameters<NonNullable<UDropdownSlots<UserOption>["option"]>>[0];
|
|
13
|
+
type DefaultProps = Parameters<NonNullable<UDropdownSlots<UserOption>["default"]>>[0];
|
|
14
|
+
|
|
15
|
+
describe("UDropdown types", () => {
|
|
16
|
+
it("infers the caller's option shape in a forwarded option slot", () => {
|
|
17
|
+
expectTypeOf<OptionProps["option"]["label"]>().toEqualTypeOf<string | undefined>();
|
|
18
|
+
expectTypeOf<OptionProps["option"]["qty"]>().toEqualTypeOf<number | undefined>();
|
|
19
|
+
expectTypeOf<OptionProps["index"]>().toEqualTypeOf<number>();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("does not expose undeclared option properties to a slot", () => {
|
|
23
|
+
type HasIndexSignature = string extends keyof OptionProps["option"] ? true : false;
|
|
24
|
+
|
|
25
|
+
expectTypeOf<HasIndexSignature>().toEqualTypeOf<false>();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("reuses UListbox's SlotOption rather than a second dialect", () => {
|
|
29
|
+
expectTypeOf<OptionProps["option"]>().toEqualTypeOf<SlotOption<UserOption>>();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("does not promise the caller's members on any option slot", () => {
|
|
33
|
+
/* The option slots are forwarded to `UListbox`, whose `filterGroups` concats nested group
|
|
34
|
+
* members into the flat list — a member need not carry the parent's members. */
|
|
35
|
+
type BeforeProps = Parameters<NonNullable<UDropdownSlots<UserOption>["before-option"]>>[0];
|
|
36
|
+
type AfterProps = Parameters<NonNullable<UDropdownSlots<UserOption>["after-option"]>>[0];
|
|
37
|
+
|
|
38
|
+
type GroupMember = { value: string; title: string };
|
|
39
|
+
|
|
40
|
+
expectTypeOf<GroupMember>().toExtend<BeforeProps["option"]>();
|
|
41
|
+
expectTypeOf<GroupMember>().toExtend<OptionProps["option"]>();
|
|
42
|
+
expectTypeOf<GroupMember>().toExtend<AfterProps["option"]>();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("keeps the full guarantee on selectedOptions, which is a plain subset", () => {
|
|
46
|
+
/* `selectedOptions` filters `props.options` and injects nothing (`UDropdown.vue:93`), so
|
|
47
|
+
* unlike the option slots it is NOT Partial. */
|
|
48
|
+
expectTypeOf<DefaultProps["selectedOptions"]>().toEqualTypeOf<UserOption[]>();
|
|
49
|
+
expectTypeOf<DefaultProps["selectedOptions"][number]["label"]>().toEqualTypeOf<string>();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("types displayLabel as possibly undefined", () => {
|
|
53
|
+
/* It joins `option[labelKey]` lookups, which are `undefined` for an option without that key. */
|
|
54
|
+
expectTypeOf<DefaultProps["displayLabel"]>().toEqualTypeOf<string | undefined>();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("resolves Props with no type argument", () => {
|
|
58
|
+
expectTypeOf<Props>().toEqualTypeOf<Props<ListboxOption>>();
|
|
59
|
+
expectTypeOf<Props<UserOption>["options"]>().toEqualTypeOf<UserOption[] | undefined>();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -3,7 +3,14 @@ import { describe, it, expect } from "vitest";
|
|
|
3
3
|
import { nextTick } from "vue";
|
|
4
4
|
|
|
5
5
|
import UDropdown from "../UDropdown.vue";
|
|
6
|
-
import
|
|
6
|
+
import UListboxComponent from "../../ui.form-listbox/UListbox.vue";
|
|
7
|
+
|
|
8
|
+
import type { ComponentProps } from "../../types";
|
|
9
|
+
|
|
10
|
+
/* Generic SFCs are typed as a function, which `findComponent` does not accept. */
|
|
11
|
+
const UListbox = UListboxComponent as unknown as new () => {
|
|
12
|
+
$props: ComponentProps<typeof UListboxComponent>;
|
|
13
|
+
};
|
|
7
14
|
|
|
8
15
|
describe("UDropdown.vue", () => {
|
|
9
16
|
const defaultOptions = [
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<!-- Type-level fixture: checked by `npm run ts:check`, never mounted.
|
|
2
|
+
Proves slot props infer from the `options` prop at a real call site. -->
|
|
3
|
+
<template>
|
|
4
|
+
<UDropdown :options="options">
|
|
5
|
+
<!-- Forwarded verbatim to `UListbox`, so they inherit its group flattening — a flattened
|
|
6
|
+
member need not carry the parent's members, hence the optional types. -->
|
|
7
|
+
<template #before-option="{ option, index }">
|
|
8
|
+
{{ expectOptionalString(option.label) }}
|
|
9
|
+
{{ expectOptionalNumber(option.qty) }}
|
|
10
|
+
{{ expectNumber(index) }}
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<template #option="{ option, index }">
|
|
14
|
+
{{ expectOptionalString(option.label) }}
|
|
15
|
+
{{ expectNumber(index) }}
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<template #after-option="{ option, index }">
|
|
19
|
+
{{ expectOptionalString(option.label) }}
|
|
20
|
+
{{ expectNumber(index) }}
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<!-- `selectedOptions` is a `filter`ed subset of `props.options` with nothing injected, so it
|
|
24
|
+
keeps the full (non-Partial) guarantee — unlike the option slots above. -->
|
|
25
|
+
<template #default="{ selectedOptions, opened }">
|
|
26
|
+
{{ expectString(selectedOptions[0].label) }}
|
|
27
|
+
{{ expectNumber(selectedOptions[0].qty) }}
|
|
28
|
+
{{ expectBoolean(opened) }}
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<template #empty>empty</template>
|
|
32
|
+
</UDropdown>
|
|
33
|
+
|
|
34
|
+
<!-- Options from a non-literal source still expose the declared option shape. -->
|
|
35
|
+
<UDropdown :options="typedOptions">
|
|
36
|
+
<template #option="{ option }">{{ expectOptionalString(option.label) }}</template>
|
|
37
|
+
</UDropdown>
|
|
38
|
+
|
|
39
|
+
<UDropdown :options="options">
|
|
40
|
+
<template #option="{ option }">
|
|
41
|
+
<!-- @vue-expect-error `nonExistent` is not a key of the inferred option. -->
|
|
42
|
+
{{ option.nonExistent }}
|
|
43
|
+
</template>
|
|
44
|
+
</UDropdown>
|
|
45
|
+
|
|
46
|
+
<!-- Regression guard: an option slot must never promise the caller's members as present. -->
|
|
47
|
+
<UDropdown
|
|
48
|
+
:options="groupedOptions"
|
|
49
|
+
group-label-key="groupName"
|
|
50
|
+
group-value-key="children"
|
|
51
|
+
label-key="label"
|
|
52
|
+
>
|
|
53
|
+
<template #option="{ option }">
|
|
54
|
+
<!-- @vue-expect-error a flattened group member need not carry `label`. -->
|
|
55
|
+
{{ expectString(option.label) }}
|
|
56
|
+
</template>
|
|
57
|
+
<template #before-option="{ option }">
|
|
58
|
+
<!-- @vue-expect-error a flattened group member need not carry `qty`. -->
|
|
59
|
+
{{ expectNumber(option.qty) }}
|
|
60
|
+
</template>
|
|
61
|
+
<template #after-option="{ option }">
|
|
62
|
+
<!-- @vue-expect-error a flattened group member need not carry `groupName`. -->
|
|
63
|
+
{{ expectString(option.groupName) }}
|
|
64
|
+
</template>
|
|
65
|
+
</UDropdown>
|
|
66
|
+
</template>
|
|
67
|
+
|
|
68
|
+
<script setup lang="ts">
|
|
69
|
+
import { ref } from "vue";
|
|
70
|
+
|
|
71
|
+
import UDropdown from "../UDropdown.vue";
|
|
72
|
+
|
|
73
|
+
interface UserOption {
|
|
74
|
+
label: string;
|
|
75
|
+
value: string;
|
|
76
|
+
qty: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const options = [{ label: "a", value: "a", qty: 1 }];
|
|
80
|
+
const typedOptions = ref<UserOption[]>([]);
|
|
81
|
+
|
|
82
|
+
/* A group whose members lack the parent's members — exactly what `filterGroups` flattens. */
|
|
83
|
+
const groupedOptions = [
|
|
84
|
+
{
|
|
85
|
+
groupName: "Group A",
|
|
86
|
+
label: "Group A",
|
|
87
|
+
qty: 1,
|
|
88
|
+
children: [{ value: "c1", title: "Child" }],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
function expectString(value: string) {
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function expectNumber(value: number) {
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function expectBoolean(value: boolean) {
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function expectOptionalString(value: string | undefined) {
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function expectOptionalNumber(value: number | undefined) {
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
</script>
|
package/ui.dropdown/types.ts
CHANGED
|
@@ -1,11 +1,35 @@
|
|
|
1
1
|
import defaultConfig from "./config";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { ListboxOption, SlotOption } from "../ui.form-listbox/types";
|
|
4
4
|
import type { ComponentConfig, UnknownObject } from "../types";
|
|
5
5
|
|
|
6
6
|
export type Config = typeof defaultConfig;
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The option slots are forwarded verbatim to `UListbox`, so they inherit its flattening: with
|
|
10
|
+
* `groupValueKey` set, `filterGroups` concats nested group members into the list the slots iterate
|
|
11
|
+
* and those members need not carry the parent's members. Hence `SlotOption`, which is `Partial`.
|
|
12
|
+
*
|
|
13
|
+
* `selectedOptions` is different — it is a `filter`ed subset of `props.options` (`UDropdown.vue:91`)
|
|
14
|
+
* with no synthetic entry, so it keeps the full `TOption` guarantee.
|
|
15
|
+
*/
|
|
16
|
+
export interface UDropdownSlots<TOption extends ListboxOption = ListboxOption> {
|
|
17
|
+
default?: (props: {
|
|
18
|
+
/* `displayLabel` joins `option[labelKey]` lookups, which yield `undefined` for an option
|
|
19
|
+
without that key — so it is only `string` when the label is resolvable. */
|
|
20
|
+
opened: boolean;
|
|
21
|
+
displayLabel: string | undefined;
|
|
22
|
+
fullLabel: string;
|
|
23
|
+
selectedOptions: TOption[];
|
|
24
|
+
}) => unknown;
|
|
25
|
+
dropdown?: (props: { opened: boolean; contentClasses: string | undefined }) => unknown;
|
|
26
|
+
"before-option"?: (props: { option: SlotOption<TOption>; index: number }) => unknown;
|
|
27
|
+
option?: (props: { option: SlotOption<TOption>; index: number }) => unknown;
|
|
28
|
+
"after-option"?: (props: { option: SlotOption<TOption>; index: number }) => unknown;
|
|
29
|
+
empty?: () => unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Props<TOption extends ListboxOption = ListboxOption> {
|
|
9
33
|
/**
|
|
10
34
|
* Selected dropdown value.
|
|
11
35
|
*/
|
|
@@ -24,7 +48,7 @@ export interface Props {
|
|
|
24
48
|
/**
|
|
25
49
|
* Options list.
|
|
26
50
|
*/
|
|
27
|
-
options?:
|
|
51
|
+
options?: TOption[];
|
|
28
52
|
|
|
29
53
|
/**
|
|
30
54
|
* Label key in the item object of options.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
1
|
+
<script setup lang="ts" generic="TOption extends ListboxOption">
|
|
2
2
|
import { computed, useTemplateRef } from "vue";
|
|
3
3
|
|
|
4
4
|
import { useUI } from "../composables/useUI";
|
|
@@ -11,11 +11,13 @@ import UDropdown from "../ui.dropdown/UDropdown.vue";
|
|
|
11
11
|
import defaultConfig from "./config";
|
|
12
12
|
import { COMPONENT_NAME } from "./constants";
|
|
13
13
|
|
|
14
|
-
import type { Props, Config } from "./types";
|
|
14
|
+
import type { Props, Config, UDropdownBadgeSlots } from "./types";
|
|
15
|
+
import type { ListboxOption } from "../ui.form-listbox/types";
|
|
16
|
+
import type { ComponentExposed } from "../types";
|
|
15
17
|
|
|
16
18
|
defineOptions({ inheritAttrs: false });
|
|
17
19
|
|
|
18
|
-
const props = withDefaults(defineProps<Props
|
|
20
|
+
const props = withDefaults(defineProps<Props<TOption>>(), {
|
|
19
21
|
...getDefaults<Props, Config>(defaultConfig, COMPONENT_NAME),
|
|
20
22
|
options: () => [],
|
|
21
23
|
modelValue: "",
|
|
@@ -59,7 +61,10 @@ const emit = defineEmits([
|
|
|
59
61
|
"update:search",
|
|
60
62
|
]);
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
defineSlots<UDropdownBadgeSlots<TOption>>();
|
|
65
|
+
|
|
66
|
+
/* `UDropdown` is generic, so its type is a function — `InstanceType` does not apply. */
|
|
67
|
+
type UDropdownRef = ComponentExposed<typeof UDropdown>;
|
|
63
68
|
|
|
64
69
|
const dropdownRef = useTemplateRef<UDropdownRef>("dropdown");
|
|
65
70
|
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
getDocsDescription,
|
|
7
7
|
} from "../../utils/storybook";
|
|
8
8
|
|
|
9
|
-
import
|
|
9
|
+
import UDropdownBadgeComponent from "../../ui.dropdown-badge/UDropdownBadge.vue";
|
|
10
10
|
import URow from "../../ui.container-row/URow.vue";
|
|
11
11
|
import UCol from "../../ui.container-col/UCol.vue";
|
|
12
12
|
import UIcon from "../../ui.image-icon/UIcon.vue";
|
|
@@ -24,6 +24,11 @@ import emilyDavis from "../../ui.form-select/storybook/assets/images/emily-davis
|
|
|
24
24
|
import alexJohnson from "../../ui.form-select/storybook/assets/images/alex-johnson.png";
|
|
25
25
|
import patMorgan from "../../ui.form-select/storybook/assets/images/pat-morgan.png";
|
|
26
26
|
|
|
27
|
+
/* Generic SFCs are typed as a function, which drops the compiler-generated `__name`. */
|
|
28
|
+
const UDropdownBadge = UDropdownBadgeComponent as typeof UDropdownBadgeComponent & {
|
|
29
|
+
__name: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
27
32
|
interface DefaultUDropdownBadgeArgs extends Props {
|
|
28
33
|
slotTemplate?: string;
|
|
29
34
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { Props, UDropdownBadgeSlots } from "../types";
|
|
4
|
+
import type { ListboxOption, SlotOption } from "../../ui.form-listbox/types";
|
|
5
|
+
|
|
6
|
+
interface UserOption {
|
|
7
|
+
label: string;
|
|
8
|
+
value: string;
|
|
9
|
+
qty: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type OptionProps = Parameters<NonNullable<UDropdownBadgeSlots<UserOption>["option"]>>[0];
|
|
13
|
+
|
|
14
|
+
describe("UDropdownBadge types", () => {
|
|
15
|
+
it("infers the caller's option shape in a forwarded option slot", () => {
|
|
16
|
+
expectTypeOf<OptionProps["option"]["label"]>().toEqualTypeOf<string | undefined>();
|
|
17
|
+
expectTypeOf<OptionProps["option"]["qty"]>().toEqualTypeOf<number | undefined>();
|
|
18
|
+
expectTypeOf<OptionProps["index"]>().toEqualTypeOf<number>();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("does not expose undeclared option properties to a slot", () => {
|
|
22
|
+
type HasIndexSignature = string extends keyof OptionProps["option"] ? true : false;
|
|
23
|
+
|
|
24
|
+
expectTypeOf<HasIndexSignature>().toEqualTypeOf<false>();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("reuses UListbox's SlotOption rather than a second dialect", () => {
|
|
28
|
+
expectTypeOf<OptionProps["option"]>().toEqualTypeOf<SlotOption<UserOption>>();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("does not promise the caller's members on any option slot", () => {
|
|
32
|
+
/* Forwarded through `UDropdown` to `UListbox`, whose `filterGroups` concats nested group
|
|
33
|
+
* members into the flat list — a member need not carry the parent's members. */
|
|
34
|
+
type BeforeProps = Parameters<NonNullable<UDropdownBadgeSlots<UserOption>["before-option"]>>[0];
|
|
35
|
+
type AfterProps = Parameters<NonNullable<UDropdownBadgeSlots<UserOption>["after-option"]>>[0];
|
|
36
|
+
|
|
37
|
+
type GroupMember = { value: string; title: string };
|
|
38
|
+
|
|
39
|
+
expectTypeOf<GroupMember>().toExtend<BeforeProps["option"]>();
|
|
40
|
+
expectTypeOf<GroupMember>().toExtend<OptionProps["option"]>();
|
|
41
|
+
expectTypeOf<GroupMember>().toExtend<AfterProps["option"]>();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("types the default slot label as possibly undefined", () => {
|
|
45
|
+
/* It is `UDropdown`'s `displayLabel`, a join of `option[labelKey]` lookups. */
|
|
46
|
+
type DefaultProps = Parameters<NonNullable<UDropdownBadgeSlots<UserOption>["default"]>>[0];
|
|
47
|
+
|
|
48
|
+
expectTypeOf<DefaultProps["label"]>().toEqualTypeOf<string | undefined>();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("resolves Props with no type argument", () => {
|
|
52
|
+
expectTypeOf<Props>().toEqualTypeOf<Props<ListboxOption>>();
|
|
53
|
+
expectTypeOf<Props<UserOption>["options"]>().toEqualTypeOf<UserOption[] | undefined>();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -4,10 +4,17 @@ import { describe, it, expect } from "vitest";
|
|
|
4
4
|
import UDropdownBadge from "../UDropdownBadge.vue";
|
|
5
5
|
import UBadge from "../../ui.text-badge/UBadge.vue";
|
|
6
6
|
import UIcon from "../../ui.image-icon/UIcon.vue";
|
|
7
|
-
import
|
|
7
|
+
import UListboxComponent from "../../ui.form-listbox/UListbox.vue";
|
|
8
8
|
|
|
9
9
|
import type { Props } from "../types";
|
|
10
10
|
|
|
11
|
+
import type { ComponentProps } from "../../types";
|
|
12
|
+
|
|
13
|
+
/* Generic SFCs are typed as a function, which `findComponent` does not accept. */
|
|
14
|
+
const UListbox = UListboxComponent as unknown as new () => {
|
|
15
|
+
$props: ComponentProps<typeof UListboxComponent>;
|
|
16
|
+
};
|
|
17
|
+
|
|
11
18
|
describe("UDropdownBadge.vue", () => {
|
|
12
19
|
const defaultOptions = [
|
|
13
20
|
{ value: 1, label: "Option 1" },
|