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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
1
|
+
<script setup lang="ts" generic="TOption extends ListboxOption">
|
|
2
2
|
import { watch, computed, useId, ref, useTemplateRef, nextTick } from "vue";
|
|
3
3
|
import { isEqual } from "lodash-es";
|
|
4
4
|
|
|
@@ -18,12 +18,20 @@ import usePointer from "./usePointer";
|
|
|
18
18
|
import defaultConfig from "./config";
|
|
19
19
|
import { COMPONENT_NAME } from "./constants";
|
|
20
20
|
|
|
21
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
Option,
|
|
23
|
+
Props,
|
|
24
|
+
Config,
|
|
25
|
+
SelectedValue,
|
|
26
|
+
ListboxOption,
|
|
27
|
+
SlotOption,
|
|
28
|
+
UListboxSlots,
|
|
29
|
+
} from "./types";
|
|
22
30
|
|
|
23
31
|
defineOptions({ inheritAttrs: false });
|
|
24
32
|
|
|
25
|
-
const props = withDefaults(defineProps<Props
|
|
26
|
-
...getDefaults<Props
|
|
33
|
+
const props = withDefaults(defineProps<Props<TOption>>(), {
|
|
34
|
+
...getDefaults<Props<TOption>, Config>(defaultConfig, COMPONENT_NAME),
|
|
27
35
|
modelValue: "",
|
|
28
36
|
options: () => [],
|
|
29
37
|
});
|
|
@@ -65,6 +73,8 @@ const emit = defineEmits([
|
|
|
65
73
|
"update:search",
|
|
66
74
|
]);
|
|
67
75
|
|
|
76
|
+
defineSlots<UListboxSlots<TOption>>();
|
|
77
|
+
|
|
68
78
|
const wrapperRef = useTemplateRef<HTMLDivElement>("wrapper");
|
|
69
79
|
const listboxInputRef = useTemplateRef<{ input: HTMLInputElement }>("listbox-input");
|
|
70
80
|
const optionsRef = useTemplateRef<HTMLLIElement[]>("option");
|
|
@@ -75,8 +85,11 @@ const wrapperMaxHeight = ref("");
|
|
|
75
85
|
|
|
76
86
|
const localSearch = ref(props.search ?? "");
|
|
77
87
|
|
|
88
|
+
/* Indexable view of the options, `labelKey` / `valueKey` lookups need an index signature. */
|
|
89
|
+
const localOptions = computed(() => props.options as Option[]);
|
|
90
|
+
|
|
78
91
|
const { pointer, pointerDirty, pointerSet, pointerBackward, pointerForward, pointerReset } =
|
|
79
|
-
usePointer(
|
|
92
|
+
usePointer(localOptions.value, optionsRef, wrapperRef);
|
|
80
93
|
|
|
81
94
|
const elementId = props.id || useId();
|
|
82
95
|
|
|
@@ -123,10 +136,13 @@ const getOptionAriaSelected = (option: Option) => {
|
|
|
123
136
|
return isSelectedOption(option);
|
|
124
137
|
};
|
|
125
138
|
|
|
139
|
+
/* `filterGroups` flattens group members, which need not carry `TOption`'s members. */
|
|
140
|
+
const getSlotOption = (option: Option) => option as SlotOption<TOption>;
|
|
141
|
+
|
|
126
142
|
const filteredOptions = computed(() => {
|
|
127
143
|
const normalizedSearch = searchModel.value.toLowerCase().trim();
|
|
128
144
|
|
|
129
|
-
let options = [...
|
|
145
|
+
let options = [...localOptions.value];
|
|
130
146
|
|
|
131
147
|
options = props.groupValueKey
|
|
132
148
|
? filterGroups(
|
|
@@ -324,9 +340,9 @@ function optionHighlight(index: number, option: Option) {
|
|
|
324
340
|
}
|
|
325
341
|
|
|
326
342
|
function addPointerElement(keyCode?: string) {
|
|
327
|
-
if (
|
|
328
|
-
select(
|
|
329
|
-
onClickOption(
|
|
343
|
+
if (localOptions.value.length > 0) {
|
|
344
|
+
select(localOptions.value[pointer.value], keyCode);
|
|
345
|
+
onClickOption(localOptions.value[pointer.value]);
|
|
330
346
|
}
|
|
331
347
|
|
|
332
348
|
pointerReset();
|
|
@@ -496,14 +512,14 @@ const {
|
|
|
496
512
|
@binding {object} option
|
|
497
513
|
@binding {number} index
|
|
498
514
|
-->
|
|
499
|
-
<slot name="before-option" :option="option" :index="index" />
|
|
515
|
+
<slot name="before-option" :option="getSlotOption(option)" :index="index" />
|
|
500
516
|
|
|
501
517
|
<!--
|
|
502
518
|
@slot Use it to add something instead of the option.
|
|
503
519
|
@binding {object} option
|
|
504
520
|
@binding {number} index
|
|
505
521
|
-->
|
|
506
|
-
<slot name="option" :option="option" :index="index">
|
|
522
|
+
<slot name="option" :option="getSlotOption(option)" :index="index">
|
|
507
523
|
<span
|
|
508
524
|
:style="getMarginForSubCategory(option.level)"
|
|
509
525
|
v-bind="optionContentAttrs"
|
|
@@ -520,7 +536,7 @@ const {
|
|
|
520
536
|
-->
|
|
521
537
|
<slot
|
|
522
538
|
name="after-option"
|
|
523
|
-
:option="option"
|
|
539
|
+
:option="getSlotOption(option)"
|
|
524
540
|
:selected="isSelectedOption(option)"
|
|
525
541
|
:index="index"
|
|
526
542
|
>
|
|
@@ -580,6 +596,7 @@ const {
|
|
|
580
596
|
<UButton
|
|
581
597
|
round
|
|
582
598
|
square
|
|
599
|
+
tabindex="-1"
|
|
583
600
|
v-bind="addOptionButtonAttrs"
|
|
584
601
|
:data-test="getDataTest('add-button')"
|
|
585
602
|
@click="onClickAddOption"
|
|
@@ -15,7 +15,7 @@ export default /*tw*/ {
|
|
|
15
15
|
listboxInput: {
|
|
16
16
|
base: "{UInputSearch} sticky top-0 pt-1 bg-default z-10",
|
|
17
17
|
searchInput: {
|
|
18
|
-
wrapper: "pl-2 pr-1 rounded-none border-0 border-b focus-within:outline-0 border-default!",
|
|
18
|
+
wrapper: "pl-2 pr-1 rounded-none border-0 border-b focus-within:outline-0 outline-none! border-default!",
|
|
19
19
|
},
|
|
20
20
|
},
|
|
21
21
|
selectIcon: {
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
getDocsDescription,
|
|
7
7
|
} from "../../utils/storybook";
|
|
8
8
|
|
|
9
|
-
import
|
|
9
|
+
import UListboxComponent from "../UListbox.vue";
|
|
10
10
|
import URow from "../../ui.container-row/URow.vue";
|
|
11
11
|
import UCol from "../../ui.container-col/UCol.vue";
|
|
12
12
|
import UAvatar from "../../ui.image-avatar/UAvatar.vue";
|
|
@@ -23,6 +23,9 @@ import emilyDavis from "../../ui.form-select/storybook/assets/images/emily-davis
|
|
|
23
23
|
import alexJohnson from "../../ui.form-select/storybook/assets/images/alex-johnson.png";
|
|
24
24
|
import patMorgan from "../../ui.form-select/storybook/assets/images/pat-morgan.png";
|
|
25
25
|
|
|
26
|
+
/* Generic SFCs are typed as a function, which drops the compiler-generated `__name`. */
|
|
27
|
+
const UListbox = UListboxComponent as typeof UListboxComponent & { __name: string };
|
|
28
|
+
|
|
26
29
|
interface DefaultUListboxArgs extends Props {
|
|
27
30
|
slotTemplate?: string;
|
|
28
31
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { ListboxOption, Props, SlotOption, UListboxSlots } from "../types";
|
|
4
|
+
|
|
5
|
+
interface UserOption {
|
|
6
|
+
label: string;
|
|
7
|
+
value: string;
|
|
8
|
+
qty: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type OptionSlot = NonNullable<UListboxSlots<UserOption>["option"]>;
|
|
12
|
+
type OptionProps = Parameters<OptionSlot>[0];
|
|
13
|
+
|
|
14
|
+
describe("UListbox types", () => {
|
|
15
|
+
it("infers the caller's option shape in an 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
|
+
/* An index signature would make any key legal, so `option.nonExistent` must not resolve. */
|
|
23
|
+
type HasIndexSignature = string extends keyof OptionProps["option"] ? true : false;
|
|
24
|
+
|
|
25
|
+
expectTypeOf<HasIndexSignature>().toEqualTypeOf<false>();
|
|
26
|
+
expectTypeOf<keyof OptionProps["option"]>().not.toEqualTypeOf<string>();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps the reserved option keys available", () => {
|
|
30
|
+
expectTypeOf<OptionProps["option"]>().toHaveProperty("groupLabel");
|
|
31
|
+
expectTypeOf<OptionProps["option"]>().toHaveProperty("isSubGroup");
|
|
32
|
+
expectTypeOf<OptionProps["option"]>().toHaveProperty("level");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not promise the caller's members on any option slot", () => {
|
|
36
|
+
/* `filterGroups` concats the `groupValueKey` array into the flat list the slots iterate, and
|
|
37
|
+
* those members are typed `Option`, not `TOption`. A runtime probe with grouped options whose
|
|
38
|
+
* members lack the parent's members confirms all three slots receive them verbatim. */
|
|
39
|
+
type BeforeOption = Parameters<NonNullable<UListboxSlots<UserOption>["before-option"]>>[0];
|
|
40
|
+
type AfterOption = Parameters<NonNullable<UListboxSlots<UserOption>["after-option"]>>[0];
|
|
41
|
+
|
|
42
|
+
expectTypeOf<BeforeOption["option"]["label"]>().toEqualTypeOf<string | undefined>();
|
|
43
|
+
expectTypeOf<OptionProps["option"]["label"]>().toEqualTypeOf<string | undefined>();
|
|
44
|
+
expectTypeOf<AfterOption["option"]["label"]>().toEqualTypeOf<string | undefined>();
|
|
45
|
+
expectTypeOf<AfterOption["selected"]>().toEqualTypeOf<boolean>();
|
|
46
|
+
|
|
47
|
+
/* The real flattened-member shape recorded at these slots must be assignable to all of them. */
|
|
48
|
+
type GroupMember = { label: string; value: string };
|
|
49
|
+
|
|
50
|
+
expectTypeOf<GroupMember>().toExtend<BeforeOption["option"]>();
|
|
51
|
+
expectTypeOf<GroupMember>().toExtend<OptionProps["option"]>();
|
|
52
|
+
expectTypeOf<GroupMember>().toExtend<AfterOption["option"]>();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("resolves Props with no type argument", () => {
|
|
56
|
+
expectTypeOf<Props>().toEqualTypeOf<Props<ListboxOption>>();
|
|
57
|
+
expectTypeOf<Props<UserOption>["options"]>().toEqualTypeOf<UserOption[] | undefined>();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("exposes SlotOption as the shared slot option shape", () => {
|
|
61
|
+
expectTypeOf<SlotOption<UserOption>>().toEqualTypeOf<OptionProps["option"]>();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
<UListbox :options="options">
|
|
5
|
+
<!-- With `groupValueKey` set, `filterGroups` flattens group members into the same list the
|
|
6
|
+
slots iterate, and a member need not carry the parent's members — so they are optional. -->
|
|
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, selected, index }">
|
|
19
|
+
{{ expectOptionalString(option.label) }}
|
|
20
|
+
{{ expectBoolean(selected) }}
|
|
21
|
+
{{ expectNumber(index) }}
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<template #empty>empty</template>
|
|
25
|
+
</UListbox>
|
|
26
|
+
|
|
27
|
+
<!-- Options from a non-literal source still expose the declared option shape. -->
|
|
28
|
+
<UListbox :options="typedOptions">
|
|
29
|
+
<template #option="{ option }">{{ expectOptionalString(option.label) }}</template>
|
|
30
|
+
</UListbox>
|
|
31
|
+
|
|
32
|
+
<UListbox :options="options">
|
|
33
|
+
<template #option="{ option }">
|
|
34
|
+
<!-- @vue-expect-error `nonExistent` is not a key of the inferred option. -->
|
|
35
|
+
{{ option.nonExistent }}
|
|
36
|
+
</template>
|
|
37
|
+
</UListbox>
|
|
38
|
+
|
|
39
|
+
<!-- Regression guard: an option slot must never promise the caller's members as present.
|
|
40
|
+
`filterGroups` concats the `groupValueKey` array into the flat list, and those members are
|
|
41
|
+
typed `Option`, not `TOption` — a runtime probe confirms the slots receive them verbatim. -->
|
|
42
|
+
<UListbox
|
|
43
|
+
:options="groupedOptions"
|
|
44
|
+
group-label-key="groupName"
|
|
45
|
+
group-value-key="children"
|
|
46
|
+
label-key="label"
|
|
47
|
+
>
|
|
48
|
+
<template #option="{ option }">
|
|
49
|
+
<!-- @vue-expect-error a flattened group member need not carry `label`. -->
|
|
50
|
+
{{ expectString(option.label) }}
|
|
51
|
+
</template>
|
|
52
|
+
<template #before-option="{ option }">
|
|
53
|
+
<!-- @vue-expect-error a flattened group member need not carry `qty`. -->
|
|
54
|
+
{{ expectNumber(option.qty) }}
|
|
55
|
+
</template>
|
|
56
|
+
<template #after-option="{ option }">
|
|
57
|
+
<!-- @vue-expect-error a flattened group member need not carry `groupName`. -->
|
|
58
|
+
{{ expectString(option.groupName) }}
|
|
59
|
+
</template>
|
|
60
|
+
</UListbox>
|
|
61
|
+
</template>
|
|
62
|
+
|
|
63
|
+
<script setup lang="ts">
|
|
64
|
+
import { ref } from "vue";
|
|
65
|
+
|
|
66
|
+
import UListbox from "../UListbox.vue";
|
|
67
|
+
|
|
68
|
+
interface UserOption {
|
|
69
|
+
label: string;
|
|
70
|
+
value: string;
|
|
71
|
+
qty: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const options = [{ label: "a", value: "a", qty: 1 }];
|
|
75
|
+
const typedOptions = ref<UserOption[]>([]);
|
|
76
|
+
|
|
77
|
+
/* A group whose members lack the parent's members — exactly what `filterGroups` flattens.
|
|
78
|
+
The parent declares `label`/`qty`/`groupName`; the flattened child carries none of them. */
|
|
79
|
+
const groupedOptions = [
|
|
80
|
+
{
|
|
81
|
+
groupName: "Group A",
|
|
82
|
+
label: "Group A",
|
|
83
|
+
qty: 1,
|
|
84
|
+
children: [{ value: "c1", title: "Child" }],
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
function expectString(value: string) {
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function expectNumber(value: number) {
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function expectBoolean(value: boolean) {
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function expectOptionalString(value: string | undefined) {
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function expectOptionalNumber(value: number | undefined) {
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
</script>
|
package/ui.form-listbox/types.ts
CHANGED
|
@@ -16,9 +16,41 @@ export interface Option extends BaseOption {
|
|
|
16
16
|
[key: string]: string | number | boolean | UnknownObject | Option[] | OnClickOption | undefined;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/* Any object shape is a valid option, only reserved option keys are type checked. */
|
|
20
|
+
export type ListboxOption = BaseOption & (object | UnknownObject);
|
|
21
|
+
|
|
19
22
|
export type SelectedValue = string | number | UnknownObject;
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
/* Drops `Option`'s `[key: string]` index signature so slot options only expose known keys. */
|
|
25
|
+
type KnownKeys<T> = {
|
|
26
|
+
[K in keyof T as string extends K ? never : number extends K ? never : K]: T[K];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Option shape exposed to slots: the caller's option plus the reserved option keys.
|
|
31
|
+
*
|
|
32
|
+
* `TOption`'s own keys are optional. With `groupValueKey` set, `filterGroups` flattens the
|
|
33
|
+
* nested `groupValueKey` array into the same list the slots iterate, and those group members
|
|
34
|
+
* are typed `Option`, not `TOption` — so a flattened child need not carry the parent's members.
|
|
35
|
+
* `BaseOption`'s keys are optional already, so nothing is promised that can be absent.
|
|
36
|
+
*/
|
|
37
|
+
export type SlotOption<TOption extends ListboxOption = ListboxOption> = Partial<
|
|
38
|
+
KnownKeys<TOption>
|
|
39
|
+
> &
|
|
40
|
+
BaseOption;
|
|
41
|
+
|
|
42
|
+
export interface UListboxSlots<TOption extends ListboxOption = ListboxOption> {
|
|
43
|
+
"before-option"?: (props: { option: SlotOption<TOption>; index: number }) => unknown;
|
|
44
|
+
option?: (props: { option: SlotOption<TOption>; index: number }) => unknown;
|
|
45
|
+
"after-option"?: (props: {
|
|
46
|
+
option: SlotOption<TOption>;
|
|
47
|
+
selected: boolean;
|
|
48
|
+
index: number;
|
|
49
|
+
}) => unknown;
|
|
50
|
+
empty?: () => unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Props<TOption extends ListboxOption = ListboxOption> {
|
|
22
54
|
/**
|
|
23
55
|
* Selected item.
|
|
24
56
|
*/
|
|
@@ -32,7 +64,7 @@ export interface Props {
|
|
|
32
64
|
/**
|
|
33
65
|
* List options.
|
|
34
66
|
*/
|
|
35
|
-
options?:
|
|
67
|
+
options?: TOption[];
|
|
36
68
|
|
|
37
69
|
/**
|
|
38
70
|
* Shows input to search value in a list.
|
package/ui.form-radio/config.ts
CHANGED
|
@@ -3,7 +3,7 @@ export default /*tw*/ {
|
|
|
3
3
|
radio: {
|
|
4
4
|
base: `
|
|
5
5
|
bg-default cursor-pointer transition
|
|
6
|
-
border border-solid border-default rounded-full outline-solid outline-transparent
|
|
6
|
+
border border-solid border-default rounded-full outline-solid outline-medium outline-transparent
|
|
7
7
|
appearance-none p-0 print:color-adjust-exact inline-block align-middle bg-origin-border select-none shrink-0
|
|
8
8
|
hover:border-lifted hover:checked:border-{color}
|
|
9
9
|
active:border-{color} active:bg-{color}/15
|
|
@@ -10,7 +10,7 @@ import URadio from "../ui.form-radio/URadio.vue";
|
|
|
10
10
|
import defaultConfig from "./config";
|
|
11
11
|
import { COMPONENT_NAME } from "./constants";
|
|
12
12
|
|
|
13
|
-
import type { Props, SetRadioGroupSelectedItem, Config } from "./types";
|
|
13
|
+
import type { Props, BaseOption, SetRadioGroupSelectedItem, Config } from "./types";
|
|
14
14
|
|
|
15
15
|
defineOptions({ inheritAttrs: false });
|
|
16
16
|
|
|
@@ -31,6 +31,11 @@ const emit = defineEmits([
|
|
|
31
31
|
|
|
32
32
|
const listRef = useTemplateRef<HTMLDivElement>("list");
|
|
33
33
|
|
|
34
|
+
/* Indexable view of the options for `labelKey` / `valueKey` lookups. */
|
|
35
|
+
const keyedOptions = computed(
|
|
36
|
+
() => props.options as (BaseOption & Record<string, BaseOption["value"]>)[],
|
|
37
|
+
);
|
|
38
|
+
|
|
34
39
|
const selectedItem = computed({
|
|
35
40
|
get: () => props.modelValue,
|
|
36
41
|
set: (value) => emit("update:modelValue", value),
|
|
@@ -57,7 +62,14 @@ defineExpose({
|
|
|
57
62
|
* Get element / nested component attributes for each config token ✨
|
|
58
63
|
* Applies: `class`, `config`, redefined default `props` and dev `vl-...` attributes.
|
|
59
64
|
*/
|
|
60
|
-
const
|
|
65
|
+
const mutatedProps = computed(() => ({
|
|
66
|
+
label: Boolean(props.label),
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
const { getDataTest, groupLabelAttrs, listAttrs, groupRadioAttrs } = useUI<Config>(
|
|
70
|
+
defaultConfig,
|
|
71
|
+
mutatedProps,
|
|
72
|
+
);
|
|
61
73
|
</script>
|
|
62
74
|
|
|
63
75
|
<template>
|
|
@@ -99,7 +111,7 @@ const { getDataTest, groupLabelAttrs, listAttrs, groupRadioAttrs } = useUI<Confi
|
|
|
99
111
|
<!-- @slot Use it to add URadio directly. -->
|
|
100
112
|
<slot>
|
|
101
113
|
<URadio
|
|
102
|
-
v-for="(option, index) in
|
|
114
|
+
v-for="(option, index) in keyedOptions"
|
|
103
115
|
:key="index"
|
|
104
116
|
:model-value="selectedItem"
|
|
105
117
|
:value="option[valueKey]"
|
|
@@ -5,11 +5,16 @@ export default /*tw*/ {
|
|
|
5
5
|
base: "flex flex-col",
|
|
6
6
|
variants: {
|
|
7
7
|
size: {
|
|
8
|
-
sm: "gap-
|
|
9
|
-
md: "gap-2
|
|
10
|
-
lg: "gap-2.5
|
|
8
|
+
sm: "gap-2",
|
|
9
|
+
md: "gap-2",
|
|
10
|
+
lg: "gap-2.5",
|
|
11
11
|
},
|
|
12
12
|
},
|
|
13
|
+
compoundVariants: [
|
|
14
|
+
{ size: "sm", label: true, class: "mt-1" },
|
|
15
|
+
{ size: "md", label: true, class: "mt-1.5" },
|
|
16
|
+
{ size: "lg", label: true, class: "mt-2" },
|
|
17
|
+
],
|
|
13
18
|
},
|
|
14
19
|
defaults: {
|
|
15
20
|
color: "primary",
|
|
@@ -10,9 +10,8 @@ export interface BaseOption {
|
|
|
10
10
|
description?: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
13
|
+
/* Any object shape is a valid option, only reserved option keys are type checked. */
|
|
14
|
+
export type Option = BaseOption & (object | UnknownObject);
|
|
16
15
|
|
|
17
16
|
export type SetRadioGroupSelectedItem =
|
|
18
17
|
| ((value: string | number | boolean | UnknownArray | UnknownObject) => void)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
1
|
+
<script setup lang="ts" generic="TOption extends SelectOption">
|
|
2
2
|
import { ref, computed, nextTick, watch, useSlots, onMounted, useId, useTemplateRef } from "vue";
|
|
3
3
|
|
|
4
4
|
import UIcon from "../ui.image-icon/UIcon.vue";
|
|
@@ -19,12 +19,13 @@ import defaultConfig from "./config";
|
|
|
19
19
|
import { COMPONENT_NAME, DIRECTION, KEYS, MULTIPLE_VARIANTS } from "./constants";
|
|
20
20
|
|
|
21
21
|
import type { Option, Config as UListboxConfig } from "../ui.form-listbox/types";
|
|
22
|
-
import type { Props, Config } from "./types";
|
|
23
|
-
import type {
|
|
22
|
+
import type { Props, Config, SelectOption, USelectSlots, SelectedSlotOption } from "./types";
|
|
23
|
+
import type { ComponentPublicInstance } from "vue";
|
|
24
|
+
import type { ComponentExposed, KeyAttrsWithConfig } from "../types";
|
|
24
25
|
|
|
25
26
|
defineOptions({ inheritAttrs: false });
|
|
26
27
|
|
|
27
|
-
const props = withDefaults(defineProps<Props
|
|
28
|
+
const props = withDefaults(defineProps<Props<TOption>>(), {
|
|
28
29
|
...getDefaults<Props, Config>(defaultConfig, COMPONENT_NAME),
|
|
29
30
|
options: () => [],
|
|
30
31
|
modelValue: "",
|
|
@@ -97,12 +98,17 @@ const emit = defineEmits([
|
|
|
97
98
|
"change",
|
|
98
99
|
]);
|
|
99
100
|
|
|
101
|
+
defineSlots<USelectSlots<TOption>>();
|
|
102
|
+
|
|
100
103
|
const slots = useSlots();
|
|
101
104
|
|
|
102
105
|
const isOpen = ref(false);
|
|
103
106
|
const preferredOpenDirection = ref(DIRECTION.bottom);
|
|
104
107
|
|
|
105
|
-
|
|
108
|
+
/* `UListbox` is generic, so its type is a function — `InstanceType` does not apply. */
|
|
109
|
+
const listboxRef = useTemplateRef<ComponentExposed<typeof UListbox> & ComponentPublicInstance>(
|
|
110
|
+
"listbox",
|
|
111
|
+
);
|
|
106
112
|
const wrapperRef = useTemplateRef<HTMLDivElement>("wrapper");
|
|
107
113
|
const labelComponentRef = useTemplateRef<InstanceType<typeof ULabel>>("labelComponent");
|
|
108
114
|
const leftSlotWrapperRef = useTemplateRef<HTMLDivElement>("leftSlotWrapper");
|
|
@@ -135,7 +141,7 @@ const dropdownValue = computed({
|
|
|
135
141
|
emit("update:modelValue", value);
|
|
136
142
|
emit("change", { value, options: props.options });
|
|
137
143
|
|
|
138
|
-
if (
|
|
144
|
+
if (props.closeOnSelect) deactivate();
|
|
139
145
|
},
|
|
140
146
|
});
|
|
141
147
|
|
|
@@ -151,16 +157,24 @@ const isMultipleBadgeVariant = computed(
|
|
|
151
157
|
() => props.multiple && props.multipleVariant === MULTIPLE_VARIANTS.badge,
|
|
152
158
|
);
|
|
153
159
|
|
|
160
|
+
/* Indexable view of the options — `labelKey` / `valueKey` lookups need an index signature. */
|
|
161
|
+
const listboxOptions = computed(() => props.options as Option[]);
|
|
162
|
+
|
|
163
|
+
/* Same array, keeping `TOption` so the forwarded `UListbox` option slots infer the caller's shape. */
|
|
164
|
+
const typedListboxOptions = computed(() => props.options);
|
|
165
|
+
|
|
154
166
|
const localValue = computed<Option | Option[]>(() => {
|
|
155
167
|
if (!props.multiple) {
|
|
156
168
|
const [singleValue] = Array.isArray(props.modelValue) ? props.modelValue : [props.modelValue];
|
|
157
169
|
|
|
158
|
-
return getCurrentOption(
|
|
170
|
+
return getCurrentOption(listboxOptions.value, singleValue, props.valueKey, props.groupValueKey);
|
|
159
171
|
}
|
|
160
172
|
|
|
161
173
|
return props.modelValue && Array.isArray(props.modelValue)
|
|
162
174
|
? (props.modelValue
|
|
163
|
-
.map((value) =>
|
|
175
|
+
.map((value) =>
|
|
176
|
+
getCurrentOption(listboxOptions.value, value, props.valueKey, props.groupValueKey),
|
|
177
|
+
)
|
|
164
178
|
.filter(Boolean) as Option[])
|
|
165
179
|
: [];
|
|
166
180
|
});
|
|
@@ -179,6 +193,21 @@ const selectedOptions = computed(() => {
|
|
|
179
193
|
};
|
|
180
194
|
});
|
|
181
195
|
|
|
196
|
+
/* Slot-facing views of the values above. The internals keep `Option` because `labelKey` /
|
|
197
|
+
* `valueKey` are runtime strings and need its index signature; slots get the caller's shape.
|
|
198
|
+
* The narrowing cannot over-promise: `SelectedSlotOption` makes every member optional, which is
|
|
199
|
+
* exactly what the runtime delivers — a probe records a bare `{}` at `selected-option`, `left` and
|
|
200
|
+
* `right` when no option matches `modelValue`, and a `{}` array element in `multiple` mode. */
|
|
201
|
+
const slotSelectedOption = computed(() => selectedOption.value as SelectedSlotOption<TOption>);
|
|
202
|
+
|
|
203
|
+
const slotSelectedOptions = computed(
|
|
204
|
+
() => selectedOptions.value.full as SelectedSlotOption<TOption>[],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const slotVisibleOptions = computed(
|
|
208
|
+
() => selectedOptions.value.visible as SelectedSlotOption<TOption>[],
|
|
209
|
+
);
|
|
210
|
+
|
|
182
211
|
const selectedOptionsLabel = computed(() => {
|
|
183
212
|
return {
|
|
184
213
|
full: selectedOptions.value.full.map((item) => item[props.labelKey]).join(", "),
|
|
@@ -556,7 +585,7 @@ const {
|
|
|
556
585
|
|
|
557
586
|
<div
|
|
558
587
|
ref="wrapper"
|
|
559
|
-
:tabindex="searchable
|
|
588
|
+
:tabindex="disabled ? -1 : searchable && isOpen ? -1 : 0"
|
|
560
589
|
role="combobox"
|
|
561
590
|
:aria-expanded="ariaExpanded"
|
|
562
591
|
aria-haspopup="listbox"
|
|
@@ -589,7 +618,7 @@ const {
|
|
|
589
618
|
<slot
|
|
590
619
|
name="right"
|
|
591
620
|
:icon-name="rightIcon"
|
|
592
|
-
:options="multiple ?
|
|
621
|
+
:options="multiple ? slotSelectedOptions : slotSelectedOption"
|
|
593
622
|
>
|
|
594
623
|
<UIcon v-if="rightIcon" :name="rightIcon" v-bind="rightIconAttrs" />
|
|
595
624
|
</slot>
|
|
@@ -669,7 +698,10 @@ const {
|
|
|
669
698
|
@binding {array} options
|
|
670
699
|
@binding {object} options
|
|
671
700
|
-->
|
|
672
|
-
<slot
|
|
701
|
+
<slot
|
|
702
|
+
name="selected-options"
|
|
703
|
+
:options="multiple ? slotSelectedOptions : slotSelectedOption"
|
|
704
|
+
>
|
|
673
705
|
<span v-if="!multiple" v-bind="selectedLabelsAttrs" @click="toggle" @mousedown.prevent>
|
|
674
706
|
<!--
|
|
675
707
|
@slot Use it to customize selected option.
|
|
@@ -681,7 +713,7 @@ const {
|
|
|
681
713
|
name="selected-option"
|
|
682
714
|
:label="selectedOption[labelKey]"
|
|
683
715
|
:value="selectedOption[valueKey]"
|
|
684
|
-
:option="
|
|
716
|
+
:option="slotSelectedOption"
|
|
685
717
|
>
|
|
686
718
|
<div
|
|
687
719
|
:title="(selectedOption[labelKey] || '') as string"
|
|
@@ -705,7 +737,7 @@ const {
|
|
|
705
737
|
name="selected-option"
|
|
706
738
|
:label="option[labelKey]"
|
|
707
739
|
:value="option[valueKey]"
|
|
708
|
-
:option="
|
|
740
|
+
:option="slotVisibleOptions[index]"
|
|
709
741
|
>
|
|
710
742
|
{{
|
|
711
743
|
option[labelKey] +
|
|
@@ -746,7 +778,7 @@ const {
|
|
|
746
778
|
name="selected-option"
|
|
747
779
|
:label="option[labelKey]"
|
|
748
780
|
:value="option[valueKey]"
|
|
749
|
-
:option="
|
|
781
|
+
:option="slotVisibleOptions[index]"
|
|
750
782
|
>
|
|
751
783
|
{{ option[labelKey] }}
|
|
752
784
|
</slot>
|
|
@@ -799,7 +831,7 @@ const {
|
|
|
799
831
|
name="selected-option"
|
|
800
832
|
:label="option[labelKey]"
|
|
801
833
|
:value="option[valueKey]"
|
|
802
|
-
:option="
|
|
834
|
+
:option="slotVisibleOptions[index]"
|
|
803
835
|
>
|
|
804
836
|
{{ option[labelKey] }}
|
|
805
837
|
</slot>
|
|
@@ -858,7 +890,7 @@ const {
|
|
|
858
890
|
:searchable="searchable"
|
|
859
891
|
:options-limit="optionsLimit"
|
|
860
892
|
:multiple="multiple"
|
|
861
|
-
:options="
|
|
893
|
+
:options="typedListboxOptions"
|
|
862
894
|
:disabled="disabled"
|
|
863
895
|
:size="size"
|
|
864
896
|
:debounce="debounce"
|
|
@@ -926,7 +958,7 @@ const {
|
|
|
926
958
|
<slot
|
|
927
959
|
name="left"
|
|
928
960
|
:icon-name="leftIcon"
|
|
929
|
-
:options="multiple ?
|
|
961
|
+
:options="multiple ? slotSelectedOptions : slotSelectedOption"
|
|
930
962
|
>
|
|
931
963
|
<UIcon v-if="leftIcon" :name="leftIcon" v-bind="leftIconAttrs" />
|
|
932
964
|
</slot>
|
package/ui.form-select/config.ts
CHANGED
|
@@ -11,10 +11,10 @@ export default /*tw*/ {
|
|
|
11
11
|
},
|
|
12
12
|
wrapper: {
|
|
13
13
|
base: `
|
|
14
|
-
flex flex-row-reverse justify-between w-full min-h-full box-border relative
|
|
15
|
-
rounded-medium border border-solid border-default bg-default outline-solid outline-transparent
|
|
16
|
-
hover:border-lifted hover:
|
|
17
|
-
focus-within:outline focus-within:outline-small focus-within:outline-primary
|
|
14
|
+
flex flex-row-reverse justify-between w-full min-h-full box-border relative transition
|
|
15
|
+
rounded-medium border border-solid border-default bg-default outline-solid outline-small outline-transparent
|
|
16
|
+
hover:border-lifted hover:focus-within:border-primary focus-within:border-primary
|
|
17
|
+
focus-within:outline focus-within:outline-small focus-within:outline-primary
|
|
18
18
|
`,
|
|
19
19
|
variants: {
|
|
20
20
|
error: {
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
getDocsDescription,
|
|
8
8
|
} from "../../utils/storybook";
|
|
9
9
|
|
|
10
|
-
import
|
|
10
|
+
import USelectComponent from "../../ui.form-select/USelect.vue";
|
|
11
11
|
import URow from "../../ui.container-row/URow.vue";
|
|
12
12
|
import UCol from "../../ui.container-col/UCol.vue";
|
|
13
13
|
import UBadge from "../../ui.text-badge/UBadge.vue";
|
|
@@ -25,6 +25,9 @@ import patMorgan from "./assets/images/pat-morgan.png";
|
|
|
25
25
|
import type { Meta, StoryFn } from "@storybook/vue3-vite";
|
|
26
26
|
import type { Props } from "../types";
|
|
27
27
|
|
|
28
|
+
/* Generic SFCs are typed as a function, which drops the compiler-generated `__name`. */
|
|
29
|
+
const USelect = USelectComponent as typeof USelectComponent & { __name: string };
|
|
30
|
+
|
|
28
31
|
interface USelectArgs extends Props {
|
|
29
32
|
slotTemplate?: string;
|
|
30
33
|
enum: "size" | "openDirection" | "labelAlign";
|