fix(NavigationMenu): improve generic types (#2482)

Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
Yasser Lahbibi
2024-10-29 19:21:35 +01:00
committed by GitHub
parent 03dd1eba7e
commit fc2015bb0e
7 changed files with 71 additions and 24 deletions

View File

@@ -7,7 +7,7 @@ import _appConfig from '#build/app.config'
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, SelectItems, SelectItemType, SelectModelValue, SelectModelValueEmits, SelectItemKey } from '../types/utils'
import type { AcceptableValue, ArrayOrWrapped, PartialString, MaybeArrayOfArray, MaybeArrayOfArrayItem, SelectModelValue, SelectModelValueEmits, SelectItemKey } from '../types/utils'
const appConfig = _appConfig as AppConfig & { ui: { inputMenu: Partial<typeof theme> } }
@@ -29,7 +29,7 @@ export interface InputMenuItem {
type InputMenuVariants = VariantProps<typeof inputMenu>
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 {
export interface InputMenuProps<T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<InputMenuItem | AcceptableValue> = MaybeArrayOfArray<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'
@@ -124,7 +124,7 @@ export interface InputMenuSlots<T> {
}
</script>
<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">
<script setup lang="ts" generic="T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<InputMenuItem | AcceptableValue> = MaybeArrayOfArray<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'

View File

@@ -6,7 +6,7 @@ import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/navigation-menu'
import type { AvatarProps, BadgeProps, LinkProps } from '../types'
import type { DynamicSlots, PartialString } from '../types/utils'
import type { DynamicSlots, MaybeArrayOfArray, MaybeArrayOfArrayItem, PartialString } from '../types/utils'
const appConfig = _appConfig as AppConfig & { ui: { navigationMenu: Partial<typeof theme> } }
@@ -45,7 +45,7 @@ export interface NavigationMenuProps<T> extends Pick<NavigationMenuRootProps, 'd
* @defaultValue appConfig.ui.icons.chevronDown
*/
trailingIcon?: string
items?: T[] | T[][]
items?: T
color?: NavigationMenuVariants['color']
variant?: NavigationMenuVariants['variant']
/**
@@ -85,10 +85,10 @@ export type NavigationMenuSlots<T extends { slot?: string }> = {
</script>
<script setup lang="ts" generic="T extends NavigationMenuItem">
import { computed, toRef } from 'vue'
<script setup lang="ts" generic="T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<NavigationMenuItem>">
import { computed, reactive, toRef } from 'vue'
import { NavigationMenuRoot, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuViewport, useForwardPropsEmits } from 'radix-vue'
import { reactivePick, createReusableTemplate } from '@vueuse/core'
import { createReusableTemplate } from '@vueuse/core'
import { get } from '../utils'
import { pickLinkProps } from '../utils/link'
import ULinkBase from './LinkBase.vue'
@@ -98,7 +98,7 @@ import UIcon from './Icon.vue'
import UBadge from './Badge.vue'
import UCollapsible from './Collapsible.vue'
const props = withDefaults(defineProps<NavigationMenuProps<T>>(), {
const props = withDefaults(defineProps<NavigationMenuProps<I>>(), {
orientation: 'horizontal',
delayDuration: 0,
labelKey: 'label'
@@ -106,7 +106,15 @@ const props = withDefaults(defineProps<NavigationMenuProps<T>>(), {
const emits = defineEmits<NavigationMenuEmits>()
const slots = defineSlots<NavigationMenuSlots<T>>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'delayDuration', 'skipDelayDuration', 'orientation'), emits)
const rootProps = useForwardPropsEmits(reactive({
as: props.as,
modelValue: props.modelValue,
defaultValue: props.defaultValue,
delayDuration: props.delayDuration,
skipDelayDuration: props.skipDelayDuration,
orientation: props.orientation
}), emits)
const contentProps = toRef(() => props.content)
const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: NavigationMenuItem, active?: boolean, index: number }>()
@@ -174,7 +182,7 @@ const lists = computed(() => props.items?.length ? (Array.isArray(props.items[0]
@select="item.onSelect"
>
<ULinkBase v-bind="slotProps" :class="ui.link({ class: [props.ui?.link, item.class], active, disabled: !!item.disabled })">
<ReuseItemTemplate :item="item" :active="active" :index="index" />
<ReuseItemTemplate :item="(item as T)" :active="active" :index="index" />
</ULinkBase>
</component>
@@ -210,7 +218,7 @@ const lists = computed(() => props.items?.length ? (Array.isArray(props.items[0]
<ULink v-slot="{ active: childActive, ...childSlotProps }" v-bind="pickLinkProps(childItem)" custom>
<NavigationMenuLink as-child :active="childActive" @select="childItem.onSelect">
<ULinkBase v-bind="childSlotProps" :class="ui.link({ class: [props.ui?.link, childItem.class], active: childActive, disabled: !!childItem.disabled })">
<ReuseItemTemplate :item="childItem" :active="childActive" :index="childIndex" />
<ReuseItemTemplate :item="(childItem as T)" :active="childActive" :index="childIndex" />
</ULinkBase>
</NavigationMenuLink>
</ULink>

View File

@@ -6,7 +6,7 @@ import _appConfig from '#build/app.config'
import theme from '#build/ui/select'
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import type { AvatarProps, ChipProps, InputProps } from '../types'
import type { AcceptableValue, PartialString, SelectItems, SelectItemType, SelectModelValue, SelectModelValueEmits, SelectItemKey } from '../types/utils'
import type { AcceptableValue, PartialString, MaybeArrayOfArray, MaybeArrayOfArrayItem, SelectModelValue, SelectModelValueEmits, SelectItemKey } from '../types/utils'
const appConfig = _appConfig as AppConfig & { ui: { select: Partial<typeof theme> } }
@@ -28,7 +28,7 @@ export interface SelectItem {
type SelectVariants = VariantProps<typeof select>
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 {
export interface SelectProps<T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<SelectItem | AcceptableValue> = MaybeArrayOfArray<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
@@ -97,7 +97,7 @@ export interface SelectSlots<T> {
}
</script>
<script setup lang="ts" generic="T extends SelectItemType<I>, I extends SelectItems<SelectItem | AcceptableValue> = SelectItems<SelectItem | AcceptableValue>, V extends SelectItemKey<T> | undefined = undefined">
<script setup lang="ts" generic="T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<SelectItem | AcceptableValue> = MaybeArrayOfArray<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'

View File

@@ -6,7 +6,7 @@ import _appConfig from '#build/app.config'
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, SelectItems, SelectItemType, SelectModelValue, SelectModelValueEmits, SelectItemKey } from '../types/utils'
import type { AcceptableValue, ArrayOrWrapped, PartialString, MaybeArrayOfArray, MaybeArrayOfArrayItem, SelectModelValue, SelectModelValueEmits, SelectItemKey } from '../types/utils'
const appConfig = _appConfig as AppConfig & { ui: { selectMenu: Partial<typeof theme> } }
@@ -28,7 +28,7 @@ export interface SelectMenuItem {
type SelectMenuVariants = VariantProps<typeof selectMenu>
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 {
export interface SelectMenuProps<T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<SelectMenuItem | AcceptableValue> = MaybeArrayOfArray<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
@@ -114,7 +114,7 @@ export interface SelectMenuSlots<T> {
}
</script>
<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">
<script setup lang="ts" generic="T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<SelectMenuItem | AcceptableValue> = MaybeArrayOfArray<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'

View File

@@ -25,10 +25,13 @@ 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 MaybeArrayOfArray<T> = T[] | T[][]
export type MaybeArrayOfArrayItem<I> = I extends Array<infer T> ? T extends Array<infer U> ? U : T : 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 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>]
}

View File

@@ -1,7 +1,8 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, test } from 'vitest'
import NavigationMenu, { type NavigationMenuProps, type NavigationMenuSlots } from '../../src/runtime/components/NavigationMenu.vue'
import ComponentRender from '../component-render'
import theme from '#build/ui/navigation-menu'
import { expectSlotProps } from '../utils/types'
describe('NavigationMenu', () => {
const variants = Object.keys(theme.variants.variant) as any
@@ -96,8 +97,30 @@ describe('NavigationMenu', () => {
['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
['with custom slot', { props, slots: { custom: () => 'Custom slot' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: NavigationMenuProps<typeof items[number][number]>, slots?: Partial<NavigationMenuSlots<any>> }) => {
])('renders %s correctly', async (nameOrHtml: string, options: { props?: NavigationMenuProps<typeof items>, slots?: Partial<NavigationMenuSlots<any>> }) => {
const html = await ComponentRender(nameOrHtml, options, NavigationMenu)
expect(html).toMatchSnapshot()
})
test('should have the correct types', () => {
// normal
expectSlotProps('item', () => NavigationMenu({
items: [{ label: 'foo', value: 'bar' }]
})).toEqualTypeOf<{ item: { label: string, value: string }, index: number, active?: boolean }>()
// groups
expectSlotProps('item', () => NavigationMenu({
items: [[{ label: 'foo', value: 'bar' }]]
})).toEqualTypeOf<{ item: { label: string, value: string }, index: number, active?: boolean }>()
// custom
expectSlotProps('item', () => NavigationMenu({
items: [{ label: 'foo', value: 'bar', custom: 'nice' }]
})).toEqualTypeOf<{ item: { label: string, value: string, custom: string }, index: number, active?: boolean }>()
// custom + groups
expectSlotProps('item', () => NavigationMenu({
items: [[{ label: 'foo', value: 'bar', custom: 'nice' }]]
})).toEqualTypeOf<{ item: { label: string, value: string, custom: string }, index: number, active?: boolean }>()
})
})

View File

@@ -8,6 +8,19 @@ export function expectEmitPayloadType<T extends VNode, E extends keyof Events<T>
return expectTypeOf<NonNullable<Events<T>[E]>>()
}
type Events<T> = T extends { __ctx?: { props: infer Props } } ? {
/**
* Expect the type of a slot props.
*/
export function expectSlotProps<T extends VNode, S extends keyof Slots<T>>(_name: S, _cb: () => T) {
return expectTypeOf<Slots<T>[S]>()
}
type Ctx<V extends VNode> = V extends { __ctx?: infer C } ? NonNullable<C> : never
type Slots<V extends VNode> = Ctx<V> extends { slots: infer S } ? {
[K in keyof S as S[K] extends never ? never : K]: S[K] extends (props: infer P) => any ? P : never
} : never
type Events<V extends VNode> = Ctx<V> extends { props: infer Props } ? {
[K in keyof Props as K extends `on${infer E}${infer Rest}` ? `${Lowercase<E>}${Rest}` : never]: NonNullable<Props[K]> extends (...args: infer P) => any ? P : never
} : never