diff --git a/package.json b/package.json index a1894085e2..af4d4f6d0e 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-vue": "^7.0.0-beta.0", "esno": "^0.9.1", + "expect-type": "^0.12.0", "fast-glob": "^3.2.7", "file-save": "^0.2.0", "gulp": "^4.0.2", diff --git a/packages/components/affix/src/affix.ts b/packages/components/affix/src/affix.ts index 9026688e56..59bf4e0877 100644 --- a/packages/components/affix/src/affix.ts +++ b/packages/components/affix/src/affix.ts @@ -1,13 +1,13 @@ -import { buildProp } from '@element-plus/utils/props' +import { buildProp, definePropType } from '@element-plus/utils/props' import type { ExtractPropTypes } from 'vue' import type { ZIndexProperty } from 'csstype' export const affixProps = { - zIndex: buildProp({ - type: [Number, String], + zIndex: buildProp({ + type: definePropType([Number, String]), default: 100, - }), + } as const), target: { type: String, default: '', diff --git a/packages/components/avatar/src/avatar.ts b/packages/components/avatar/src/avatar.ts index 64725a1f27..4fcc237b44 100644 --- a/packages/components/avatar/src/avatar.ts +++ b/packages/components/avatar/src/avatar.ts @@ -1,5 +1,6 @@ -import { buildProp } from '@element-plus/utils/props' -import type { CSSProperties, ExtractPropTypes } from 'vue' +import { buildProp, definePropType } from '@element-plus/utils/props' +import type { ExtractPropTypes } from 'vue' +import type { ObjectFitProperty } from 'csstype' export const avatarProps = { size: buildProp({ @@ -22,8 +23,8 @@ export const avatarProps = { }, alt: String, srcSet: String, - fit: buildProp({ - type: String, + fit: buildProp({ + type: definePropType(String), default: 'cover', } as const), } as const diff --git a/packages/components/badge/src/badge.ts b/packages/components/badge/src/badge.ts index e199ca3ee6..8615f1f47c 100644 --- a/packages/components/badge/src/badge.ts +++ b/packages/components/badge/src/badge.ts @@ -3,10 +3,10 @@ import { buildProp } from '@element-plus/utils/props' import type { ExtractPropTypes } from 'vue' export const badgeProps = { - value: buildProp({ + value: buildProp({ type: [String, Number], default: '', - }), + } as const), max: { type: Number, default: 99, diff --git a/packages/components/breadcrumb/src/breadcrumb-item.ts b/packages/components/breadcrumb/src/breadcrumb-item.ts index 3ba266b99e..fbb8b67c04 100644 --- a/packages/components/breadcrumb/src/breadcrumb-item.ts +++ b/packages/components/breadcrumb/src/breadcrumb-item.ts @@ -1,13 +1,13 @@ -import { buildProp } from '@element-plus/utils/props' +import { buildProp, definePropType } from '@element-plus/utils/props' import type { ExtractPropTypes } from 'vue' import type { RouteLocationRaw } from 'vue-router' export const breadcrumbItemProps = { - to: buildProp({ - type: [String, Object], + to: buildProp({ + type: definePropType([String, Object]), default: '', - }), + } as const), replace: { type: Boolean, default: false, diff --git a/packages/components/card/src/card.ts b/packages/components/card/src/card.ts index 308d68d047..354edbe1c6 100644 --- a/packages/components/card/src/card.ts +++ b/packages/components/card/src/card.ts @@ -1,4 +1,4 @@ -import { buildProp } from '@element-plus/utils/props' +import { buildProp, definePropType } from '@element-plus/utils/props' import type { ExtractPropTypes } from 'vue' import type { StyleValue } from '@element-plus/utils/types' @@ -7,10 +7,10 @@ export const cardProps = { type: String, default: '', }, - bodyStyle: buildProp({ - type: [String, Object, Array], + bodyStyle: buildProp({ + type: definePropType([String, Object, Array]), default: '', - }), + } as const), shadow: { type: String, default: '', diff --git a/packages/components/dialog/src/dialog.ts b/packages/components/dialog/src/dialog.ts index ec1d4fea53..2668c20205 100644 --- a/packages/components/dialog/src/dialog.ts +++ b/packages/components/dialog/src/dialog.ts @@ -1,5 +1,5 @@ import { isValidWidthUnit } from '@element-plus/utils/validators' -import { buildProp } from '@element-plus/utils/props' +import { buildProp, definePropType } from '@element-plus/utils/props' import { UPDATE_MODEL_EVENT } from '@element-plus/utils/constants' import type { ExtractPropTypes } from 'vue' @@ -9,8 +9,8 @@ export const dialogProps = { type: Boolean, default: false, }, - beforeClose: buildProp<(...args: any[]) => void>({ - type: Function, + beforeClose: buildProp({ + type: definePropType<(...args: any[]) => void>(Function), }), destroyOnClose: { type: Boolean, @@ -68,7 +68,7 @@ export const dialogProps = { required: true, }, modalClass: String, - width: buildProp({ + width: buildProp({ type: [String, Number], validator: isValidWidthUnit, }), diff --git a/packages/components/message/src/message.ts b/packages/components/message/src/message.ts index 68db13643e..68155b3cda 100644 --- a/packages/components/message/src/message.ts +++ b/packages/components/message/src/message.ts @@ -1,4 +1,4 @@ -import { buildProp } from '@element-plus/utils/props' +import { buildProp, definePropType } from '@element-plus/utils/props' import type { VNode, ExtractPropTypes } from 'vue' @@ -29,12 +29,12 @@ export const messageProps = { type: String, default: '', }, - message: buildProp({ - type: [String, Object], + message: buildProp({ + type: definePropType([String, Object]), default: '', - }), - onClose: buildProp<() => void>({ - type: Function, + } as const), + onClose: buildProp({ + type: definePropType<() => void>(Function), required: false, }), showClose: { diff --git a/packages/components/notification/src/notification.ts b/packages/components/notification/src/notification.ts index d2a12fb394..0d3fed9b35 100644 --- a/packages/components/notification/src/notification.ts +++ b/packages/components/notification/src/notification.ts @@ -1,4 +1,4 @@ -import { buildProp } from '@element-plus/utils/props' +import { buildProp, definePropType } from '@element-plus/utils/props' import type { VNode, ExtractPropTypes } from 'vue' @@ -30,20 +30,20 @@ export const notificationProps = { type: String, default: '', }, - message: buildProp({ - type: [String, Object], + message: buildProp({ + type: definePropType([String, Object]), default: '', }), offset: { type: Number, default: 0, }, - onClick: buildProp<() => void>({ - type: Function, + onClick: buildProp({ + type: definePropType<() => void>(Function), default: () => undefined, }), - onClose: buildProp<() => void, boolean>({ - type: Function, + onClose: buildProp({ + type: definePropType<() => void>(Function), required: true, }), position: buildProp({ diff --git a/packages/components/overlay/src/overlay.ts b/packages/components/overlay/src/overlay.ts index 2f652cba03..2c0778c000 100644 --- a/packages/components/overlay/src/overlay.ts +++ b/packages/components/overlay/src/overlay.ts @@ -1,9 +1,10 @@ import { createVNode, defineComponent, renderSlot, h } from 'vue' import { PatchFlags } from '@element-plus/utils/vnode' import { useSameTarget } from '@element-plus/hooks' -import { buildProp } from '@element-plus/utils/props' +import { buildProp, definePropType } from '@element-plus/utils/props' import type { ExtractPropTypes, CSSProperties } from 'vue' +import type { ZIndexProperty } from 'csstype' export const overlayProps = { mask: { @@ -14,11 +15,15 @@ export const overlayProps = { type: Boolean, default: false, }, - overlayClass: buildProp>({ - type: [String, Array, Object], + overlayClass: buildProp({ + type: definePropType>([ + String, + Array, + Object, + ]), }), - zIndex: buildProp({ - type: [String, Number], + zIndex: buildProp({ + type: definePropType([String, Number]), }), } as const export type OverlayProps = ExtractPropTypes diff --git a/packages/components/pagination/src/components/sizes.vue b/packages/components/pagination/src/components/sizes.vue index 15788c9a65..feb0227256 100644 --- a/packages/components/pagination/src/components/sizes.vue +++ b/packages/components/pagination/src/components/sizes.vue @@ -22,20 +22,19 @@ import { defineComponent, watch, computed, ref } from 'vue' import isEqual from 'lodash/isEqual' import { ElSelect, ElOption } from '@element-plus/components/select' import { useLocaleInject } from '@element-plus/hooks' -import { buildProp, mutable } from '@element-plus/utils/props' +import { buildProp, definePropType, mutable } from '@element-plus/utils/props' import { usePagination } from '../usePagination' import type { Nullable } from '@element-plus/utils/types' -const defaultPageSizes = mutable([10, 20, 30, 40, 50, 100] as const) const paginationSizesProps = { pageSize: { type: Number, required: true, }, - pageSizes: buildProp({ - type: Array, - default: () => defaultPageSizes, + pageSizes: buildProp({ + type: definePropType(Array), + default: () => mutable([10, 20, 30, 40, 50, 100] as const), } as const), popperClass: { type: String, diff --git a/packages/components/pagination/src/pagination.ts b/packages/components/pagination/src/pagination.ts index 783da9d884..804d7bae58 100644 --- a/packages/components/pagination/src/pagination.ts +++ b/packages/components/pagination/src/pagination.ts @@ -9,7 +9,7 @@ import { } from 'vue' import { useLocaleInject } from '@element-plus/hooks' import { debugWarn } from '@element-plus/utils/error' -import { buildProp, mutable } from '@element-plus/utils/props' +import { buildProp, definePropType, mutable } from '@element-plus/utils/props' import { elPaginationKey } from '@element-plus/tokens' import Prev from './components/prev.vue' @@ -38,8 +38,6 @@ type LayoutKey = | 'sizes' | 'slot' -const defaultPageSizes = mutable([10, 20, 30, 40, 50, 100] as const) - export const paginationProps = { total: Number, pageSize: Number, @@ -66,9 +64,9 @@ export const paginationProps = { ['prev', 'pager', 'next', 'jumper', '->', 'total'] as LayoutKey[] ).join(', '), }, - pageSizes: buildProp({ - type: Array, - default: () => defaultPageSizes, + pageSizes: buildProp({ + type: definePropType(Array), + default: () => mutable([10, 20, 30, 40, 50, 100] as const), }), popperClass: { type: String, diff --git a/packages/utils/props.ts b/packages/utils/props.ts index fd8df52ab8..bc6569c382 100644 --- a/packages/utils/props.ts +++ b/packages/utils/props.ts @@ -1,5 +1,21 @@ +import { debugWarn } from './error' +import type { ExtractPropTypes, PropType } from '@vue/runtime-core' import type { Mutable } from './types' -import type { PropType } from 'vue' + +const wrapperKey = Symbol() +export type PropWrapper = { [wrapperKey]: T } + +type ResolveProp = ExtractPropTypes<{ + key: { type: T; required: true } +}>['key'] +type ResolvePropType = ResolveProp extends { type: infer V } + ? V + : ResolveProp +type ResolvePropTypeWithReadonly = Readonly extends Readonly< + Array +> + ? ResolvePropType + : ResolvePropType /** * @description Build prop. It can better optimize prop types @@ -22,9 +38,14 @@ import type { PropType } from 'vue' @link see more: https://github.com/element-plus/element-plus/pull/3341 */ export function buildProp< - T = any, + T = never, + D extends + | (T extends PropWrapper + ? T[typeof wrapperKey] + : ResolvePropTypeWithReadonly) + | V = never, R extends boolean = false, - D extends T = T, + V = never, C = never >({ values, @@ -33,44 +54,72 @@ export function buildProp< type, validator, }: { - values?: readonly T[] + type?: T + values?: readonly V[] required?: R default?: R extends true ? never : D extends Record | Array ? () => D : D - type?: any validator?: ((val: any) => val is C) | ((val: any) => boolean) } = {}) { - type DefaultType = typeof defaultValue - type HasDefaultValue = Exclude extends never ? false : true + type HasDefaultValue = Exclude extends never ? false : true + type Type = PropType< + | (T extends PropWrapper + ? T[typeof wrapperKey] + : [V] extends [never] + ? ResolvePropTypeWithReadonly + : never) + | V + | C + > return { - type: type as PropType, + type: ((type as any)?.[wrapperKey] || type) as unknown as Type, required: !!required as R, - default: defaultValue as R extends true + default: defaultValue as unknown as R extends true ? never : HasDefaultValue extends true - ? Exclude + ? Exclude< + D extends Record | Array ? () => D : D, + undefined + > : undefined, validator: values || validator ? (val: unknown) => { let valid = false - if (values) - valid ||= ([...values, defaultValue] as unknown[]).includes(val) + let allowedValues: unknown[] = [] + + if (values) { + allowedValues = [...values, defaultValue] + valid ||= allowedValues.includes(val) + } if (validator) valid ||= validator(val) + + if (!valid && allowedValues.length > 0) { + debugWarn( + `Vue warn`, + `Invalid prop: Expected one of (${allowedValues.join( + ', ' + )}), got value ${val}` + ) + } return valid } : undefined, } as const } +export const definePropType = (val: any) => + ({ [wrapperKey]: val } as PropWrapper) + export const keyOf = (arr: T) => Object.keys(arr) as Array -export const mutable = (val: T) => - val as Mutable +export const mutable = >( + val: T +) => val as Mutable export const componentSize = ['large', 'medium', 'small', 'mini'] as const diff --git a/packages/utils/tests/prop.spec.ts b/packages/utils/tests/prop.spec.ts new file mode 100644 index 0000000000..b973624712 --- /dev/null +++ b/packages/utils/tests/prop.spec.ts @@ -0,0 +1,246 @@ +import { expectTypeOf } from 'expect-type' +import { buildProp, definePropType, mutable, keyOf } from '../props' + +import type { PropType } from 'vue' + +describe('buildProp', () => { + it('Only type', () => { + expectTypeOf( + buildProp({ + type: definePropType<'a' | 'b'>(String), + }) + ).toEqualTypeOf<{ + readonly type: PropType<'a' | 'b'> + readonly required: false + readonly default: undefined + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Only values', () => { + expectTypeOf( + buildProp({ + values: [1, 2, 3, 4], + } as const) + ).toEqualTypeOf<{ + readonly type: PropType<1 | 2 | 3 | 4> + readonly required: false + readonly default: undefined + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Type and values', () => { + expectTypeOf( + buildProp({ + type: definePropType(Array), + values: [1, 2, 3, 4], + } as const) + ).toEqualTypeOf<{ + readonly type: PropType<1 | 2 | 3 | 4 | number[]> + readonly required: false + readonly default: undefined + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Values and validator', () => { + expectTypeOf( + buildProp({ + values: ['a', 'b', 'c'], + validator: (val: unknown): val is number => typeof val === 'number', + } as const) + ).toEqualTypeOf<{ + readonly type: PropType + readonly required: false + readonly default: undefined + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Values and required', () => { + expectTypeOf( + buildProp({ + values: ['a', 'b', 'c'], + required: true, + } as const) + ).toEqualTypeOf<{ + readonly type: PropType<'a' | 'b' | 'c'> + readonly required: true + readonly default: never + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Value and default', () => { + expectTypeOf( + buildProp({ + values: ['a', 'b', 'c'], + required: false, + default: 'b', + } as const) + ).toEqualTypeOf<{ + readonly type: PropType<'a' | 'b' | 'c'> + readonly required: false + readonly default: 'b' + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Type and Array default value', () => { + expectTypeOf( + buildProp({ + type: definePropType(Array), + default: () => mutable(['a', 'b'] as const), + } as const) + ).toEqualTypeOf<{ + readonly type: PropType + readonly required: false + readonly default: () => ['a', 'b'] + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Type and Object default value', () => { + interface Options { + key: string + } + + expectTypeOf( + buildProp({ + type: definePropType(Object), + default: () => mutable({ key: 'value' } as const), + } as const) + ).toEqualTypeOf<{ + readonly type: PropType + readonly required: false + readonly default: () => { key: 'value' } + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Type, validator and Object default value', () => { + interface Options { + key: string + } + expectTypeOf( + buildProp({ + type: definePropType(Object), + default: () => ({ key: 'value' }), + validator: (val: unknown): val is string => true, + } as const) + ).toEqualTypeOf<{ + readonly type: PropType + readonly required: false + readonly default: () => { key: string } + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Type, validator, required', () => { + expectTypeOf( + buildProp({ + type: definePropType<'a' | 'b' | 'c'>(String), + required: true, + validator: (val: unknown): val is number => true, + } as const) + ).toEqualTypeOf<{ + readonly type: PropType + readonly required: true + readonly default: never + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Normal type', () => { + expectTypeOf( + buildProp({ + type: String, + }) + ).toEqualTypeOf<{ + readonly type: PropType + readonly required: false + readonly default: undefined + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Normal types', () => { + expectTypeOf(buildProp({ type: [String, Number, Boolean] })).toEqualTypeOf<{ + readonly type: PropType + readonly required: false + readonly default: undefined + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Normal type and values', () => { + expectTypeOf( + buildProp({ + type: String, + values: ['1', '2', '3'], + } as const) + ).toEqualTypeOf<{ + readonly type: PropType<'1' | '2' | '3'> + readonly required: false + readonly default: undefined + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Required and validator', () => { + expectTypeOf( + buildProp({ + required: true, + validator: (val: unknown): val is string => true, + } as const) + ).toEqualTypeOf<{ + readonly type: PropType + readonly required: true + readonly default: never + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Required and validator', () => { + expectTypeOf( + buildProp({ + values: keyOf({ a: 'a', b: 'b' }), + default: 'a', + } as const) + ).toEqualTypeOf<{ + readonly type: PropType<'a' | 'b'> + readonly required: false + readonly default: 'a' + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Type and default value', () => { + expectTypeOf( + buildProp({ + type: definePropType<{ key: 'a' | 'b' | 'c' } | undefined>(Object), + default: () => mutable({ key: 'a' } as const), + } as const) + ).toEqualTypeOf<{ + readonly type: PropType<{ key: 'a' | 'b' | 'c' } | undefined> + readonly required: false + readonly default: () => { key: 'a' } + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) + + it('Type and default value', () => { + expectTypeOf( + buildProp({ + type: [String, Number], + default: '', + } as const) + ).toEqualTypeOf<{ + readonly type: PropType + readonly required: false + readonly default: '' + readonly validator: ((val: unknown) => boolean) | undefined + }>() + }) +}) diff --git a/yarn.lock b/yarn.lock index d230007954..5b8d226e7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6219,6 +6219,11 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" +expect-type@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-0.12.0.tgz#133534b5e2561158c371e74af63fd8f18a9f3d42" + integrity sha512-IHwziEOjpjXqxQhtOAD5zMiQpGztaEKM4Q8wnwoRN9NIFlnyNHNjRxKWv+18UqRfsqi6vVnZIYFU16ePf+HaqA== + expect@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/expect/-/expect-26.6.2.tgz#c6b996bf26bf3fe18b67b2d0f51fc981ba934417"