mirror of
https://github.com/ant-design/ant-design.git
synced 2025-06-11 19:42:54 +08:00
Merge cfc48f15be
into 8abb52fc92
This commit is contained in:
commit
28723ccd92
@ -205,6 +205,32 @@ describe('Button', () => {
|
|||||||
expect(onClick).not.toHaveBeenCalledWith();
|
expect(onClick).not.toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should prevent multiple clicks after triggering loading state', () => {
|
||||||
|
const onClick = jest.fn();
|
||||||
|
const TestComponent = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
onClick();
|
||||||
|
setLoading(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button loading={loading} onClick={handleClick}>
|
||||||
|
Click Me
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(<TestComponent />);
|
||||||
|
|
||||||
|
fireEvent.click(container.firstChild!);
|
||||||
|
fireEvent.click(container.firstChild!);
|
||||||
|
fireEvent.click(container.firstChild!);
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('should support link button', () => {
|
it('should support link button', () => {
|
||||||
const wrapper = render(
|
const wrapper = render(
|
||||||
<Button target="_blank" href="https://ant.design">
|
<Button target="_blank" href="https://ant.design">
|
||||||
|
@ -23,6 +23,7 @@ import DefaultLoadingIcon from './DefaultLoadingIcon';
|
|||||||
import IconWrapper from './IconWrapper';
|
import IconWrapper from './IconWrapper';
|
||||||
import useStyle from './style';
|
import useStyle from './style';
|
||||||
import Compact from './style/compact';
|
import Compact from './style/compact';
|
||||||
|
import useLoadingState from './useLoadingState';
|
||||||
|
|
||||||
export type LegacyButtonType = ButtonType | 'danger';
|
export type LegacyButtonType = ButtonType | 'danger';
|
||||||
|
|
||||||
@ -61,27 +62,6 @@ export interface ButtonProps extends BaseButtonProps, MergedHTMLAttributes {
|
|||||||
autoInsertSpace?: boolean;
|
autoInsertSpace?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadingConfigType = {
|
|
||||||
loading: boolean;
|
|
||||||
delay: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getLoadingConfig(loading: BaseButtonProps['loading']): LoadingConfigType {
|
|
||||||
if (typeof loading === 'object' && loading) {
|
|
||||||
let delay = loading?.delay;
|
|
||||||
delay = !Number.isNaN(delay) && typeof delay === 'number' ? delay : 0;
|
|
||||||
return {
|
|
||||||
loading: delay <= 0,
|
|
||||||
delay,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
loading: !!loading,
|
|
||||||
delay: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type ColorVariantPairType = [color?: ButtonColorType, variant?: ButtonVariantType];
|
type ColorVariantPairType = [color?: ButtonColorType, variant?: ButtonVariantType];
|
||||||
|
|
||||||
const ButtonTypeMap: Partial<Record<ButtonType, ColorVariantPairType>> = {
|
const ButtonTypeMap: Partial<Record<ButtonType, ColorVariantPairType>> = {
|
||||||
@ -177,9 +157,7 @@ const InternalCompoundedButton = React.forwardRef<
|
|||||||
|
|
||||||
const groupSize = useContext(GroupSizeContext);
|
const groupSize = useContext(GroupSizeContext);
|
||||||
|
|
||||||
const loadingOrDelay = useMemo<LoadingConfigType>(() => getLoadingConfig(loading), [loading]);
|
const { getLoading } = useLoadingState(loading);
|
||||||
|
|
||||||
const [innerLoading, setLoading] = useState<boolean>(loadingOrDelay.loading);
|
|
||||||
|
|
||||||
const [hasTwoCNChar, setHasTwoCNChar] = useState<boolean>(false);
|
const [hasTwoCNChar, setHasTwoCNChar] = useState<boolean>(false);
|
||||||
|
|
||||||
@ -202,28 +180,6 @@ const InternalCompoundedButton = React.forwardRef<
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ========================= Effect =========================
|
// ========================= Effect =========================
|
||||||
// Loading
|
|
||||||
useEffect(() => {
|
|
||||||
let delayTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
if (loadingOrDelay.delay > 0) {
|
|
||||||
delayTimer = setTimeout(() => {
|
|
||||||
delayTimer = null;
|
|
||||||
setLoading(true);
|
|
||||||
}, loadingOrDelay.delay);
|
|
||||||
} else {
|
|
||||||
setLoading(loadingOrDelay.loading);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupTimer() {
|
|
||||||
if (delayTimer) {
|
|
||||||
clearTimeout(delayTimer);
|
|
||||||
delayTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cleanupTimer;
|
|
||||||
}, [loadingOrDelay]);
|
|
||||||
|
|
||||||
// Two chinese characters check
|
// Two chinese characters check
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// FIXME: for HOC usage like <FormatMessage />
|
// FIXME: for HOC usage like <FormatMessage />
|
||||||
@ -251,7 +207,7 @@ const InternalCompoundedButton = React.forwardRef<
|
|||||||
const handleClick = React.useCallback(
|
const handleClick = React.useCallback(
|
||||||
(e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement, MouseEvent>) => {
|
(e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement, MouseEvent>) => {
|
||||||
// FIXME: https://github.com/ant-design/ant-design/issues/30207
|
// FIXME: https://github.com/ant-design/ant-design/issues/30207
|
||||||
if (innerLoading || mergedDisabled) {
|
if (getLoading() || mergedDisabled) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -262,7 +218,7 @@ const InternalCompoundedButton = React.forwardRef<
|
|||||||
: (e as React.MouseEvent<HTMLButtonElement, MouseEvent>),
|
: (e as React.MouseEvent<HTMLButtonElement, MouseEvent>),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[props.onClick, innerLoading, mergedDisabled],
|
[props.onClick, mergedDisabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ========================== Warn ==========================
|
// ========================== Warn ==========================
|
||||||
@ -291,7 +247,7 @@ const InternalCompoundedButton = React.forwardRef<
|
|||||||
|
|
||||||
const sizeCls = sizeFullName ? (sizeClassNameMap[sizeFullName] ?? '') : '';
|
const sizeCls = sizeFullName ? (sizeClassNameMap[sizeFullName] ?? '') : '';
|
||||||
|
|
||||||
const iconType = innerLoading ? 'loading' : icon;
|
const iconType = getLoading() ? 'loading' : icon;
|
||||||
|
|
||||||
const linkButtonRestProps = omit(rest as ButtonProps & { navigate: any }, ['navigate']);
|
const linkButtonRestProps = omit(rest as ButtonProps & { navigate: any }, ['navigate']);
|
||||||
|
|
||||||
@ -311,8 +267,8 @@ const InternalCompoundedButton = React.forwardRef<
|
|||||||
[`${prefixCls}-${sizeCls}`]: sizeCls,
|
[`${prefixCls}-${sizeCls}`]: sizeCls,
|
||||||
[`${prefixCls}-icon-only`]: !children && children !== 0 && !!iconType,
|
[`${prefixCls}-icon-only`]: !children && children !== 0 && !!iconType,
|
||||||
[`${prefixCls}-background-ghost`]: ghost && !isUnBorderedButtonVariant(mergedVariant),
|
[`${prefixCls}-background-ghost`]: ghost && !isUnBorderedButtonVariant(mergedVariant),
|
||||||
[`${prefixCls}-loading`]: innerLoading,
|
[`${prefixCls}-loading`]: getLoading(),
|
||||||
[`${prefixCls}-two-chinese-chars`]: hasTwoCNChar && mergedInsertSpace && !innerLoading,
|
[`${prefixCls}-two-chinese-chars`]: hasTwoCNChar && mergedInsertSpace && !getLoading(),
|
||||||
[`${prefixCls}-block`]: block,
|
[`${prefixCls}-block`]: block,
|
||||||
[`${prefixCls}-rtl`]: direction === 'rtl',
|
[`${prefixCls}-rtl`]: direction === 'rtl',
|
||||||
[`${prefixCls}-icon-end`]: iconPosition === 'end',
|
[`${prefixCls}-icon-end`]: iconPosition === 'end',
|
||||||
@ -332,7 +288,7 @@ const InternalCompoundedButton = React.forwardRef<
|
|||||||
};
|
};
|
||||||
|
|
||||||
const iconNode =
|
const iconNode =
|
||||||
icon && !innerLoading ? (
|
icon && !getLoading() ? (
|
||||||
<IconWrapper prefixCls={prefixCls} className={iconClasses} style={iconStyle}>
|
<IconWrapper prefixCls={prefixCls} className={iconClasses} style={iconStyle}>
|
||||||
{icon}
|
{icon}
|
||||||
</IconWrapper>
|
</IconWrapper>
|
||||||
@ -344,7 +300,7 @@ const InternalCompoundedButton = React.forwardRef<
|
|||||||
<DefaultLoadingIcon
|
<DefaultLoadingIcon
|
||||||
existIcon={!!icon}
|
existIcon={!!icon}
|
||||||
prefixCls={prefixCls}
|
prefixCls={prefixCls}
|
||||||
loading={innerLoading}
|
loading={getLoading()}
|
||||||
mount={isMountRef.current}
|
mount={isMountRef.current}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -389,7 +345,7 @@ const InternalCompoundedButton = React.forwardRef<
|
|||||||
|
|
||||||
if (!isUnBorderedButtonVariant(mergedVariant)) {
|
if (!isUnBorderedButtonVariant(mergedVariant)) {
|
||||||
buttonNode = (
|
buttonNode = (
|
||||||
<Wave component="Button" disabled={innerLoading}>
|
<Wave component="Button" disabled={getLoading()}>
|
||||||
{buttonNode}
|
{buttonNode}
|
||||||
</Wave>
|
</Wave>
|
||||||
);
|
);
|
||||||
|
59
components/button/useLoadingState.ts
Normal file
59
components/button/useLoadingState.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import useForceUpdate from '../_util/hooks/useForceUpdate';
|
||||||
|
|
||||||
|
type LoadingConfigType = {
|
||||||
|
loading: boolean;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getLoadingConfig(loading: boolean | { delay?: number }): LoadingConfigType {
|
||||||
|
if (typeof loading === 'object' && loading) {
|
||||||
|
let delay = loading?.delay;
|
||||||
|
delay = !Number.isNaN(delay) && typeof delay === 'number' ? delay : 0;
|
||||||
|
return {
|
||||||
|
loading: delay <= 0,
|
||||||
|
delay,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading: !!loading,
|
||||||
|
delay: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useLoadingState(loadingProp: boolean | { delay?: number }) {
|
||||||
|
const forceUpdate = useForceUpdate();
|
||||||
|
const loadingOrDelay = useMemo(() => getLoadingConfig(loadingProp), [loadingProp, loadingProp?.delay]);
|
||||||
|
const innerLoading = useRef<boolean>(loadingOrDelay.loading);
|
||||||
|
const getLoading = useCallback(() => innerLoading.current, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let delayTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
if (loadingOrDelay.delay > 0) {
|
||||||
|
delayTimer = setTimeout(() => {
|
||||||
|
delayTimer = null;
|
||||||
|
innerLoading.current = true;
|
||||||
|
forceUpdate();
|
||||||
|
}, loadingOrDelay.delay);
|
||||||
|
} else {
|
||||||
|
innerLoading.current = loadingOrDelay.loading;
|
||||||
|
forceUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupTimer = () => {
|
||||||
|
if (delayTimer) {
|
||||||
|
clearTimeout(delayTimer);
|
||||||
|
delayTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return cleanupTimer;
|
||||||
|
}, [loadingOrDelay, forceUpdate]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading: innerLoading.current,
|
||||||
|
getLoading,
|
||||||
|
};
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user