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
|
@@ -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";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
Props,
|
|
5
|
+
SelectOption,
|
|
6
|
+
SelectedSlotOption,
|
|
7
|
+
SelectedOptionsBinding,
|
|
8
|
+
USelectSlots,
|
|
9
|
+
} from "../types";
|
|
10
|
+
import type { SlotOption } from "../../ui.form-listbox/types";
|
|
11
|
+
|
|
12
|
+
interface UserOption {
|
|
13
|
+
label: string;
|
|
14
|
+
value: string;
|
|
15
|
+
qty: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type OptionSlot = NonNullable<USelectSlots<UserOption>["option"]>;
|
|
19
|
+
type OptionProps = Parameters<OptionSlot>[0];
|
|
20
|
+
|
|
21
|
+
type SelectedOptionProps = Parameters<NonNullable<USelectSlots<UserOption>["selected-option"]>>[0];
|
|
22
|
+
|
|
23
|
+
describe("USelect types", () => {
|
|
24
|
+
it("infers the caller's option shape in a forwarded option slot", () => {
|
|
25
|
+
expectTypeOf<OptionProps["option"]["label"]>().toEqualTypeOf<string | undefined>();
|
|
26
|
+
expectTypeOf<OptionProps["option"]["qty"]>().toEqualTypeOf<number | undefined>();
|
|
27
|
+
expectTypeOf<OptionProps["index"]>().toEqualTypeOf<number>();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("does not expose undeclared option properties to a slot", () => {
|
|
31
|
+
type HasIndexSignature = string extends keyof OptionProps["option"] ? true : false;
|
|
32
|
+
|
|
33
|
+
expectTypeOf<HasIndexSignature>().toEqualTypeOf<false>();
|
|
34
|
+
expectTypeOf<keyof OptionProps["option"]>().not.toEqualTypeOf<string>();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reuses UListbox's SlotOption rather than a second dialect", () => {
|
|
38
|
+
/* Stage 3 must not invent its own option-slot shape. */
|
|
39
|
+
expectTypeOf<OptionProps["option"]>().toEqualTypeOf<SlotOption<UserOption>>();
|
|
40
|
+
expectTypeOf<SelectedSlotOption<UserOption>>().toEqualTypeOf<SlotOption<UserOption>>();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("promises nothing on a selected option, because {} reaches it at runtime", () => {
|
|
44
|
+
/* `getCurrentOption` returns `{} as Option` when no option matches `modelValue`
|
|
45
|
+
* (`utilSelect.ts:15`). A runtime probe records that bare `{}` at `selected-option` in all
|
|
46
|
+
* three `multiple` variants, and at `left` / `right` in single mode. */
|
|
47
|
+
expectTypeOf<SelectedOptionProps["option"]["label"]>().toEqualTypeOf<string | undefined>();
|
|
48
|
+
expectTypeOf<SelectedOptionProps["option"]["qty"]>().toEqualTypeOf<number | undefined>();
|
|
49
|
+
|
|
50
|
+
/* The empty object the runtime actually delivers must be assignable. */
|
|
51
|
+
expectTypeOf<Record<string, never>>().toExtend<SelectedOptionProps["option"]>();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("types left / right / selected-options as the option-or-array binding", () => {
|
|
55
|
+
type LeftProps = Parameters<NonNullable<USelectSlots<UserOption>["left"]>>[0];
|
|
56
|
+
type RightProps = Parameters<NonNullable<USelectSlots<UserOption>["right"]>>[0];
|
|
57
|
+
type SelectedOptionsProps = Parameters<
|
|
58
|
+
NonNullable<USelectSlots<UserOption>["selected-options"]>
|
|
59
|
+
>[0];
|
|
60
|
+
|
|
61
|
+
/* All three bind `multiple ? selectedOptions.full : selectedOption`. */
|
|
62
|
+
expectTypeOf<LeftProps["options"]>().toEqualTypeOf<SelectedOptionsBinding<UserOption>>();
|
|
63
|
+
expectTypeOf<RightProps["options"]>().toEqualTypeOf<SelectedOptionsBinding<UserOption>>();
|
|
64
|
+
expectTypeOf<SelectedOptionsProps["options"]>().toEqualTypeOf<
|
|
65
|
+
SelectedOptionsBinding<UserOption>
|
|
66
|
+
>();
|
|
67
|
+
|
|
68
|
+
/* In `multiple` mode a non-matching `modelValue` entry contributes a `{}` element. */
|
|
69
|
+
expectTypeOf<Record<string, never>[]>().toExtend<SelectedOptionsBinding<UserOption>>();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("resolves Props with no type argument", () => {
|
|
73
|
+
expectTypeOf<Props>().toEqualTypeOf<Props<SelectOption>>();
|
|
74
|
+
expectTypeOf<Props<UserOption>["options"]>().toEqualTypeOf<UserOption[] | undefined>();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -2,13 +2,20 @@ import { mount, flushPromises } from "@vue/test-utils";
|
|
|
2
2
|
import { describe, it, expect, vi } from "vitest";
|
|
3
3
|
|
|
4
4
|
import USelect from "../USelect.vue";
|
|
5
|
-
import
|
|
5
|
+
import UListboxComponent from "../../ui.form-listbox/UListbox.vue";
|
|
6
6
|
import UIcon from "../../ui.image-icon/UIcon.vue";
|
|
7
7
|
import ULabel from "../../ui.form-label/ULabel.vue";
|
|
8
8
|
import UBadge from "../../ui.text-badge/UBadge.vue";
|
|
9
9
|
|
|
10
10
|
import type { Props } from "../types";
|
|
11
11
|
|
|
12
|
+
import type { ComponentProps } from "../../types";
|
|
13
|
+
|
|
14
|
+
/* Generic SFCs are typed as a function, which `findComponent` does not accept. */
|
|
15
|
+
const UListbox = UListboxComponent as unknown as new () => {
|
|
16
|
+
$props: ComponentProps<typeof UListboxComponent>;
|
|
17
|
+
};
|
|
18
|
+
|
|
12
19
|
describe("USelect.vue", () => {
|
|
13
20
|
const defaultOptions = [
|
|
14
21
|
{ label: "Option 1", value: "option1" },
|
|
@@ -255,6 +262,63 @@ describe("USelect.vue", () => {
|
|
|
255
262
|
expect(component.getComponent(UListbox).props("searchable")).toBe(true);
|
|
256
263
|
});
|
|
257
264
|
|
|
265
|
+
it("Searchable – closes when the search input blurs to an outside element", async () => {
|
|
266
|
+
const component = mount(USelect, {
|
|
267
|
+
props: {
|
|
268
|
+
searchable: true,
|
|
269
|
+
options: defaultOptions,
|
|
270
|
+
},
|
|
271
|
+
attachTo: document.body,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await component.get("[role='combobox']").trigger("focus");
|
|
275
|
+
await flushPromises();
|
|
276
|
+
|
|
277
|
+
expect(component.emitted("open")).toBeTruthy();
|
|
278
|
+
|
|
279
|
+
const outside = document.createElement("button");
|
|
280
|
+
|
|
281
|
+
document.body.appendChild(outside);
|
|
282
|
+
|
|
283
|
+
await component
|
|
284
|
+
.getComponent(UListbox)
|
|
285
|
+
.get("input")
|
|
286
|
+
.trigger("blur", { relatedTarget: outside });
|
|
287
|
+
await flushPromises();
|
|
288
|
+
|
|
289
|
+
expect(component.emitted("close")).toBeTruthy();
|
|
290
|
+
expect(component.findComponent(UListbox).exists()).toBe(false);
|
|
291
|
+
|
|
292
|
+
outside.remove();
|
|
293
|
+
component.unmount();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("Searchable – stays open when focus moves within its own listbox", async () => {
|
|
297
|
+
const component = mount(USelect, {
|
|
298
|
+
props: {
|
|
299
|
+
searchable: true,
|
|
300
|
+
options: defaultOptions,
|
|
301
|
+
},
|
|
302
|
+
attachTo: document.body,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await component.get("[role='combobox']").trigger("focus");
|
|
306
|
+
await flushPromises();
|
|
307
|
+
|
|
308
|
+
const listboxEl = component.getComponent(UListbox).element as HTMLElement;
|
|
309
|
+
|
|
310
|
+
await component
|
|
311
|
+
.getComponent(UListbox)
|
|
312
|
+
.get("input")
|
|
313
|
+
.trigger("blur", { relatedTarget: listboxEl });
|
|
314
|
+
await flushPromises();
|
|
315
|
+
|
|
316
|
+
expect(component.emitted("close")).toBeFalsy();
|
|
317
|
+
expect(component.findComponent(UListbox).exists()).toBe(true);
|
|
318
|
+
|
|
319
|
+
component.unmount();
|
|
320
|
+
});
|
|
321
|
+
|
|
258
322
|
it("Search v-model – passes search to UListbox and filters", async () => {
|
|
259
323
|
const component = mount(USelect, {
|
|
260
324
|
props: {
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
<USelect :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, selected, index }">
|
|
19
|
+
{{ expectOptionalString(option.label) }}
|
|
20
|
+
{{ expectBoolean(selected) }}
|
|
21
|
+
{{ expectNumber(index) }}
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<template #empty>empty</template>
|
|
25
|
+
</USelect>
|
|
26
|
+
|
|
27
|
+
<!-- Options from a non-literal source still expose the declared option shape. -->
|
|
28
|
+
<USelect :options="typedOptions">
|
|
29
|
+
<template #option="{ option }">{{ expectOptionalString(option.label) }}</template>
|
|
30
|
+
</USelect>
|
|
31
|
+
|
|
32
|
+
<USelect :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
|
+
</USelect>
|
|
38
|
+
|
|
39
|
+
<!-- Regression guard: `getCurrentOption` returns `{} as Option` when no option matches
|
|
40
|
+
`modelValue` (`utilSelect.ts:15`). A runtime probe records that bare `{}` arriving at
|
|
41
|
+
`selected-option`, `left` and `right`, and as an array element in `multiple` mode. -->
|
|
42
|
+
<USelect :options="options" model-value="no-such-value">
|
|
43
|
+
<template #selected-option="{ option }">
|
|
44
|
+
<!-- @vue-expect-error the `{}` fallback need not carry `label`. -->
|
|
45
|
+
{{ expectString(option.label) }}
|
|
46
|
+
</template>
|
|
47
|
+
|
|
48
|
+
<template #left="{ options: selected }">
|
|
49
|
+
<!-- @vue-expect-error the binding is `option | option[]`, never an array alone. -->
|
|
50
|
+
{{ expectArray(selected) }}
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<template #right="{ options: selected }">
|
|
54
|
+
{{ expectSelectedBinding(selected) }}
|
|
55
|
+
</template>
|
|
56
|
+
|
|
57
|
+
<template #selected-options="{ options: selected }">
|
|
58
|
+
{{ expectSelectedBinding(selected) }}
|
|
59
|
+
</template>
|
|
60
|
+
</USelect>
|
|
61
|
+
|
|
62
|
+
<USelect :options="options" :model-value="['a']" multiple>
|
|
63
|
+
<template #selected-options="{ options: selected }">
|
|
64
|
+
<!-- @vue-expect-error in `multiple` mode an element may be the `{}` fallback. -->
|
|
65
|
+
{{ Array.isArray(selected) ? expectString(selected[0].label) : "" }}
|
|
66
|
+
</template>
|
|
67
|
+
</USelect>
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<script setup lang="ts">
|
|
71
|
+
import { ref } from "vue";
|
|
72
|
+
|
|
73
|
+
import USelect from "../USelect.vue";
|
|
74
|
+
|
|
75
|
+
import type { SelectedOptionsBinding } from "../types";
|
|
76
|
+
|
|
77
|
+
interface UserOption {
|
|
78
|
+
label: string;
|
|
79
|
+
value: string;
|
|
80
|
+
qty: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const options = [{ label: "a", value: "a", qty: 1 }];
|
|
84
|
+
const typedOptions = ref<UserOption[]>([]);
|
|
85
|
+
|
|
86
|
+
function expectString(value: string) {
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function expectNumber(value: number) {
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function expectBoolean(value: boolean) {
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function expectOptionalString(value: string | undefined) {
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function expectOptionalNumber(value: number | undefined) {
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function expectArray(value: unknown[]) {
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function expectSelectedBinding(value: SelectedOptionsBinding<UserOption>) {
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
</script>
|
package/ui.form-select/types.ts
CHANGED
|
@@ -1,11 +1,62 @@
|
|
|
1
1
|
import defaultConfig from "./config";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
import type { ComponentConfig } from "../types";
|
|
3
|
+
import type { BaseOption, SlotOption } from "../ui.form-listbox/types";
|
|
4
|
+
import type { ComponentConfig, UnknownObject } from "../types";
|
|
5
5
|
|
|
6
6
|
export type Config = typeof defaultConfig;
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/* Any object shape is a valid option, only reserved option keys are type checked. */
|
|
9
|
+
export type SelectOption = BaseOption & (object | UnknownObject);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Shape of a selected option exposed to slots.
|
|
13
|
+
*
|
|
14
|
+
* `getCurrentOption` falls back to `{} as Option` when no option matches (`utilSelect.ts:15`), and
|
|
15
|
+
* that empty object reaches `selected-option`, `left` and `right` verbatim — confirmed by runtime
|
|
16
|
+
* probe. In `multiple` mode it is an element of the array given to `selected-options`. So no member
|
|
17
|
+
* of `TOption` can be promised; `SlotOption` (Stage 2's type) already encodes exactly that.
|
|
18
|
+
*/
|
|
19
|
+
export type SelectedSlotOption<TOption extends SelectOption = SelectOption> = SlotOption<TOption>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What `left`, `right` and `selected-options` receive: `multiple ? selectedOptions.full : selectedOption`.
|
|
23
|
+
* In `multiple` mode a non-matching `modelValue` entry contributes a `{}` element to the array.
|
|
24
|
+
*/
|
|
25
|
+
export type SelectedOptionsBinding<TOption extends SelectOption = SelectOption> =
|
|
26
|
+
| SelectedSlotOption<TOption>
|
|
27
|
+
| SelectedSlotOption<TOption>[];
|
|
28
|
+
|
|
29
|
+
export interface USelectSlots<TOption extends SelectOption = SelectOption> {
|
|
30
|
+
label?: (props: { label: string }) => unknown;
|
|
31
|
+
description?: (props: { description: string }) => unknown;
|
|
32
|
+
error?: (props: { error: string | boolean }) => unknown;
|
|
33
|
+
left?: (props: { iconName: string; options: SelectedOptionsBinding<TOption> }) => unknown;
|
|
34
|
+
right?: (props: { iconName: string; options: SelectedOptionsBinding<TOption> }) => unknown;
|
|
35
|
+
toggle?: (props: { iconName: string; opened: boolean }) => unknown;
|
|
36
|
+
clear?: (props: { iconName: string; clear: (event: MouseEvent) => void }) => unknown;
|
|
37
|
+
"before-toggle"?: () => unknown;
|
|
38
|
+
"after-toggle"?: () => unknown;
|
|
39
|
+
"selected-options"?: (props: { options: SelectedOptionsBinding<TOption> }) => unknown;
|
|
40
|
+
/* All four `selected-option` sites bind a single option, never an array: the `!multiple` branch
|
|
41
|
+
* binds the selected option and each `multiple` variant binds a `selectedOptions.visible` element.
|
|
42
|
+
* A runtime probe records a bare `{}` here in all three variants, hence the optional members. */
|
|
43
|
+
"selected-option"?: (props: {
|
|
44
|
+
label: unknown;
|
|
45
|
+
value: unknown;
|
|
46
|
+
option: SelectedSlotOption<TOption>;
|
|
47
|
+
}) => unknown;
|
|
48
|
+
"selected-counter"?: (props: { count: number }) => unknown;
|
|
49
|
+
"before-option"?: (props: { option: SlotOption<TOption>; index: number }) => unknown;
|
|
50
|
+
option?: (props: { option: SlotOption<TOption>; index: number }) => unknown;
|
|
51
|
+
"after-option"?: (props: {
|
|
52
|
+
option: SlotOption<TOption>;
|
|
53
|
+
selected: boolean;
|
|
54
|
+
index: number;
|
|
55
|
+
}) => unknown;
|
|
56
|
+
empty?: () => unknown;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Props<TOption extends SelectOption = SelectOption> {
|
|
9
60
|
/**
|
|
10
61
|
* Select value.
|
|
11
62
|
*/
|
|
@@ -14,7 +65,7 @@ export interface Props {
|
|
|
14
65
|
/**
|
|
15
66
|
* Select options.
|
|
16
67
|
*/
|
|
17
|
-
options?:
|
|
68
|
+
options?: TOption[];
|
|
18
69
|
|
|
19
70
|
/**
|
|
20
71
|
* Select label.
|