diff --git a/components/_util/hooks/disabled.ts b/components/_util/hooks/disabled.ts new file mode 100644 index 0000000..b9145a4 --- /dev/null +++ b/components/_util/hooks/disabled.ts @@ -0,0 +1,8 @@ +import type { ComputedRef } from 'vue' +import { computed } from 'vue' +import { useProviderConfigState } from '../../config-provider/context' + +export const useDisabled = (props: Record) => { + const { componentDisabled } = useProviderConfigState() + return computed(() => props.disabled || componentDisabled.value) as ComputedRef +} diff --git a/components/_util/hooks/size.ts b/components/_util/hooks/size.ts new file mode 100644 index 0000000..19c05d0 --- /dev/null +++ b/components/_util/hooks/size.ts @@ -0,0 +1,9 @@ +import type { ComputedRef } from 'vue' +import { computed } from 'vue' +import type { SizeType } from '../../config-provider/context' +import { useProviderConfigState } from '../../config-provider/context' + +export const useSize = (props: Record) => { + const { componentSize } = useProviderConfigState() + return computed(() => props.size || componentSize.value) as ComputedRef +} diff --git a/components/_util/warning.ts b/components/_util/warning.ts new file mode 100644 index 0000000..ed1794c --- /dev/null +++ b/components/_util/warning.ts @@ -0,0 +1,21 @@ +import { warning as rcWarning, resetWarned } from '@v-c/utils' + +export { resetWarned } +export function noop() {} + +type Warning = (valid: boolean, component: string, message?: string) => void + +// eslint-disable-next-line import/no-mutable-exports +let warning: Warning = noop +if (process.env.NODE_ENV !== 'production') { + warning = (valid, component, message) => { + rcWarning(valid, `[antd: ${component}] ${message}`) + + // StrictMode will inject console which will not throw warning in React 17. + if (process.env.NODE_ENV === 'test') { + resetWarned() + } + } +} + +export default warning diff --git a/components/button/button.tsx b/components/button/button.tsx index e84d4b0..1b934da 100644 --- a/components/button/button.tsx +++ b/components/button/button.tsx @@ -1,8 +1,33 @@ -import { computed, defineComponent } from 'vue' +import { computed, defineComponent, onMounted, shallowRef } from 'vue' +import { tryOnBeforeUnmount } from '@vueuse/core' +import { classNames, filterEmpty, getSlotsProps, runEvent, useState } from '@v-c/utils' import { useProviderConfigState } from '../config-provider/context' +import warning from '../_util/warning' import Wave from '../_util/wave' +import { useSize } from '../_util/hooks/size' +import { useDisabled } from '../_util/hooks/disabled' +import { useCompactItemContext } from '../space/compact' import useStyle from './style' +import type { ButtonProps, LoadingConfigType } from './interface' import { buttonProps } from './interface' +import { isTwoCNChar, isUnBorderedButtonType } from './button-helper' +type Loading = number | boolean + +function getLoadingConfig(loading: ButtonProps['loading']): LoadingConfigType { + if (typeof loading === 'object' && loading) { + const delay = loading?.delay + const isDelay = !Number.isNaN(delay) && typeof delay === 'number' + return { + loading: false, + delay: isDelay ? delay : 0 + } + } + + return { + loading: !!loading, + delay: 0 + } +} const Button = defineComponent({ name: 'AButton', @@ -12,29 +37,150 @@ const Button = defineComponent({ ...buttonProps }, setup(props, { slots, attrs }) { - const { getPrefixCls } = useProviderConfigState() + const { getPrefixCls, autoInsertSpaceInButton, direction } = useProviderConfigState() const prefixCls = computed(() => getPrefixCls('btn', props.prefixCls)) const [wrapSSR, hashId] = useStyle(prefixCls) + const size = useSize(props) + const { compactSize, compactItemClassnames } = useCompactItemContext(prefixCls, direction) + const sizeCls = computed(() => { + const sizeClassNameMap = { large: 'lg', small: 'sm', middle: undefined } + const sizeFullname = compactSize?.value || size.value + return sizeClassNameMap[sizeFullname!] + }) + const disabled = useDisabled(props) + const buttonRef = shallowRef(null) - const cls = computed(() => { - return { - [prefixCls.value]: true, - [`${prefixCls.value}-${props.type}`]: !!props.type, - [hashId.value]: true - } + const loadingOrDelay = computed(() => { + return getLoadingConfig(props.loading) }) + const [innerLoading, setLoading] = useState(loadingOrDelay.value.loading) + const [hasTwoCNChar, setHasTwoCNChar] = useState(false) + + let delayTimer: number | null = null + + onMounted(() => { + if (loadingOrDelay.value.delay > 0) { + delayTimer = window.setTimeout(() => { + delayTimer = null + setLoading(true) + }, loadingOrDelay.value.delay) + } else { + setLoading(loadingOrDelay.value.loading) + } + // fixTwoCNChar() + }) + + function cleanupTimer() { + if (delayTimer) { + window.clearTimeout(delayTimer) + delayTimer = null + } + } + tryOnBeforeUnmount(() => { + cleanupTimer() + }) + + const handleClick = (e: MouseEvent) => { + // FIXME: https://github.com/ant-design/ant-design/issues/30207 + if (innerLoading.value || disabled.value) { + e.preventDefault() + return + } + runEvent(props, 'onClick', e) + } + + const showError = () => { + const { ghost, type } = props + + const icon = getSlotsProps(slots, props, 'icon') + + warning(!(typeof icon === 'string' && icon.length > 2), 'Button', `\`icon\` is using ReactNode instead of string naming in v4. Please check \`${icon}\` at https://ant.design/components/icon`) + + warning(!(ghost && isUnBorderedButtonType(type)), 'Button', "`link` or `text` button can't be a `ghost` button.") + } + return () => { - return wrapSSR( - - - + const { shape, rootClassName, ghost, type, block, danger } = props + const icon = getSlotsProps(slots, props, 'icon') + const children = filterEmpty(slots.default?.()) + const isNeedInserted = () => { + return children.length === 1 && !slots.icon && isUnBorderedButtonType(props.type) + } + + const fixTwoCNChar = () => { + // FIXME: for HOC usage like + if (!buttonRef.value || autoInsertSpaceInButton.value === false) { + return + } + const buttonText = buttonRef.value.textContent + if (isNeedInserted() && isTwoCNChar(buttonText as string)) { + if (!hasTwoCNChar) { + setHasTwoCNChar(true) + } + } else if (hasTwoCNChar) { + setHasTwoCNChar(false) + } + } + fixTwoCNChar() + showError() + const iconType = innerLoading.value ? 'loading' : icon + + const autoInsertSpace = autoInsertSpaceInButton.value !== false + + const hrefAndDisabled = attrs.href !== undefined && disabled.value + + const classes = classNames( + prefixCls.value, + hashId.value, + { + [`${prefixCls.value}-${shape}`]: shape !== 'default' && shape, + [`${prefixCls.value}-${type}`]: type, + [`${prefixCls.value}-${sizeCls.value}`]: sizeCls.value, + [`${prefixCls.value}-icon-only`]: !children && children !== 0 && !!iconType, + [`${prefixCls.value}-background-ghost`]: ghost && !isUnBorderedButtonType(type), + [`${prefixCls.value}-loading`]: innerLoading.value, + [`${prefixCls.value}-two-chinese-chars`]: hasTwoCNChar.value && autoInsertSpace && !innerLoading.value, + [`${prefixCls.value}-block`]: block, + [`${prefixCls.value}-dangerous`]: !!danger, + [`${prefixCls.value}-rtl`]: direction.value === 'rtl', + [`${prefixCls.value}-disabled`]: hrefAndDisabled + }, + attrs.class, + compactItemClassnames.value, + rootClassName ) + const iconNode = icon && !innerLoading.value ? icon?.() : <>L + + if (attrs.href !== undefined) { + return wrapSSR( + + {iconNode} + {children} + + ) + } + let buttonNode = ( + + ) + + if (!isUnBorderedButtonType(type)) { + buttonNode = {buttonNode} + } + + return wrapSSR(buttonNode) } } }) diff --git a/components/button/interface.ts b/components/button/interface.ts index 7cfda45..1a4de87 100644 --- a/components/button/interface.ts +++ b/components/button/interface.ts @@ -2,6 +2,10 @@ import { booleanType, someType, stringType, vNodeType } from '@v-c/utils' import type { ExtractPropTypes } from 'vue' import type { SizeType } from '../config-provider/context' import type { ButtonHTMLType, ButtonShape, ButtonType } from './button-helper' +export interface LoadingConfigType { + loading: boolean + delay: number +} export const buttonProps = { type: stringType('default'), @@ -9,7 +13,7 @@ export const buttonProps = { shape: stringType(), size: someType([String], 'default'), disabled: booleanType(), - loading: someType(), + loading: someType(), prefixCls: stringType(), rootClassName: stringType(), ghost: booleanType(), diff --git a/components/config-provider/context.ts b/components/config-provider/context.ts index cfcea07..87115ea 100644 --- a/components/config-provider/context.ts +++ b/components/config-provider/context.ts @@ -90,13 +90,17 @@ const configState = (props: ConfigProviderProps) => { const csp = computed(() => props?.csp) const componentSize = computed(() => props?.componentSize) const componentDisabled = computed(() => props?.componentDisabled) + const autoInsertSpaceInButton = computed(() => props?.autoInsertSpaceInButton) + const direction = computed(() => props?.direction) return { getPrefixCls, iconPrefixCls, shouldWrapSSR, csp, componentSize, - componentDisabled + componentDisabled, + autoInsertSpaceInButton, + direction } } const [useProviderConfigProvide, useProviderConfigInject] = createInjectionState(configState) @@ -107,7 +111,11 @@ export const useProviderConfigState = (): ReturnType => { useProviderConfigInject() ?? ({ getPrefixCls: defaultGetPrefixCls, - iconPrefixCls: computed(() => defaultIconPrefixCls) + iconPrefixCls: computed(() => defaultIconPrefixCls), + componentSize: computed(() => undefined), + componentDisabled: computed(() => false), + direction: computed(() => undefined), + autoInsertSpaceInButton: computed(() => undefined) } as any) ) } diff --git a/components/space/compact.ts b/components/space/compact.ts new file mode 100644 index 0000000..28353d5 --- /dev/null +++ b/components/space/compact.ts @@ -0,0 +1,41 @@ +import { classNames, createInjectionState } from '@v-c/utils' +import type { Ref } from 'vue' +import { computed } from 'vue' +import type { DirectionType } from '../config-provider' + +const spaceCompactItem = () => { + return { + compactDirection: computed(() => null), + isFirstItem: computed(() => null), + isLastItem: computed(() => null), + compactSize: computed(() => null) + } +} + +export const [useSpaceCompactProvider, useSpaceCompactInject] = createInjectionState(spaceCompactItem) + +export const useSpaceCompactItemState = (): ReturnType | undefined => useSpaceCompactInject() + +export const useCompactItemContext = (prefixCls: Ref, direction: Ref) => { + const compactItemContext = useSpaceCompactItemState() + + const compactItemClassnames = computed(() => { + if (!compactItemContext) return '' + + const { compactDirection, isFirstItem, isLastItem } = compactItemContext + const separator = compactDirection.value === 'vertical' ? '-vertical-' : '-' + + return classNames({ + [`${prefixCls.value}-compact${separator}item`]: true, + [`${prefixCls.value}-compact${separator}first-item`]: isFirstItem.value, + [`${prefixCls.value}-compact${separator}last-item`]: isLastItem.value, + [`${prefixCls.value}-compact${separator}item-rtl`]: direction.value === 'rtl' + }) + }) + + return { + compactSize: compactItemContext?.compactSize, + compactDirection: compactItemContext?.compactDirection, + compactItemClassnames + } +} diff --git a/site/demos/button/basic.vue b/site/demos/button/basic.vue index 67e9f3e..7e4eeec 100644 --- a/site/demos/button/basic.vue +++ b/site/demos/button/basic.vue @@ -10,9 +10,15 @@ title: 基础按钮