diff --git a/components/_util/colors.ts b/components/_util/colors.ts index 5b0d35bd3..f6f3287db 100644 --- a/components/_util/colors.ts +++ b/components/_util/colors.ts @@ -1,4 +1,6 @@ -import { tuple } from './type'; +import { ElementOf, tuple } from './type'; + +export const PresetStatusColorTypes = tuple('success', 'processing', 'error', 'default', 'warning'); export const PresetColorTypes = tuple( 'pink', @@ -15,3 +17,6 @@ export const PresetColorTypes = tuple( 'gold', 'lime', ); + +export type PresetColorType = ElementOf; +export type PresetStatusColorType = ElementOf; diff --git a/components/_util/type.ts b/components/_util/type.ts index 8df5e1574..efe931251 100644 --- a/components/_util/type.ts +++ b/components/_util/type.ts @@ -1,4 +1,22 @@ +export type Omit = Pick>; // https://stackoverflow.com/questions/46176165/ways-to-get-string-literal-type-of-array-values-without-enum-overhead export const tuple = (...args: T) => args; -export const tupleNum = (...args) => args; +export const tupleNum = (...args: T) => args; + +/** + * https://stackoverflow.com/a/59187769 + * Extract the type of an element of an array/tuple without performing indexing + */ +export type ElementOf = T extends (infer E)[] ? E : T extends readonly (infer E)[] ? E : never; + +/** + * https://github.com/Microsoft/TypeScript/issues/29729 + */ +export type LiteralUnion = T | (U & {}); + +export type StringKeyOf = Extract; + +export type EventHandlers = { + [K in StringKeyOf]?: E[K] extends Function ? E[K] : (payload: E[K]) => void; +}; diff --git a/components/_util/wave.tsx b/components/_util/wave.tsx index 430072a34..72d2c6ebb 100644 --- a/components/_util/wave.tsx +++ b/components/_util/wave.tsx @@ -1,18 +1,18 @@ -import { nextTick, inject } from 'vue'; +import { nextTick, inject, defineComponent } from 'vue'; import TransitionEvents from './css-animation/Event'; import raf from './raf'; import { ConfigConsumerProps } from '../config-provider'; import { findDOMNode } from './props-util'; -let styleForPesudo; +let styleForPesudo: HTMLStyleElement | null; // Where el is the DOM element you'd like to test for visibility -function isHidden(element) { +function isHidden(element: HTMLElement) { if (process.env.NODE_ENV === 'test') { return false; } return !element || element.offsetParent === null; } -function isNotGrey(color) { +function isNotGrey(color: string) { // eslint-disable-next-line no-useless-escape const match = (color || '').match(/rgba?\((\d*), (\d*), (\d*)(, [\.\d]*)?\)/); if (match && match[1] && match[2] && match[3]) { @@ -20,7 +20,7 @@ function isNotGrey(color) { } return true; } -export default { +export default defineComponent({ name: 'Wave', props: ['insertExtraNode'], mounted() { @@ -47,7 +47,7 @@ export default { } }, methods: { - onClick(node, waveColor) { + onClick(node: HTMLElement, waveColor: string) { if (!node || isHidden(node) || node.className.indexOf('-leave') >= 0) { return; } @@ -87,7 +87,7 @@ export default { TransitionEvents.addStartEventListener(node, this.onTransitionStart); TransitionEvents.addEndEventListener(node, this.onTransitionEnd); }, - onTransitionStart(e) { + onTransitionStart(e: AnimationEvent) { if (this._.isUnmounted) return; const node = findDOMNode(this); @@ -99,7 +99,7 @@ export default { this.resetEffect(node); } }, - onTransitionEnd(e) { + onTransitionEnd(e: AnimationEvent) { if (!e || e.animationName !== 'fadeEffect') { return; } @@ -109,7 +109,7 @@ export default { const { insertExtraNode } = this.$props; return insertExtraNode ? 'ant-click-animating' : 'ant-click-animating-without-extra-node'; }, - bindAnimationEvent(node) { + bindAnimationEvent(node: HTMLElement) { if ( !node || !node.getAttribute || @@ -118,9 +118,9 @@ export default { ) { return; } - const onClick = e => { + const onClick = (e: MouseEvent) => { // Fix radio button click twice - if (e.target.tagName === 'INPUT' || isHidden(e.target)) { + if ((e.target as HTMLElement).tagName === 'INPUT' || isHidden(e.target as HTMLElement)) { return; } this.resetEffect(node); @@ -146,7 +146,7 @@ export default { }; }, - resetEffect(node) { + resetEffect(node: HTMLElement) { if (!node || node === this.extraNode || !(node instanceof Element)) { return; } @@ -171,4 +171,4 @@ export default { } return this.$slots.default && this.$slots.default()[0]; }, -}; +}); diff --git a/components/tag/CheckableTag.jsx b/components/tag/CheckableTag.jsx deleted file mode 100644 index f753cf1e8..000000000 --- a/components/tag/CheckableTag.jsx +++ /dev/null @@ -1,45 +0,0 @@ -import { inject } from 'vue'; -import PropTypes from '../_util/vue-types'; -import { ConfigConsumerProps } from '../config-provider'; - -export default { - name: 'ACheckableTag', - props: { - prefixCls: PropTypes.string, - checked: PropTypes.bool, - onChange: PropTypes.func, - 'onUpdate:checked': PropTypes.func, - }, - setup() { - return { - configProvider: inject('configProvider', ConfigConsumerProps), - }; - }, - computed: { - classes() { - const { checked, prefixCls: customizePrefixCls } = this; - const getPrefixCls = this.configProvider.getPrefixCls; - const prefixCls = getPrefixCls('tag', customizePrefixCls); - return { - [`${prefixCls}`]: true, - [`${prefixCls}-checkable`]: true, - [`${prefixCls}-checkable-checked`]: checked, - }; - }, - }, - methods: { - handleClick() { - const { checked } = this; - this.$emit('update:checked', !checked); - this.$emit('change', !checked); - }, - }, - render() { - const { classes, handleClick, $slots } = this; - return ( -
- {$slots.default && $slots.default()} -
- ); - }, -}; diff --git a/components/tag/CheckableTag.tsx b/components/tag/CheckableTag.tsx new file mode 100644 index 000000000..013c097fe --- /dev/null +++ b/components/tag/CheckableTag.tsx @@ -0,0 +1,44 @@ +import { inject, CSSProperties, SetupContext } from 'vue'; +import classNames from 'classnames'; +import { ConfigConsumerProps } from '../config-provider'; + +export interface CheckableTagProps { + prefixCls?: string; + class?: string; + style?: CSSProperties; + checked: boolean; + onChange?: (checked: boolean) => void; + onClick?: (e: Event) => void; +} + +const CheckableTag = (props: CheckableTagProps, { slots }: SetupContext) => { + const { getPrefixCls } = inject('configProvider', ConfigConsumerProps); + const handleClick = (e: Event) => { + const { checked, onChange, onClick } = props; + if (onChange) { + onChange(!checked); + } + if (onClick) { + onClick(e); + } + }; + + const { prefixCls: customizePrefixCls, class: className, checked } = props; + const prefixCls = getPrefixCls('tag', customizePrefixCls); + const cls = classNames( + prefixCls, + { + [`${prefixCls}-checkable`]: true, + [`${prefixCls}-checkable-checked`]: checked, + }, + className, + ); + + return ( + + {slots.default?.()} + + ); +}; + +export default CheckableTag; diff --git a/components/tag/Tag.jsx b/components/tag/Tag.jsx deleted file mode 100644 index 8d3079221..000000000 --- a/components/tag/Tag.jsx +++ /dev/null @@ -1,121 +0,0 @@ -import { inject } from 'vue'; -import CloseOutlined from '@ant-design/icons-vue/CloseOutlined'; -import PropTypes from '../_util/vue-types'; -import Wave from '../_util/wave'; -import { hasProp, getOptionProps } from '../_util/props-util'; -import BaseMixin from '../_util/BaseMixin'; -import { ConfigConsumerProps } from '../config-provider'; - -const PresetColorTypes = [ - 'pink', - 'red', - 'yellow', - 'orange', - 'cyan', - 'green', - 'blue', - 'purple', - 'geekblue', - 'magenta', - 'volcano', - 'gold', - 'lime', -]; -const PresetColorRegex = new RegExp(`^(${PresetColorTypes.join('|')})(-inverse)?$`); - -export default { - name: 'ATag', - mixins: [BaseMixin], - props: { - prefixCls: PropTypes.string, - color: PropTypes.string, - closable: PropTypes.bool.def(false), - visible: PropTypes.bool, - onClose: PropTypes.func, - 'onUpdate:visible': PropTypes.func, - }, - setup() { - return { - configProvider: inject('configProvider', ConfigConsumerProps), - }; - }, - data() { - let _visible = true; - const props = getOptionProps(this); - if ('visible' in props) { - _visible = this.visible; - } - return { - _visible, - }; - }, - watch: { - visible(val) { - this.setState({ - _visible: val, - }); - }, - }, - methods: { - setVisible(visible, e) { - this.$emit('update:visible', false); - this.$emit('close', e); - if (e.defaultPrevented) { - return; - } - if (!hasProp(this, 'visible')) { - this.setState({ _visible: visible }); - } - }, - - handleIconClick(e) { - e.stopPropagation(); - this.setVisible(false, e); - }, - - isPresetColor() { - const { color } = this.$props; - if (!color) { - return false; - } - return PresetColorRegex.test(color); - }, - getTagStyle() { - const { color } = this.$props; - const isPresetColor = this.isPresetColor(); - return { - backgroundColor: color && !isPresetColor ? color : undefined, - }; - }, - - getTagClassName(prefixCls) { - const { color } = this.$props; - const isPresetColor = this.isPresetColor(); - return { - [prefixCls]: true, - [`${prefixCls}-${color}`]: isPresetColor, - [`${prefixCls}-has-color`]: color && !isPresetColor, - }; - }, - - renderCloseIcon() { - const { closable } = this.$props; - return closable ? : null; - }, - }, - - render() { - const { prefixCls: customizePrefixCls } = this.$props; - const isNeedWave = 'onClick' in this.$attrs; - const getPrefixCls = this.configProvider.getPrefixCls; - const prefixCls = getPrefixCls('tag', customizePrefixCls); - const { _visible: visible } = this.$data; - const tag = ( - - {this.$slots.default && this.$slots.default()} - {this.renderCloseIcon()} - - ); - return isNeedWave ? {tag} : tag; - }, -}; diff --git a/components/tag/index.js b/components/tag/index.js deleted file mode 100644 index c91755a02..000000000 --- a/components/tag/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import Tag from './Tag'; -import CheckableTag from './CheckableTag'; - -Tag.CheckableTag = CheckableTag; - -/* istanbul ignore next */ -Tag.install = function(app) { - app.component(Tag.name, Tag); - app.component(Tag.CheckableTag.name, Tag.CheckableTag); -}; - -export default Tag; diff --git a/components/tag/index.tsx b/components/tag/index.tsx new file mode 100644 index 000000000..5b1d6ba09 --- /dev/null +++ b/components/tag/index.tsx @@ -0,0 +1,143 @@ +import { + inject, + ref, + HTMLAttributes, + VNodeChild, + Events, + defineComponent, + SetupContext, + App, + watchEffect, +} from 'vue'; +import classNames from 'classnames'; +import omit from 'omit.js'; +import CloseOutlined from '@ant-design/icons-vue/CloseOutlined'; +import Wave from '../_util/wave'; +import { + PresetColorTypes, + PresetStatusColorTypes, + PresetColorType, + PresetStatusColorType, +} from '../_util/colors'; +import { LiteralUnion, EventHandlers } from '../_util/type'; +import { ConfigConsumerProps } from '../config-provider'; +import CheckableTag from './CheckableTag'; + +const PresetColorRegex = new RegExp(`^(${PresetColorTypes.join('|')})(-inverse)?$`); +const PresetStatusColorRegex = new RegExp(`^(${PresetStatusColorTypes.join('|')})$`); + +export interface TagProps extends HTMLAttributes, Partial> { + prefixCls?: string; + color?: LiteralUnion; + closable?: boolean; + closeIcon?: VNodeChild | JSX.Element; + visible?: boolean; + onClose?: Function; + icon?: VNodeChild | JSX.Element; +} + +const Tag = defineComponent({ + setup(_: TagProps, { slots, attrs }: SetupContext) { + const { getPrefixCls } = inject('configProvider', ConfigConsumerProps); + + const visible = ref(true); + + return () => { + const { + prefixCls: customizePrefixCls, + style, + icon, + color, + onClose, + closeIcon, + closable = false, + ...props + } = attrs as TagProps; + + watchEffect(() => { + if ('visible' in props) { + visible.value = props.visible!; + } + }); + + const isPresetColor = (): boolean => { + if (!color) { + return false; + } + return PresetColorRegex.test(color) || PresetStatusColorRegex.test(color); + }; + + const presetColor = isPresetColor(); + const prefixCls = getPrefixCls('tag', customizePrefixCls); + + const handleCloseClick = (e: MouseEvent) => { + e.stopPropagation(); + if (onClose) { + onClose(e); + } + + if (e.defaultPrevented) { + return; + } + if (!('visible' in props)) { + visible.value = false; + } + }; + + const renderCloseIcon = () => { + if (closable) { + return closeIcon ? ( +
+ {closeIcon} +
+ ) : ( + + ); + } + return null; + }; + + const tagStyle = { + backgroundColor: color && !isPresetColor() ? color : undefined, + }; + + const tagClassName = classNames(prefixCls, { + [`${prefixCls}-${color}`]: presetColor, + [`${prefixCls}-has-color`]: color && !presetColor, + [`${prefixCls}-hidden`]: !visible.value, + }); + + const tagProps = omit(props, ['visible']); + const iconNode = icon || null; + const children = slots.default?.(); + const kids = iconNode ? ( + <> + {iconNode} + {children} + + ) : ( + children + ); + + const isNeedWave = 'onClick' in props; + + const tagNode = ( + + {kids} + {renderCloseIcon()} + + ); + + return isNeedWave ? {tagNode} : tagNode; + }; + }, +}); + +Tag.CheckableTag = CheckableTag; + +Tag.install = (app: App) => { + app.component(Tag.name, Tag); + app.component(CheckableTag.name, CheckableTag); +}; + +export default Tag;