Skip to content

fix(InputMenu/Select/SelectMenu): improve types #2471

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions playground/app/pages/components/input-menu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const fruits = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple']
const vegetables = ['Aubergine', 'Broccoli', 'Carrot', 'Courgette', 'Leek']

const items = [[{ label: 'Fruits', type: 'label' }, ...fruits], [{ label: 'Vegetables', type: 'label' }, ...vegetables]]
const selectedItems = ref([fruits[0], vegetables[0]])
const selectedItems = ref([fruits[0]!, vegetables[0]!])

const statuses = [{
label: 'Backlog',
Expand Down Expand Up @@ -135,7 +135,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
v-for="size in sizes"
:key="size"
:items="items"
:model-value="[fruits[0]]"
:model-value="[fruits[0]!]"
multiple
icon="i-heroicons-magnifying-glass"
placeholder="Search..."
Expand Down
2 changes: 1 addition & 1 deletion playground/app/pages/components/select-menu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const fruits = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple']
const vegetables = ['Aubergine', 'Broccoli', 'Carrot', 'Courgette', 'Leek']

const items = [[{ label: 'Fruits', type: 'label' }, ...fruits], [{ label: 'Vegetables', type: 'label' }, ...vegetables]]
const selectedItems = ref([fruits[0], vegetables[0]])
const selectedItems = ref([fruits[0]!, vegetables[0]!])

const statuses = [{
label: 'Backlog',
Expand Down
22 changes: 13 additions & 9 deletions src/runtime/components/InputMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import theme from '#build/ui/input-menu'
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import type { AvatarProps, ChipProps, InputProps } from '../types'
import type { AcceptableValue, ArrayOrWrapped, PartialString } from '../types/utils'
import type { AcceptableValue, ArrayOrWrapped, PartialString, SelectItems, SelectItemType, SelectModelValue, SelectModelValueEmits, SelectItemKey } from '../types/utils'

const appConfig = _appConfig as AppConfig & { ui: { inputMenu: Partial<typeof theme> } }

Expand All @@ -29,7 +29,7 @@

type InputMenuVariants = VariantProps<typeof inputMenu>

export interface InputMenuProps<T> extends Pick<ComboboxRootProps<T>, 'modelValue' | 'defaultValue' | 'selectedValue' | 'open' | 'defaultOpen' | 'searchTerm' | 'multiple' | 'disabled' | 'name' | 'resetSearchTermOnBlur'>, UseComponentIconsProps {
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 {
/**
* The element or component this component should render as.
* @defaultValue 'div'
Expand Down Expand Up @@ -86,24 +86,28 @@
* When `items` is an array of objects, select the field to use as the value instead of the object itself.
* @defaultValue undefined
*/
valueKey?: keyof T
valueKey?: V
/**
* When `items` is an array of objects, select the field to use as the label.
* @defaultValue 'label'
*/
labelKey?: keyof T
items?: T[] | T[][]
items?: I
/** Highlight the ring color like a focus state. */
highlight?: boolean
class?: any
ui?: PartialString<typeof inputMenu.slots>
/** The controlled value of the Combobox. Can be binded-with with `v-model`. */
modelValue?: SelectModelValue<T, V, M>
/** Whether multiple options can be selected or not. */
multiple?: M
}

export type InputMenuEmits<T> = ComboboxRootEmits<T> & {
export type InputMenuEmits<T, V, M extends boolean> = Omit<ComboboxRootEmits<T>, 'update:modelValue'> & {
change: [payload: Event]
blur: [payload: FocusEvent]
focus: [payload: FocusEvent]
}
} & SelectModelValueEmits<T, V, M>

type SlotProps<T> = (props: { item: T, index: number }) => any

Expand All @@ -120,7 +124,7 @@
}
</script>

<script setup lang="ts" generic="T extends InputMenuItem | AcceptableValue">
<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">
import { computed, ref, toRef, onMounted } from 'vue'
import { ComboboxRoot, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, TagsInputRoot, TagsInputItem, TagsInputItemText, TagsInputItemDelete, TagsInputInput, useForwardPropsEmits } from 'radix-vue'
import { defu } from 'defu'
Expand All @@ -137,14 +141,14 @@

defineOptions({ inheritAttrs: false })

const props = withDefaults(defineProps<InputMenuProps<T>>(), {
const props = withDefaults(defineProps<InputMenuProps<T, I, V, M>>(), {
type: 'text',
autofocusDelay: 0,
portal: true,
filter: () => ['label'],
labelKey: 'label' as keyof T
})
const emits = defineEmits<InputMenuEmits<T>>()
const emits = defineEmits<InputMenuEmits<T, V, M>>()
const slots = defineSlots<InputMenuSlots<T>>()

const searchTerm = defineModel<string>('searchTerm', { default: '' })
Expand Down Expand Up @@ -251,7 +255,7 @@
<template>
<ComboboxRoot
:id="id"
v-slot="{ modelValue, open }"

Check warning on line 258 in src/runtime/components/InputMenu.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 20)

Variable 'modelValue' is already declared in the upper scope
v-bind="rootProps"
v-model:search-term="searchTerm"
:name="name"
Expand Down
29 changes: 17 additions & 12 deletions src/runtime/components/Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import theme from '#build/ui/select'
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import type { AvatarProps, ChipProps, InputProps } from '../types'
import type { AcceptableValue, PartialString } from '../types/utils'
import type { AcceptableValue, PartialString, SelectItems, SelectItemType, SelectModelValue, SelectModelValueEmits, SelectItemKey } from '../types/utils'

const appConfig = _appConfig as AppConfig & { ui: { select: Partial<typeof theme> } }

Expand All @@ -28,7 +28,7 @@

type SelectVariants = VariantProps<typeof select>

export interface SelectProps<T> extends Omit<SelectRootProps, 'dir'>, UseComponentIconsProps {
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 {
id?: string
/** The placeholder text when the select is empty. */
placeholder?: string
Expand Down Expand Up @@ -64,24 +64,26 @@
* When `items` is an array of objects, select the field to use as the value.
* @defaultValue 'value'
*/
valueKey?: string
valueKey?: V
/**
* When `items` is an array of objects, select the field to use as the label.
* @defaultValue 'label'
*/
labelKey?: string
items?: T[] | T[][]
labelKey?: SelectItemKey<T>
items?: I
/** Highlight the ring color like a focus state. */
highlight?: boolean
class?: any
ui?: PartialString<typeof select.slots>
/** The controlled value of the Select. Can be bind as `v-model`. */
modelValue?: SelectModelValue<T, V, false, T extends { value: infer U } ? U : never>
}

export type SelectEmits = SelectRootEmits & {
export type SelectEmits<T, V> = Omit<SelectRootEmits, 'update:modelValue'> & {
change: [payload: Event]
blur: [payload: FocusEvent]
focus: [payload: FocusEvent]
}
} & SelectModelValueEmits<T, V, false, T extends { value: infer U } ? U : never>

type SlotProps<T> = (props: { item: T, index: number }) => any

Expand All @@ -95,7 +97,7 @@
}
</script>

<script setup lang="ts" generic="T extends SelectItem | AcceptableValue">
<script setup lang="ts" generic="T extends SelectItemType<I>, I extends SelectItems<SelectItem | AcceptableValue> = SelectItems<SelectItem | AcceptableValue>, V extends SelectItemKey<T> | undefined = undefined">
import { computed, toRef } from 'vue'
import { SelectRoot, SelectTrigger, SelectValue, SelectPortal, SelectContent, SelectViewport, SelectLabel, SelectGroup, SelectItem, SelectItemIndicator, SelectItemText, SelectSeparator, useForwardPropsEmits } from 'radix-vue'
import { defu } from 'defu'
Expand All @@ -109,12 +111,12 @@
import UAvatar from './Avatar.vue'
import UChip from './Chip.vue'

const props = withDefaults(defineProps<SelectProps<T>>(), {
valueKey: 'value',
labelKey: 'label',
const props = withDefaults(defineProps<SelectProps<T, I, V>>(), {
valueKey: 'value' as never,
labelKey: 'label' as never,
portal: true
})
const emits = defineEmits<SelectEmits>()
const emits = defineEmits<SelectEmits<T, V>>()
const slots = defineSlots<SelectSlots<T>>()

const appConfig = useAppConfig()
Expand Down Expand Up @@ -163,9 +165,12 @@
<template>
<SelectRoot
:id="id"
v-slot="{ modelValue, open }"

Check warning on line 168 in src/runtime/components/Select.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 20)

Variable 'modelValue' is already declared in the upper scope
v-bind="rootProps"
:name="name"
:default-value="(defaultValue as string)"
:model-value="(modelValue as string)"
:autocomplete="autocomplete"
:disabled="disabled"
@update:model-value="onUpdate"
@update:open="onUpdateOpen"
Expand Down
33 changes: 19 additions & 14 deletions src/runtime/components/SelectMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import theme from '#build/ui/select-menu'
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import type { AvatarProps, ChipProps, InputProps } from '../types'
import type { AcceptableValue, ArrayOrWrapped, PartialString } from '../types/utils'
import type { AcceptableValue, ArrayOrWrapped, PartialString, SelectItems, SelectItemType, SelectModelValue, SelectModelValueEmits, SelectItemKey } from '../types/utils'

const appConfig = _appConfig as AppConfig & { ui: { selectMenu: Partial<typeof theme> } }

Expand All @@ -28,7 +28,7 @@

type SelectMenuVariants = VariantProps<typeof selectMenu>

export interface SelectMenuProps<T> extends Pick<ComboboxRootProps<T>, 'modelValue' | 'defaultValue' | 'selectedValue' | 'open' | 'defaultOpen' | 'searchTerm' | 'multiple' | 'disabled' | 'name' | 'resetSearchTermOnBlur'>, UseComponentIconsProps {
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 {
id?: string
/** The placeholder text when the select is empty. */
placeholder?: string
Expand Down Expand Up @@ -77,24 +77,28 @@
* When `items` is an array of objects, select the field to use as the value instead of the object itself.
* @defaultValue undefined
*/
valueKey?: keyof T
valueKey?: V
/**
* When `items` is an array of objects, select the field to use as the label.
* @defaultValue 'label'
*/
labelKey?: keyof T
items?: T[] | T[][]
labelKey?: SelectItemKey<T>
items?: I
/** Highlight the ring color like a focus state. */
highlight?: boolean
class?: any
ui?: PartialString<typeof selectMenu.slots>
/** The controlled value of the Combobox. Can be binded-with with `v-model`. */
modelValue?: SelectModelValue<T, V, M>
/** Whether multiple options can be selected or not. */
multiple?: M
}

export type SelectMenuEmits<T> = ComboboxRootEmits<T> & {
export type SelectMenuEmits<T, V, M extends boolean> = Omit<ComboboxRootEmits<T>, 'update:modelValue'> & {
change: [payload: Event]
blur: [payload: FocusEvent]
focus: [payload: FocusEvent]
}
} & SelectModelValueEmits<T, V, M>

type SlotProps<T> = (props: { item: T, index: number }) => any

Expand All @@ -110,7 +114,7 @@
}
</script>

<script setup lang="ts" generic="T extends SelectMenuItem | AcceptableValue">
<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">
import { computed, toRef } from 'vue'
import { ComboboxRoot, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, useForwardPropsEmits } from 'radix-vue'
import { defu } from 'defu'
Expand All @@ -125,15 +129,16 @@
import UAvatar from './Avatar.vue'
import UChip from './Chip.vue'

const props = withDefaults(defineProps<SelectMenuProps<T>>(), {
const props = withDefaults(defineProps<SelectMenuProps<T, I, V, M>>(), {
search: true,
portal: true,
autofocusDelay: 0,
searchInput: () => ({ placeholder: 'Search...' }),
filter: () => ['label'],
labelKey: 'label' as keyof T
labelKey: 'label' as never
})
const emits = defineEmits<SelectMenuEmits<T>>()

const emits = defineEmits<SelectMenuEmits<T, V, M>>()
const slots = defineSlots<SelectMenuSlots<T>>()

const searchTerm = defineModel<string>('searchTerm', { default: '' })
Expand All @@ -158,7 +163,7 @@
buttonGroup: orientation.value
}))

function displayValue(value: T): string {
function displayValue(value: T | T[]): string {
if (props.multiple && Array.isArray(value)) {
return value.map(v => displayValue(v)).join(', ')
}
Expand All @@ -168,15 +173,15 @@
return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
}

function filterFunction(items: ArrayOrWrapped<AcceptableValue>, searchTerm: string): ArrayOrWrapped<AcceptableValue> {
function filterFunction(items: ArrayOrWrapped<T>, searchTerm: string): ArrayOrWrapped<T> {
if (props.filter === false) {
return items
}

const fields = Array.isArray(props.filter) ? props.filter : [props.labelKey]
const escapedSearchTerm = escapeRegExp(searchTerm)

return items.filter((item) => {
return items.filter((item: T) => {
if (typeof item !== 'object') {
return String(item).search(new RegExp(escapedSearchTerm, 'i')) !== -1
}
Expand Down Expand Up @@ -216,7 +221,7 @@
<template>
<ComboboxRoot
:id="id"
v-slot="{ modelValue, open }"

Check warning on line 224 in src/runtime/components/SelectMenu.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 20)

Variable 'modelValue' is already declared in the upper scope
v-bind="rootProps"
v-model:search-term="searchTerm"
as-child
Expand Down
8 changes: 8 additions & 0 deletions src/runtime/types/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,11 @@ export type ArrayOrWrapped<T> = T extends any[] ? T : Array<T>
export type PartialString<T> = {
[K in keyof T]?: string
}

export type SelectItems<T> = T[] | T[][]
export type SelectItemType<I extends SelectItems<unknown>> = I extends (infer U)[][] ? U : I extends (infer U)[] ? U : never
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
export type SelectItemKey<T> = (T extends Record<string, any> ? keyof T : string)
export type SelectModelValueEmits<T, V, M extends boolean = false, DV = T> = {
'update:modelValue': [payload: SelectModelValue<T, V, M, DV>]
}
62 changes: 62 additions & 0 deletions test/components/InputMenu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import theme from '#build/ui/input'
import { renderForm } from '../utils/form'
import { flushPromises, mount } from '@vue/test-utils'
import type { FormInputEvents } from '~/src/module'
import { expectEmitPayloadType } from '../utils/types'

describe('InputMenu', () => {
const sizes = Object.keys(theme.variants.size) as any
Expand Down Expand Up @@ -157,5 +158,66 @@ describe('InputMenu', () => {
await flushPromises()
expect(wrapper.text()).not.toContain('Error message')
})

test('should have the correct types', () => {
// with object item
expectEmitPayloadType('update:modelValue', () => InputMenu({
items: [{ label: 'foo', value: 'bar' }]
})).toEqualTypeOf<[{ label: string, value: string }]>()

// with object item and multiple
expectEmitPayloadType('update:modelValue', () => InputMenu({
items: [{ label: 'foo', value: 1 }],
multiple: true
})).toEqualTypeOf<[{ label: string, value: number }[]]>()

// with object item and valueKey
expectEmitPayloadType('update:modelValue', () => InputMenu({
items: [{ label: 'foo', value: 'bar' }],
valueKey: 'value'
})).toEqualTypeOf<[string]>()

// with object item and multiple and valueKey
expectEmitPayloadType('update:modelValue', () => InputMenu({
items: [{ label: 'foo', value: 1 }],
multiple: true,
valueKey: 'value'
})).toEqualTypeOf<[number[]]>()

// with string item
expectEmitPayloadType('update:modelValue', () => InputMenu({
items: ['foo']
})).toEqualTypeOf<[string]>()

// with string item and multiple
expectEmitPayloadType('update:modelValue', () => InputMenu({
items: ['foo'],
multiple: true
})).toEqualTypeOf<[string[]]>()

// with groups
expectEmitPayloadType('update:modelValue', () => InputMenu({
items: [['foo']]
})).toEqualTypeOf<[string]>()

// with groups and multiple
expectEmitPayloadType('update:modelValue', () => InputMenu({
items: [['foo']],
multiple: true
})).toEqualTypeOf<[string[]]>()

// with groups, multiple and mixed types
expectEmitPayloadType('update:modelValue', () => InputMenu({
items: [['foo', { value: 1 }], [{ value: 'bar' }, 2]],
multiple: true
})).toEqualTypeOf<[(string | number | { value: string } | { value: number })[]]>()

// with groups, multiple, mixed types and valueKey
expectEmitPayloadType('update:modelValue', () => InputMenu({
items: [['foo', { value: 1 }], [{ value: 'bar' }, 2]],
multiple: true,
valueKey: 'value'
})).toEqualTypeOf<[(string | number)[]]>()
})
})
})
Loading