Skip to content

Commit db8111d

Browse files
authored
fix(InputMenu/Select/SelectMenu): improve types (#2471)
1 parent 1402436 commit db8111d

File tree

10 files changed

+226
-38
lines changed

10 files changed

+226
-38
lines changed

playground/app/pages/components/input-menu.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const fruits = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple']
1111
const vegetables = ['Aubergine', 'Broccoli', 'Carrot', 'Courgette', 'Leek']
1212
1313
const items = [[{ label: 'Fruits', type: 'label' }, ...fruits], [{ label: 'Vegetables', type: 'label' }, ...vegetables]]
14-
const selectedItems = ref([fruits[0], vegetables[0]])
14+
const selectedItems = ref([fruits[0]!, vegetables[0]!])
1515
1616
const statuses = [{
1717
label: 'Backlog',
@@ -135,7 +135,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
135135
v-for="size in sizes"
136136
:key="size"
137137
:items="items"
138-
:model-value="[fruits[0]]"
138+
:model-value="[fruits[0]!]"
139139
multiple
140140
icon="i-heroicons-magnifying-glass"
141141
placeholder="Search..."

playground/app/pages/components/select-menu.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const fruits = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple']
1111
const vegetables = ['Aubergine', 'Broccoli', 'Carrot', 'Courgette', 'Leek']
1212
1313
const items = [[{ label: 'Fruits', type: 'label' }, ...fruits], [{ label: 'Vegetables', type: 'label' }, ...vegetables]]
14-
const selectedItems = ref([fruits[0], vegetables[0]])
14+
const selectedItems = ref([fruits[0]!, vegetables[0]!])
1515
1616
const statuses = [{
1717
label: 'Backlog',

src/runtime/components/InputMenu.vue

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import _appConfig from '#build/app.config'
77
import theme from '#build/ui/input-menu'
88
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
99
import type { AvatarProps, ChipProps, InputProps } from '../types'
10-
import type { AcceptableValue, ArrayOrWrapped, PartialString } from '../types/utils'
10+
import type { AcceptableValue, ArrayOrWrapped, PartialString, SelectItems, SelectItemType, SelectModelValue, SelectModelValueEmits, SelectItemKey } from '../types/utils'
1111
1212
const appConfig = _appConfig as AppConfig & { ui: { inputMenu: Partial<typeof theme> } }
1313
@@ -29,7 +29,7 @@ export interface InputMenuItem {
2929
3030
type InputMenuVariants = VariantProps<typeof inputMenu>
3131
32-
export interface InputMenuProps<T> extends Pick<ComboboxRootProps<T>, 'modelValue' | 'defaultValue' | 'selectedValue' | 'open' | 'defaultOpen' | 'searchTerm' | 'multiple' | 'disabled' | 'name' | 'resetSearchTermOnBlur'>, UseComponentIconsProps {
32+
export interface InputMenuProps<T extends SelectItemType<I>, I extends SelectItems<InputMenuItem | AcceptableValue> = SelectItems<InputMenuItem | AcceptableValue>, V extends SelectItemKey<T> | undefined = undefined, M extends boolean = false> extends Pick<ComboboxRootProps<T>, 'defaultValue' | 'selectedValue' | 'open' | 'defaultOpen' | 'searchTerm' | 'disabled' | 'name' | 'resetSearchTermOnBlur'>, UseComponentIconsProps {
3333
/**
3434
* The element or component this component should render as.
3535
* @defaultValue 'div'
@@ -86,24 +86,28 @@ export interface InputMenuProps<T> extends Pick<ComboboxRootProps<T>, 'modelValu
8686
* When `items` is an array of objects, select the field to use as the value instead of the object itself.
8787
* @defaultValue undefined
8888
*/
89-
valueKey?: keyof T
89+
valueKey?: V
9090
/**
9191
* When `items` is an array of objects, select the field to use as the label.
9292
* @defaultValue 'label'
9393
*/
9494
labelKey?: keyof T
95-
items?: T[] | T[][]
95+
items?: I
9696
/** Highlight the ring color like a focus state. */
9797
highlight?: boolean
9898
class?: any
9999
ui?: PartialString<typeof inputMenu.slots>
100+
/** The controlled value of the Combobox. Can be binded-with with `v-model`. */
101+
modelValue?: SelectModelValue<T, V, M>
102+
/** Whether multiple options can be selected or not. */
103+
multiple?: M
100104
}
101105
102-
export type InputMenuEmits<T> = ComboboxRootEmits<T> & {
106+
export type InputMenuEmits<T, V, M extends boolean> = Omit<ComboboxRootEmits<T>, 'update:modelValue'> & {
103107
change: [payload: Event]
104108
blur: [payload: FocusEvent]
105109
focus: [payload: FocusEvent]
106-
}
110+
} & SelectModelValueEmits<T, V, M>
107111
108112
type SlotProps<T> = (props: { item: T, index: number }) => any
109113
@@ -120,7 +124,7 @@ export interface InputMenuSlots<T> {
120124
}
121125
</script>
122126

123-
<script setup lang="ts" generic="T extends InputMenuItem | AcceptableValue">
127+
<script setup lang="ts" generic="T extends SelectItemType<I>, I extends SelectItems<InputMenuItem | AcceptableValue> = SelectItems<InputMenuItem | AcceptableValue>, V extends SelectItemKey<T> | undefined = undefined, M extends boolean = false">
124128
import { computed, ref, toRef, onMounted } from 'vue'
125129
import { ComboboxRoot, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, TagsInputRoot, TagsInputItem, TagsInputItemText, TagsInputItemDelete, TagsInputInput, useForwardPropsEmits } from 'radix-vue'
126130
import { defu } from 'defu'
@@ -137,14 +141,14 @@ import UChip from './Chip.vue'
137141
138142
defineOptions({ inheritAttrs: false })
139143
140-
const props = withDefaults(defineProps<InputMenuProps<T>>(), {
144+
const props = withDefaults(defineProps<InputMenuProps<T, I, V, M>>(), {
141145
type: 'text',
142146
autofocusDelay: 0,
143147
portal: true,
144148
filter: () => ['label'],
145149
labelKey: 'label' as keyof T
146150
})
147-
const emits = defineEmits<InputMenuEmits<T>>()
151+
const emits = defineEmits<InputMenuEmits<T, V, M>>()
148152
const slots = defineSlots<InputMenuSlots<T>>()
149153
150154
const searchTerm = defineModel<string>('searchTerm', { default: '' })

src/runtime/components/Select.vue

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import _appConfig from '#build/app.config'
66
import theme from '#build/ui/select'
77
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
88
import type { AvatarProps, ChipProps, InputProps } from '../types'
9-
import type { AcceptableValue, PartialString } from '../types/utils'
9+
import type { AcceptableValue, PartialString, SelectItems, SelectItemType, SelectModelValue, SelectModelValueEmits, SelectItemKey } from '../types/utils'
1010
1111
const appConfig = _appConfig as AppConfig & { ui: { select: Partial<typeof theme> } }
1212
@@ -28,7 +28,7 @@ export interface SelectItem {
2828
2929
type SelectVariants = VariantProps<typeof select>
3030
31-
export interface SelectProps<T> extends Omit<SelectRootProps, 'dir'>, UseComponentIconsProps {
31+
export interface SelectProps<T extends SelectItemType<I>, I extends SelectItems<SelectItem | AcceptableValue> = SelectItems<SelectItem | AcceptableValue>, V extends SelectItemKey<T> | undefined = undefined> extends Omit<SelectRootProps, 'dir' | 'modelValue'>, UseComponentIconsProps {
3232
id?: string
3333
/** The placeholder text when the select is empty. */
3434
placeholder?: string
@@ -64,24 +64,26 @@ export interface SelectProps<T> extends Omit<SelectRootProps, 'dir'>, UseCompone
6464
* When `items` is an array of objects, select the field to use as the value.
6565
* @defaultValue 'value'
6666
*/
67-
valueKey?: string
67+
valueKey?: V
6868
/**
6969
* When `items` is an array of objects, select the field to use as the label.
7070
* @defaultValue 'label'
7171
*/
72-
labelKey?: string
73-
items?: T[] | T[][]
72+
labelKey?: SelectItemKey<T>
73+
items?: I
7474
/** Highlight the ring color like a focus state. */
7575
highlight?: boolean
7676
class?: any
7777
ui?: PartialString<typeof select.slots>
78+
/** The controlled value of the Select. Can be bind as `v-model`. */
79+
modelValue?: SelectModelValue<T, V, false, T extends { value: infer U } ? U : never>
7880
}
7981
80-
export type SelectEmits = SelectRootEmits & {
82+
export type SelectEmits<T, V> = Omit<SelectRootEmits, 'update:modelValue'> & {
8183
change: [payload: Event]
8284
blur: [payload: FocusEvent]
8385
focus: [payload: FocusEvent]
84-
}
86+
} & SelectModelValueEmits<T, V, false, T extends { value: infer U } ? U : never>
8587
8688
type SlotProps<T> = (props: { item: T, index: number }) => any
8789
@@ -95,7 +97,7 @@ export interface SelectSlots<T> {
9597
}
9698
</script>
9799

98-
<script setup lang="ts" generic="T extends SelectItem | AcceptableValue">
100+
<script setup lang="ts" generic="T extends SelectItemType<I>, I extends SelectItems<SelectItem | AcceptableValue> = SelectItems<SelectItem | AcceptableValue>, V extends SelectItemKey<T> | undefined = undefined">
99101
import { computed, toRef } from 'vue'
100102
import { SelectRoot, SelectTrigger, SelectValue, SelectPortal, SelectContent, SelectViewport, SelectLabel, SelectGroup, SelectItem, SelectItemIndicator, SelectItemText, SelectSeparator, useForwardPropsEmits } from 'radix-vue'
101103
import { defu } from 'defu'
@@ -109,12 +111,12 @@ import UIcon from './Icon.vue'
109111
import UAvatar from './Avatar.vue'
110112
import UChip from './Chip.vue'
111113
112-
const props = withDefaults(defineProps<SelectProps<T>>(), {
113-
valueKey: 'value',
114-
labelKey: 'label',
114+
const props = withDefaults(defineProps<SelectProps<T, I, V>>(), {
115+
valueKey: 'value' as never,
116+
labelKey: 'label' as never,
115117
portal: true
116118
})
117-
const emits = defineEmits<SelectEmits>()
119+
const emits = defineEmits<SelectEmits<T, V>>()
118120
const slots = defineSlots<SelectSlots<T>>()
119121
120122
const appConfig = useAppConfig()
@@ -166,6 +168,9 @@ function onUpdateOpen(value: boolean) {
166168
v-slot="{ modelValue, open }"
167169
v-bind="rootProps"
168170
:name="name"
171+
:default-value="(defaultValue as string)"
172+
:model-value="(modelValue as string)"
173+
:autocomplete="autocomplete"
169174
:disabled="disabled"
170175
@update:model-value="onUpdate"
171176
@update:open="onUpdateOpen"

src/runtime/components/SelectMenu.vue

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import _appConfig from '#build/app.config'
66
import theme from '#build/ui/select-menu'
77
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
88
import type { AvatarProps, ChipProps, InputProps } from '../types'
9-
import type { AcceptableValue, ArrayOrWrapped, PartialString } from '../types/utils'
9+
import type { AcceptableValue, ArrayOrWrapped, PartialString, SelectItems, SelectItemType, SelectModelValue, SelectModelValueEmits, SelectItemKey } from '../types/utils'
1010
1111
const appConfig = _appConfig as AppConfig & { ui: { selectMenu: Partial<typeof theme> } }
1212
@@ -28,7 +28,7 @@ export interface SelectMenuItem {
2828
2929
type SelectMenuVariants = VariantProps<typeof selectMenu>
3030
31-
export interface SelectMenuProps<T> extends Pick<ComboboxRootProps<T>, 'modelValue' | 'defaultValue' | 'selectedValue' | 'open' | 'defaultOpen' | 'searchTerm' | 'multiple' | 'disabled' | 'name' | 'resetSearchTermOnBlur'>, UseComponentIconsProps {
31+
export interface SelectMenuProps<T extends SelectItemType<I>, I extends SelectItems<SelectMenuItem | AcceptableValue> = SelectItems<SelectMenuItem | AcceptableValue>, V extends SelectItemKey<T> | undefined = undefined, M extends boolean = false> extends Pick<ComboboxRootProps<T>, 'defaultValue' | 'selectedValue' | 'open' | 'defaultOpen' | 'searchTerm' | 'disabled' | 'name' | 'resetSearchTermOnBlur'>, UseComponentIconsProps {
3232
id?: string
3333
/** The placeholder text when the select is empty. */
3434
placeholder?: string
@@ -77,24 +77,28 @@ export interface SelectMenuProps<T> extends Pick<ComboboxRootProps<T>, 'modelVal
7777
* When `items` is an array of objects, select the field to use as the value instead of the object itself.
7878
* @defaultValue undefined
7979
*/
80-
valueKey?: keyof T
80+
valueKey?: V
8181
/**
8282
* When `items` is an array of objects, select the field to use as the label.
8383
* @defaultValue 'label'
8484
*/
85-
labelKey?: keyof T
86-
items?: T[] | T[][]
85+
labelKey?: SelectItemKey<T>
86+
items?: I
8787
/** Highlight the ring color like a focus state. */
8888
highlight?: boolean
8989
class?: any
9090
ui?: PartialString<typeof selectMenu.slots>
91+
/** The controlled value of the Combobox. Can be binded-with with `v-model`. */
92+
modelValue?: SelectModelValue<T, V, M>
93+
/** Whether multiple options can be selected or not. */
94+
multiple?: M
9195
}
9296
93-
export type SelectMenuEmits<T> = ComboboxRootEmits<T> & {
97+
export type SelectMenuEmits<T, V, M extends boolean> = Omit<ComboboxRootEmits<T>, 'update:modelValue'> & {
9498
change: [payload: Event]
9599
blur: [payload: FocusEvent]
96100
focus: [payload: FocusEvent]
97-
}
101+
} & SelectModelValueEmits<T, V, M>
98102
99103
type SlotProps<T> = (props: { item: T, index: number }) => any
100104
@@ -110,7 +114,7 @@ export interface SelectMenuSlots<T> {
110114
}
111115
</script>
112116

113-
<script setup lang="ts" generic="T extends SelectMenuItem | AcceptableValue">
117+
<script setup lang="ts" generic="T extends SelectItemType<I>, I extends SelectItems<SelectMenuItem | AcceptableValue> = SelectItems<SelectMenuItem | AcceptableValue>, V extends SelectItemKey<T> | undefined = undefined, M extends boolean = false">
114118
import { computed, toRef } from 'vue'
115119
import { ComboboxRoot, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, useForwardPropsEmits } from 'radix-vue'
116120
import { defu } from 'defu'
@@ -125,15 +129,16 @@ import UIcon from './Icon.vue'
125129
import UAvatar from './Avatar.vue'
126130
import UChip from './Chip.vue'
127131
128-
const props = withDefaults(defineProps<SelectMenuProps<T>>(), {
132+
const props = withDefaults(defineProps<SelectMenuProps<T, I, V, M>>(), {
129133
search: true,
130134
portal: true,
131135
autofocusDelay: 0,
132136
searchInput: () => ({ placeholder: 'Search...' }),
133137
filter: () => ['label'],
134-
labelKey: 'label' as keyof T
138+
labelKey: 'label' as never
135139
})
136-
const emits = defineEmits<SelectMenuEmits<T>>()
140+
141+
const emits = defineEmits<SelectMenuEmits<T, V, M>>()
137142
const slots = defineSlots<SelectMenuSlots<T>>()
138143
139144
const searchTerm = defineModel<string>('searchTerm', { default: '' })
@@ -158,7 +163,7 @@ const ui = computed(() => selectMenu({
158163
buttonGroup: orientation.value
159164
}))
160165
161-
function displayValue(value: T): string {
166+
function displayValue(value: T | T[]): string {
162167
if (props.multiple && Array.isArray(value)) {
163168
return value.map(v => displayValue(v)).join(', ')
164169
}
@@ -168,15 +173,15 @@ function displayValue(value: T): string {
168173
return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
169174
}
170175
171-
function filterFunction(items: ArrayOrWrapped<AcceptableValue>, searchTerm: string): ArrayOrWrapped<AcceptableValue> {
176+
function filterFunction(items: ArrayOrWrapped<T>, searchTerm: string): ArrayOrWrapped<T> {
172177
if (props.filter === false) {
173178
return items
174179
}
175180
176181
const fields = Array.isArray(props.filter) ? props.filter : [props.labelKey]
177182
const escapedSearchTerm = escapeRegExp(searchTerm)
178183
179-
return items.filter((item) => {
184+
return items.filter((item: T) => {
180185
if (typeof item !== 'object') {
181186
return String(item).search(new RegExp(escapedSearchTerm, 'i')) !== -1
182187
}

src/runtime/types/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,11 @@ export type ArrayOrWrapped<T> = T extends any[] ? T : Array<T>
2424
export type PartialString<T> = {
2525
[K in keyof T]?: string
2626
}
27+
28+
export type SelectItems<T> = T[] | T[][]
29+
export type SelectItemType<I extends SelectItems<unknown>> = I extends (infer U)[][] ? U : I extends (infer U)[] ? U : never
30+
export type SelectModelValue<T, V, M extends boolean = false, DV = T> = (T extends Record<string, any> ? V extends keyof T ? T[V] : DV : T) extends infer U ? M extends true ? U[] : U : never
31+
export type SelectItemKey<T> = (T extends Record<string, any> ? keyof T : string)
32+
export type SelectModelValueEmits<T, V, M extends boolean = false, DV = T> = {
33+
'update:modelValue': [payload: SelectModelValue<T, V, M, DV>]
34+
}

test/components/InputMenu.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import theme from '#build/ui/input'
55
import { renderForm } from '../utils/form'
66
import { flushPromises, mount } from '@vue/test-utils'
77
import type { FormInputEvents } from '~/src/module'
8+
import { expectEmitPayloadType } from '../utils/types'
89

910
describe('InputMenu', () => {
1011
const sizes = Object.keys(theme.variants.size) as any
@@ -157,5 +158,66 @@ describe('InputMenu', () => {
157158
await flushPromises()
158159
expect(wrapper.text()).not.toContain('Error message')
159160
})
161+
162+
test('should have the correct types', () => {
163+
// with object item
164+
expectEmitPayloadType('update:modelValue', () => InputMenu({
165+
items: [{ label: 'foo', value: 'bar' }]
166+
})).toEqualTypeOf<[{ label: string, value: string }]>()
167+
168+
// with object item and multiple
169+
expectEmitPayloadType('update:modelValue', () => InputMenu({
170+
items: [{ label: 'foo', value: 1 }],
171+
multiple: true
172+
})).toEqualTypeOf<[{ label: string, value: number }[]]>()
173+
174+
// with object item and valueKey
175+
expectEmitPayloadType('update:modelValue', () => InputMenu({
176+
items: [{ label: 'foo', value: 'bar' }],
177+
valueKey: 'value'
178+
})).toEqualTypeOf<[string]>()
179+
180+
// with object item and multiple and valueKey
181+
expectEmitPayloadType('update:modelValue', () => InputMenu({
182+
items: [{ label: 'foo', value: 1 }],
183+
multiple: true,
184+
valueKey: 'value'
185+
})).toEqualTypeOf<[number[]]>()
186+
187+
// with string item
188+
expectEmitPayloadType('update:modelValue', () => InputMenu({
189+
items: ['foo']
190+
})).toEqualTypeOf<[string]>()
191+
192+
// with string item and multiple
193+
expectEmitPayloadType('update:modelValue', () => InputMenu({
194+
items: ['foo'],
195+
multiple: true
196+
})).toEqualTypeOf<[string[]]>()
197+
198+
// with groups
199+
expectEmitPayloadType('update:modelValue', () => InputMenu({
200+
items: [['foo']]
201+
})).toEqualTypeOf<[string]>()
202+
203+
// with groups and multiple
204+
expectEmitPayloadType('update:modelValue', () => InputMenu({
205+
items: [['foo']],
206+
multiple: true
207+
})).toEqualTypeOf<[string[]]>()
208+
209+
// with groups, multiple and mixed types
210+
expectEmitPayloadType('update:modelValue', () => InputMenu({
211+
items: [['foo', { value: 1 }], [{ value: 'bar' }, 2]],
212+
multiple: true
213+
})).toEqualTypeOf<[(string | number | { value: string } | { value: number })[]]>()
214+
215+
// with groups, multiple, mixed types and valueKey
216+
expectEmitPayloadType('update:modelValue', () => InputMenu({
217+
items: [['foo', { value: 1 }], [{ value: 'bar' }, 2]],
218+
multiple: true,
219+
valueKey: 'value'
220+
})).toEqualTypeOf<[(string | number)[]]>()
221+
})
160222
})
161223
})

0 commit comments

Comments
 (0)