refactor[Wave]: CC => FC (#39705)

* fix

* refactor[Wave]: CC => FC

* fix lint

* fix

* fix

* fix

* add test case

* add test case

* fix test

* fix test

* test case

* add test case

* fix

* fix

* fix

* fix

* raname

* fix

* test case

* test case

* test case

* fix test

* test case

* refactor: Use React way

* test: coverage

* chore: clean up

* rerun fail ci

* fix: React 17 error

* test: fix test case

* test: fix test case

* fix borderRadius

* test: fix test case

* chore: clean up

* chore: clean up

Co-authored-by: 二货机器人 <smith3816@gmail.com>
This commit is contained in:
lijianan 2022-12-28 23:20:22 +08:00 committed by yoyo837
parent 365288a9a4
commit 4f16966e28
15 changed files with 414 additions and 671 deletions

View File

@ -1,10 +1,8 @@
/* eslint-disable class-methods-use-this */
import KeyCode from 'rc-util/lib/KeyCode';
import raf from 'rc-util/lib/raf';
import React from 'react';
import { waitFakeTimer, render, fireEvent } from '../../../tests/utils';
import getDataOrAriaProps from '../getDataOrAriaProps';
import delayRaf from '../raf';
import { isStyleSupport } from '../styleChecker';
import throttleByAnimationFrame from '../throttleByAnimationFrame';
import TransButton from '../transButton';
@ -99,38 +97,6 @@ describe('Test utils function', () => {
});
});
it('delayRaf', (done) => {
jest.useRealTimers();
let bamboo = false;
delayRaf(() => {
bamboo = true;
}, 3);
// Do nothing, but insert in the frame
// https://github.com/ant-design/ant-design/issues/16290
delayRaf(() => {}, 3);
// Variable bamboo should be false in frame 2 but true in frame 4
raf(() => {
expect(bamboo).toBe(false);
// Frame 2
raf(() => {
expect(bamboo).toBe(false);
// Frame 3
raf(() => {
// Frame 4
raf(() => {
expect(bamboo).toBe(true);
done();
});
});
});
});
});
describe('TransButton', () => {
it('can be focus/blur', () => {
const ref = React.createRef<HTMLDivElement>();

View File

@ -1,9 +1,14 @@
import React from 'react';
import mountTest from '../../../tests/shared/mountTest';
import { render, waitFakeTimer, fireEvent, act } from '../../../tests/utils';
import ConfigProvider from '../../config-provider';
import { render, fireEvent, getByText, waitFakeTimer } from '../../../tests/utils';
import Wave from '../wave';
import type { InternalWave } from '../wave';
(global as any).isVisible = true;
jest.mock('rc-util/lib/Dom/isVisible', () => {
const mockFn = () => (global as any).isVisible;
return mockFn;
});
describe('Wave component', () => {
mountTest(Wave);
@ -17,6 +22,7 @@ describe('Wave component', () => {
});
beforeEach(() => {
(global as any).isVisible = true;
document.body.innerHTML = '';
});
@ -28,46 +34,52 @@ describe('Wave component', () => {
}
});
function filterStyles(styles: any) {
return Array.from<HTMLStyleElement>(styles).filter(
(style: HTMLStyleElement) => !style.hasAttribute('data-css-hash'),
);
function getWaveStyle() {
const styleObj: Record<string, string> = {};
const { style } = document.querySelector<HTMLElement>('.ant-wave')!;
style.cssText.split(';').forEach((kv) => {
if (kv.trim()) {
const cells = kv.split(':');
styleObj[cells[0].trim()] = cells[1].trim();
}
});
return styleObj;
}
it('isHidden works', () => {
const TEST_NODE_ENV = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
it('work', async () => {
const { container, unmount } = render(
<Wave>
<button type="button">button</button>
</Wave>,
);
expect(container.querySelector('button')?.className).toBe('');
container.querySelector('button')?.click();
fireEvent.click(container.querySelector('button')!);
expect(document.querySelector('.ant-wave')).toBeTruthy();
// Match deadline
await waitFakeTimer();
expect(document.querySelector('.ant-wave')).toBeFalsy();
expect(
container.querySelector('button')?.hasAttribute('ant-click-animating-without-extra-node'),
).toBeFalsy();
unmount();
process.env.NODE_ENV = TEST_NODE_ENV;
});
it('isHidden is mocked', () => {
it('invisible in screen', () => {
(global as any).isVisible = false;
const { container, unmount } = render(
<Wave>
<button type="button">button</button>
</Wave>,
);
expect(container.querySelector('button')?.className).toBe('');
container.querySelector('button')?.click();
expect(
container.querySelector('button')?.getAttribute('ant-click-animating-without-extra-node'),
).toBe('false');
fireEvent.click(container.querySelector('button')!);
expect(document.querySelector('.ant-wave')).toBeFalsy();
unmount();
});
it('wave color is grey', async () => {
it('wave color is grey', () => {
const { container, unmount } = render(
<Wave>
<button
@ -78,17 +90,18 @@ describe('Wave component', () => {
</button>
</Wave>,
);
container.querySelector('button')?.click();
await waitFakeTimer();
let styles: HTMLCollectionOf<HTMLStyleElement> | HTMLStyleElement[] = (
container.querySelector('button')?.getRootNode() as HTMLButtonElement
).getElementsByTagName('style');
styles = filterStyles(styles);
expect(styles.length).toBe(0);
fireEvent.click(container.querySelector('button')!);
const style = getWaveStyle();
expect(style['--wave-scale']).toBeTruthy();
expect(style['--wave-color']).toBeFalsy();
unmount();
});
it('wave color is not grey', async () => {
it('wave color is not grey', () => {
const { container, unmount } = render(
<Wave>
<button type="button" style={{ borderColor: 'red' }}>
@ -96,69 +109,61 @@ describe('Wave component', () => {
</button>
</Wave>,
);
container.querySelector('button')?.click();
await waitFakeTimer();
let styles: HTMLCollectionOf<HTMLStyleElement> | HTMLStyleElement[] = (
container.querySelector('button')?.getRootNode() as HTMLButtonElement
).getElementsByTagName('style');
styles = filterStyles(styles);
expect(styles.length).toBe(1);
expect(styles[0].innerHTML).toContain('--antd-wave-shadow-color: red;');
fireEvent.click(container.querySelector('button')!);
const style = getWaveStyle();
expect(style['--wave-color']).toEqual('red');
unmount();
});
it('read wave color from border-top-color', async () => {
it('read wave color from border-top-color', () => {
const { container, unmount } = render(
<Wave>
<div style={{ borderTopColor: 'blue' }}>button</div>
</Wave>,
);
container.querySelector('div')?.click();
await waitFakeTimer();
let styles: HTMLCollectionOf<HTMLStyleElement> | HTMLStyleElement[] = (
container.querySelector('div')?.getRootNode() as HTMLDivElement
).getElementsByTagName('style');
styles = filterStyles(styles);
expect(styles.length).toBe(1);
expect(styles[0].innerHTML).toContain('--antd-wave-shadow-color: blue;');
fireEvent.click(getByText(container, 'button')!);
const style = getWaveStyle();
expect(style['--wave-color']).toEqual('blue');
unmount();
});
it('read wave color from background color', async () => {
it('read wave color from background color', () => {
const { container, unmount } = render(
<Wave>
<div style={{ backgroundColor: 'green' }}>button</div>
</Wave>,
);
container.querySelector('div')?.click();
await waitFakeTimer();
let styles: HTMLCollectionOf<HTMLStyleElement> | HTMLStyleElement[] = (
container.querySelector('div')?.getRootNode() as HTMLDivElement
).getElementsByTagName('style');
styles = filterStyles(styles);
expect(styles.length).toBe(1);
expect(styles[0].innerHTML).toContain('--antd-wave-shadow-color: green;');
fireEvent.click(getByText(container, 'button')!);
const style = getWaveStyle();
expect(style['--wave-color']).toEqual('green');
unmount();
});
it('read wave color from border firstly', async () => {
it('read wave color from border firstly', () => {
const { container, unmount } = render(
<Wave>
<div style={{ borderColor: 'yellow', backgroundColor: 'green' }}>button</div>
</Wave>,
);
container.querySelector('div')?.click();
await waitFakeTimer();
let styles: HTMLCollectionOf<HTMLStyleElement> | HTMLStyleElement[] = (
container.querySelector('div')?.getRootNode() as HTMLDivElement
).getElementsByTagName('style');
styles = filterStyles(styles);
expect(styles.length).toBe(1);
expect(styles[0].innerHTML).toContain('--antd-wave-shadow-color: yellow;');
fireEvent.click(getByText(container, 'button')!);
const style = getWaveStyle();
expect(style['--wave-color']).toEqual('yellow');
unmount();
});
it('hidden element with -leave className', async () => {
it('hidden element with -leave className', () => {
const { container, unmount } = render(
<Wave>
<button type="button" className="xx-leave">
@ -166,66 +171,50 @@ describe('Wave component', () => {
</button>
</Wave>,
);
container.querySelector('button')?.click();
await waitFakeTimer();
let styles: HTMLCollectionOf<HTMLStyleElement> | HTMLStyleElement[] = (
container.querySelector('button')?.getRootNode() as HTMLButtonElement
).getElementsByTagName('style');
styles = filterStyles(styles);
expect(styles.length).toBe(0);
fireEvent.click(container.querySelector('button')!);
expect(document.querySelector('.ant-wave')).toBeFalsy();
unmount();
});
it('ConfigProvider csp', async () => {
const { container, unmount } = render(
<ConfigProvider csp={{ nonce: 'YourNonceCode' }}>
<Wave>
<button type="button">button</button>
</Wave>
</ConfigProvider>,
);
container.querySelector('button')?.click();
await waitFakeTimer();
let styles: HTMLCollectionOf<HTMLStyleElement> | HTMLStyleElement[] = (
container.querySelector('button')?.getRootNode() as HTMLButtonElement
).getElementsByTagName('style');
styles = filterStyles(styles);
expect(styles[0].getAttribute('nonce')).toBe('YourNonceCode');
unmount();
});
it('bindAnimationEvent should return when node is null', () => {
const ref = React.createRef<InternalWave>();
render(
<Wave ref={ref}>
it('not show when disabled', () => {
const { container } = render(
<Wave>
<button type="button" disabled>
button
</button>
</Wave>,
);
expect(ref.current?.bindAnimationEvent()).toBe(undefined);
fireEvent.click(container.querySelector('button')!);
expect(document.querySelector('.ant-wave')).toBeFalsy();
});
it('bindAnimationEvent.onClick should return when children is hidden', () => {
const ref = React.createRef<InternalWave>();
render(
<Wave ref={ref}>
it('not show when hidden', () => {
(global as any).isVisible = false;
const { container } = render(
<Wave>
<button type="button" style={{ display: 'none' }}>
button
</button>
</Wave>,
);
expect(ref.current?.bindAnimationEvent()).toBe(undefined);
fireEvent.click(container.querySelector('button')!);
expect(document.querySelector('.ant-wave')).toBeFalsy();
});
it('bindAnimationEvent.onClick should return when children is input', () => {
const ref = React.createRef<InternalWave>();
render(
<Wave ref={ref}>
it('not show when is input', () => {
const { container } = render(
<Wave>
<input />
</Wave>,
);
expect(ref.current?.bindAnimationEvent()).toBe(undefined);
fireEvent.click(container.querySelector('input')!);
expect(document.querySelector('.ant-wave')).toBeFalsy();
});
it('should not throw when click it', () => {
@ -243,7 +232,7 @@ describe('Wave component', () => {
expect(() => render(<Wave />)).not.toThrow();
});
it('wave color should inferred if border is transparent and background is not', async () => {
it('wave color should inferred if border is transparent and background is not', () => {
const { container, unmount } = render(
<Wave>
<button type="button" style={{ borderColor: 'transparent', background: 'red' }}>
@ -252,17 +241,14 @@ describe('Wave component', () => {
</Wave>,
);
fireEvent.click(container.querySelector('button')!);
await waitFakeTimer();
let styles = (container.querySelector('button')!.getRootNode() as any).getElementsByTagName(
'style',
);
styles = filterStyles(styles);
expect(styles.length).toBe(1);
expect(styles[0].innerHTML).toContain('--antd-wave-shadow-color: red;');
const style = getWaveStyle();
expect(style['--wave-color']).toEqual('red');
unmount();
});
it('wave color should inferred if borderTopColor is transparent and borderColor is not', async () => {
it('wave color should inferred if borderTopColor is transparent and borderColor is not', () => {
const { container, unmount } = render(
<Wave>
<button type="button" style={{ borderColor: 'red', borderTopColor: 'transparent' }}>
@ -270,19 +256,16 @@ describe('Wave component', () => {
</button>
</Wave>,
);
fireEvent.click(container.querySelector('button')!);
await waitFakeTimer();
let styles = (container.querySelector('button')!.getRootNode() as any).getElementsByTagName(
'style',
);
styles = filterStyles(styles);
expect(styles[0].innerHTML).toContain('--antd-wave-shadow-color: red;');
fireEvent.click(container.querySelector('button')!);
const style = getWaveStyle();
expect(style['--wave-color']).toEqual('red');
unmount();
});
it('Wave style should append to validate element', () => {
jest.useFakeTimers();
const { container } = render(
<Wave>
<div className="bamboo" style={{ borderColor: 'red' }} />
@ -295,20 +278,12 @@ describe('Wave component', () => {
fakeDoc.appendChild(document.createElement('span'));
expect(fakeDoc.childNodes).toHaveLength(2);
const elem = container.querySelector('.bamboo');
const elem = container.querySelector('.bamboo')!;
elem.getRootNode = () => fakeDoc;
if (elem) {
elem.getRootNode = () => fakeDoc;
// Click should not throw
fireEvent.click(elem);
// Click should not throw
fireEvent.click(elem);
act(() => {
jest.runAllTimers();
});
expect(fakeDoc.querySelector('style')).toBeTruthy();
}
jest.useRealTimers();
expect(fakeDoc.querySelector('.ant-wave')).toBeTruthy();
});
});

View File

@ -1,38 +0,0 @@
import raf from 'rc-util/lib/raf';
interface RafMap {
[id: number]: number;
}
let id: number = 0;
const ids: RafMap = {};
// Support call raf with delay specified frame
export default function wrapperRaf(callback: () => void, delayFrames: number = 1): number {
const myId: number = id++;
let restFrames: number = delayFrames;
function internalCallback() {
restFrames -= 1;
if (restFrames <= 0) {
callback();
delete ids[myId];
} else {
ids[myId] = raf(internalCallback);
}
}
ids[myId] = raf(internalCallback);
return myId;
}
wrapperRaf.cancel = function cancel(pid?: number) {
if (pid === undefined) return;
raf.cancel(ids[pid]);
delete ids[pid];
};
wrapperRaf.ids = ids; // export this for test usage

View File

@ -0,0 +1,105 @@
import * as React from 'react';
import CSSMotion from 'rc-motion';
import { render, unmount } from 'rc-util/lib/React/render';
import classNames from 'classnames';
import { getTargetWaveColor, getValidateContainer } from './util';
export interface WaveEffectProps {
left: number;
top: number;
width: number;
height: number;
color: string | null;
className: string;
scale: number;
borderRadius: number[];
}
const WaveEffect: React.FC<WaveEffectProps> = (props) => {
const { className, left, top, width, height, color, borderRadius, scale } = props;
const divRef = React.useRef<HTMLDivElement>(null);
const waveStyle = {
left,
top,
width,
height,
borderRadius: borderRadius.map((radius) => `${radius}px`).join(' '),
'--wave-scale': scale,
} as React.CSSProperties & {
[name: string]: number | string;
};
if (color) {
waveStyle['--wave-color'] = color;
}
return (
<CSSMotion
visible
motionAppear
motionName="wave-motion"
motionDeadline={5000}
onAppearEnd={(_, event) => {
if (event.deadline || (event as TransitionEvent).propertyName === 'opacity') {
const holder = divRef.current?.parentElement!;
unmount(holder).then(() => {
holder.parentElement?.removeChild(holder);
});
}
return false;
}}
>
{({ className: motionClassName }) => (
<div ref={divRef} className={classNames(className, motionClassName)} style={waveStyle} />
)}
</CSSMotion>
);
};
function validateNum(value: number) {
return Number.isNaN(value) ? 0 : value;
}
export default function showWaveEffect(container: Node, node: HTMLElement, className: string) {
const nodeStyle = getComputedStyle(node);
const nodeRect = node.getBoundingClientRect();
// Get wave color from target
const waveColor = getTargetWaveColor(node);
// Get border radius
const {
borderTopLeftRadius,
borderTopRightRadius,
borderBottomLeftRadius,
borderBottomRightRadius,
} = nodeStyle;
// Do scale calc
const { offsetWidth } = node;
const scale = validateNum(nodeRect.width / offsetWidth);
// Create holder
const holder = document.createElement('div');
getValidateContainer(container).appendChild(holder);
render(
<WaveEffect
left={nodeRect.left}
top={nodeRect.top}
width={nodeRect.width}
height={nodeRect.height}
color={waveColor}
className={className}
scale={scale}
borderRadius={[
borderTopLeftRadius,
borderTopRightRadius,
borderBottomRightRadius,
borderBottomLeftRadius,
].map((radius) => validateNum(parseFloat(radius) * scale))}
/>,
holder,
);
}

View File

@ -0,0 +1,71 @@
import classNames from 'classnames';
import { composeRef, supportRef } from 'rc-util/lib/ref';
import isVisible from 'rc-util/lib/Dom/isVisible';
import React, { useContext, useRef } from 'react';
import type { ConfigConsumerProps } from '../../config-provider';
import { ConfigContext } from '../../config-provider';
import { cloneElement } from '../reactNode';
import useStyle from './style';
import useWave from './useWave';
export interface WaveProps {
disabled?: boolean;
children?: React.ReactNode;
}
const Wave: React.FC<WaveProps> = (props) => {
const { children, disabled } = props;
const { getPrefixCls } = useContext<ConfigConsumerProps>(ConfigContext);
const containerRef = useRef<HTMLElement>(null);
// ============================== Style ===============================
const prefixCls = getPrefixCls('wave');
const [, hashId] = useStyle(prefixCls);
// =============================== Wave ===============================
const showWave = useWave(containerRef, classNames(prefixCls, hashId));
// ============================== Effect ==============================
React.useEffect(() => {
const node = containerRef.current;
if (!node || node.nodeType !== 1 || disabled) {
return;
}
// Click handler
const onClick = (e: MouseEvent) => {
// Fix radio button click twice
if (
(e.target as HTMLElement).tagName === 'INPUT' ||
!isVisible(e.target as HTMLElement) ||
// No need wave
!node.getAttribute ||
node.getAttribute('disabled') ||
(node as HTMLInputElement).disabled ||
node.className.includes('disabled') ||
node.className.includes('-leave')
) {
return;
}
showWave();
};
// Bind events
node.addEventListener('click', onClick, true);
return () => {
node.removeEventListener('click', onClick, true);
};
}, [disabled]);
// ============================== Render ==============================
if (!React.isValidElement(children)) {
return (children ?? null) as unknown as React.ReactElement;
}
const ref = supportRef(children) ? composeRef((children as any).ref, containerRef) : containerRef;
return cloneElement(children, { ref });
};
export default Wave;

View File

@ -1,261 +0,0 @@
import { updateCSS } from 'rc-util/lib/Dom/dynamicCSS';
import { composeRef, supportRef } from 'rc-util/lib/ref';
import * as React from 'react';
import { forwardRef } from 'react';
import type { ConfigConsumerProps, CSPConfig } from '../../config-provider';
import { ConfigConsumer, ConfigContext } from '../../config-provider';
import raf from '../raf';
import { cloneElement } from '../reactNode';
import useStyle from './style';
let styleForPseudo: HTMLStyleElement | null;
// Where el is the DOM element you'd like to test for visibility
function isHidden(element: HTMLElement) {
if (process.env.NODE_ENV === 'test') {
return false;
}
return !element || element.offsetParent === null || element.hidden;
}
function getValidateContainer(nodeRoot: Node): Element {
if (nodeRoot instanceof Document) {
return nodeRoot.body;
}
return Array.from(nodeRoot.childNodes).find(
(ele) => ele?.nodeType === Node.ELEMENT_NODE,
) as Element;
}
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]) {
return !(match[1] === match[2] && match[2] === match[3]);
}
return true;
}
function isValidWaveColor(color: string) {
return (
color &&
color !== '#fff' &&
color !== '#ffffff' &&
color !== 'rgb(255, 255, 255)' &&
color !== 'rgba(255, 255, 255, 1)' &&
isNotGrey(color) &&
!/rgba\((?:\d*, ){3}0\)/.test(color) && // any transparent rgba color
color !== 'transparent'
);
}
function getTargetWaveColor(node: HTMLElement) {
const computedStyle = getComputedStyle(node);
const borderTopColor = computedStyle.getPropertyValue('border-top-color');
const borderColor = computedStyle.getPropertyValue('border-color');
const backgroundColor = computedStyle.getPropertyValue('background-color');
if (isValidWaveColor(borderTopColor)) {
return borderTopColor;
}
if (isValidWaveColor(borderColor)) {
return borderColor;
}
return backgroundColor;
}
export interface WaveProps {
insertExtraNode?: boolean;
disabled?: boolean;
children?: React.ReactNode;
}
export class InternalWave extends React.Component<WaveProps> {
static contextType = ConfigContext;
private instance?: {
cancel: () => void;
};
private containerRef = React.createRef<HTMLDivElement>();
private extraNode: HTMLDivElement;
private clickWaveTimeoutId: number;
private animationStartId: number;
private animationStart: boolean = false;
private destroyed: boolean = false;
private csp?: CSPConfig;
context: ConfigConsumerProps;
componentDidMount() {
this.destroyed = false;
const node = this.containerRef.current as HTMLDivElement;
if (!node || node.nodeType !== 1) {
return;
}
this.instance = this.bindAnimationEvent(node);
}
componentWillUnmount() {
if (this.instance) {
this.instance.cancel();
}
if (this.clickWaveTimeoutId) {
clearTimeout(this.clickWaveTimeoutId);
}
this.destroyed = true;
}
onClick = (node: HTMLElement, waveColor: string) => {
const { insertExtraNode, disabled } = this.props;
if (disabled || !node || isHidden(node) || node.className.includes('-leave')) {
return;
}
this.extraNode = document.createElement('div');
const { extraNode } = this;
const { getPrefixCls } = this.context;
extraNode.className = `${getPrefixCls('')}-click-animating-node`;
const attributeName = this.getAttributeName();
node.setAttribute(attributeName, 'true');
// Not white or transparent or grey
if (isValidWaveColor(waveColor)) {
extraNode.style.borderColor = waveColor;
const nodeRoot = node.getRootNode?.() || node.ownerDocument;
const nodeBody = getValidateContainer(nodeRoot) ?? nodeRoot;
styleForPseudo = updateCSS(
`
[${getPrefixCls('')}-click-animating-without-extra-node='true']::after, .${getPrefixCls('')}-click-animating-node {
--antd-wave-shadow-color: ${waveColor};
}`,
'antd-wave',
{ csp: this.csp, attachTo: nodeBody },
);
}
if (insertExtraNode) {
node.appendChild(extraNode);
}
['transition', 'animation'].forEach((name) => {
node.addEventListener(`${name}start`, this.onTransitionStart);
node.addEventListener(`${name}end`, this.onTransitionEnd);
});
};
onTransitionStart = (e: AnimationEvent) => {
if (this.destroyed) {
return;
}
const node = this.containerRef.current as HTMLDivElement;
if (!e || e.target !== node || this.animationStart) {
return;
}
this.resetEffect(node);
};
onTransitionEnd = (e: AnimationEvent) => {
if (!e || e.animationName !== 'fadeEffect') {
return;
}
this.resetEffect(e.target as HTMLElement);
};
getAttributeName() {
const { getPrefixCls } = this.context;
const { insertExtraNode } = this.props;
return insertExtraNode
? `${getPrefixCls('')}-click-animating`
: `${getPrefixCls('')}-click-animating-without-extra-node`;
}
bindAnimationEvent = (node?: HTMLElement) => {
if (
!node ||
!node.getAttribute ||
node.getAttribute('disabled') ||
node.className.includes('disabled')
) {
return;
}
const onClick = (e: MouseEvent) => {
// Fix radio button click twice
if ((e.target as HTMLElement).tagName === 'INPUT' || isHidden(e.target as HTMLElement)) {
return;
}
this.resetEffect(node);
// Get wave color from target
const waveColor = getTargetWaveColor(node);
this.clickWaveTimeoutId = window.setTimeout(() => this.onClick(node, waveColor), 0);
raf.cancel(this.animationStartId);
this.animationStart = true;
// Render to trigger transition event cost 3 frames. Let's delay 10 frames to reset this.
this.animationStartId = raf(() => {
this.animationStart = false;
}, 10);
};
node.addEventListener('click', onClick, true);
return {
cancel: () => {
node.removeEventListener('click', onClick, true);
},
};
};
resetEffect(node: HTMLElement) {
if (!node || node === this.extraNode || !(node instanceof Element)) {
return;
}
const { insertExtraNode } = this.props;
const attributeName = this.getAttributeName();
node.setAttribute(attributeName, 'false'); // edge has bug on `removeAttribute` #14466
if (styleForPseudo) {
styleForPseudo.innerHTML = '';
}
if (insertExtraNode && this.extraNode && node.contains(this.extraNode)) {
node.removeChild(this.extraNode);
}
['transition', 'animation'].forEach((name) => {
node.removeEventListener(`${name}start`, this.onTransitionStart);
node.removeEventListener(`${name}end`, this.onTransitionEnd);
});
}
renderWave = ({ csp }: ConfigConsumerProps) => {
const { children } = this.props;
this.csp = csp;
if (!React.isValidElement(children)) return children;
let ref: React.Ref<any> = this.containerRef;
if (supportRef(children)) {
ref = composeRef((children as any).ref, this.containerRef as any);
}
return cloneElement(children, { ref });
};
render() {
return <ConfigConsumer>{this.renderWave}</ConfigConsumer>;
}
}
const Wave = forwardRef<InternalWave, WaveProps>((props, ref) => {
useStyle();
return <InternalWave ref={ref} {...props} />;
});
export default Wave;

View File

@ -1,86 +1,37 @@
import { Keyframes, useStyleRegister } from '@ant-design/cssinjs';
import { useContext } from 'react';
import { ConfigContext } from '../../config-provider';
import type { AliasToken, GenerateStyle, UseComponentStyleResult } from '../../theme/internal';
import { useToken } from '../../theme/internal';
import { genComponentStyleHook } from '../../theme/internal';
import type { FullToken, GenerateStyle } from '../../theme/internal';
interface WaveToken extends AliasToken {
hashId: string;
clickAnimatingNode: string;
clickAnimatingTrue: string;
clickAnimatingWithoutExtraNodeTrue: string;
clickAnimatingWithoutExtraNodeTrueAfter: string;
}
export interface ComponentToken {}
export interface WaveToken extends FullToken<'Wave'> {}
const genWaveStyle: GenerateStyle<WaveToken> = (token) => {
const waveEffect = new Keyframes('waveEffect', {
'100%': {
boxShadow: `0 0 0 6px var(--antd-wave-shadow-color)`,
},
});
const { componentCls, colorPrimary } = token;
return {
[componentCls]: {
position: 'fixed',
background: 'transparent',
pointerEvents: 'none',
boxSizing: 'border-box',
color: `var(--wave-color, ${colorPrimary})`,
const fadeEffect = new Keyframes('fadeEffect', {
'100%': {
opacity: 0,
},
});
boxShadow: `0 0 0 0 currentcolor`,
opacity: 0.2,
return [
{
[`${token.clickAnimatingWithoutExtraNodeTrue},
${token.clickAnimatingTrue}`]: {
'--antd-wave-shadow-color': token.colorPrimary,
'--scroll-bar': 0,
position: 'relative',
},
[`${token.clickAnimatingWithoutExtraNodeTrueAfter},
& ${token.clickAnimatingNode}`]: {
position: 'absolute',
top: 0,
insetInlineStart: 0,
insetInlineEnd: 0,
bottom: 0,
display: 'block',
borderRadius: 'inherit',
boxShadow: `0 0 0 0 var(--antd-wave-shadow-color)`,
opacity: 0.2,
animation: {
_skip_check_: true,
value: `${fadeEffect.getName(token.hashId)} 2s ${
token.motionEaseOutCirc
}, ${waveEffect.getName(token.hashId)} 0.4s ${token.motionEaseOutCirc}`,
// =================== Motion ===================
'&.wave-motion-appear': {
transition: [
`box-shadow 0.4s ${token.motionEaseOutCirc}`,
`opacity 2s ${token.motionEaseOutCirc}`,
].join(','),
'&-active': {
boxShadow: `0 0 0 calc(6px * var(--wave-scale)) currentcolor`,
opacity: 0,
},
animationFillMode: 'forwards',
content: '""',
pointerEvents: 'none',
},
},
{},
waveEffect,
fadeEffect,
];
};
export default (): UseComponentStyleResult => {
const [theme, token, hashId] = useToken();
const { getPrefixCls } = useContext(ConfigContext);
const rootPrefixCls = getPrefixCls();
const clickAnimatingTrue = `[${rootPrefixCls}-click-animating='true']`;
const clickAnimatingWithoutExtraNodeTrue = `[${rootPrefixCls}-click-animating-without-extra-node='true']`;
const clickAnimatingNode = `.${rootPrefixCls}-click-animating-node`;
const waveToken: WaveToken = {
...token,
hashId,
clickAnimatingNode,
clickAnimatingTrue,
clickAnimatingWithoutExtraNodeTrue,
clickAnimatingWithoutExtraNodeTrueAfter: `${clickAnimatingWithoutExtraNodeTrue}::after`,
};
return [
useStyleRegister({ theme, token, hashId, path: ['wave'] }, () => [genWaveStyle(waveToken)]),
hashId,
];
};
export default genComponentStyleHook('Wave', (token) => [genWaveStyle(token)]);

View File

@ -0,0 +1,18 @@
import showWaveEffect from './WaveEffect';
export default function useWave(
nodeRef: React.RefObject<HTMLElement>,
className: string,
): VoidFunction {
function showWave() {
const node = nodeRef.current!;
// Skip if not exist doc
const container = node.getRootNode?.() || node?.ownerDocument;
if (container) {
showWaveEffect(container, node, className);
}
}
return showWave;
}

View File

@ -0,0 +1,45 @@
export function getValidateContainer(nodeRoot: Node): Element {
if (nodeRoot instanceof Document) {
return nodeRoot.body;
}
return Array.from(nodeRoot.childNodes).find(
(ele) => ele?.nodeType === Node.ELEMENT_NODE,
) as Element;
}
export 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]) {
return !(match[1] === match[2] && match[2] === match[3]);
}
return true;
}
export function isValidWaveColor(color: string) {
return (
color &&
color !== '#fff' &&
color !== '#ffffff' &&
color !== 'rgb(255, 255, 255)' &&
color !== 'rgba(255, 255, 255, 1)' &&
isNotGrey(color) &&
!/rgba\((?:\d*, ){3}0\)/.test(color) && // any transparent rgba color
color !== 'transparent'
);
}
export function getTargetWaveColor(node: HTMLElement) {
const { borderTopColor, borderColor, backgroundColor } = getComputedStyle(node);
if (isValidWaveColor(borderTopColor)) {
return borderTopColor;
}
if (isValidWaveColor(borderColor)) {
return borderColor;
}
if (isValidWaveColor(backgroundColor)) {
return backgroundColor;
}
return null;
}

View File

@ -1,26 +1,11 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import Button from '..';
import { fireEvent, render, assertsExist } from '../../../tests/utils';
import { fireEvent, render } from '../../../tests/utils';
// Mock Wave ref
let waveInstanceMock: any;
jest.mock('../../_util/wave', () => {
const Wave: typeof import('../../_util/wave') = jest.requireActual('../../_util/wave');
const WaveComponent = Wave.default;
return {
...Wave,
__esModule: true,
default: (props: import('../../_util/wave').WaveProps) => (
<WaveComponent
ref={(node) => {
waveInstanceMock = node;
}}
{...props}
/>
),
};
jest.mock('rc-util/lib/Dom/isVisible', () => {
const mockFn = () => true;
return mockFn;
});
describe('click wave effect', () => {
@ -31,99 +16,38 @@ describe('click wave effect', () => {
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
document.body.innerHTML = '';
});
async function clickButton(wrapper: any) {
const element = wrapper.container.firstChild;
async function clickButton(container: HTMLElement) {
const element = container.firstChild;
// https://github.com/testing-library/user-event/issues/833
await userEvent.setup({ advanceTimers: jest.advanceTimersByTime }).click(element);
fireEvent(element, new Event('transitionstart'));
fireEvent(element, new Event('animationend'));
await userEvent.setup({ advanceTimers: jest.advanceTimersByTime }).click(element as Element);
fireEvent(element!, new Event('transitionstart'));
fireEvent(element!, new Event('animationend'));
}
it('should have click wave effect for primary button', async () => {
const wrapper = render(<Button type="primary">button</Button>);
await clickButton(wrapper);
expect(wrapper.container.querySelector('.ant-btn')).toHaveAttribute(
'ant-click-animating-without-extra-node',
);
const { container } = render(<Button type="primary">button</Button>);
await clickButton(container);
expect(document.querySelector('.ant-wave')).toBeTruthy();
});
it('should have click wave effect for default button', async () => {
const wrapper = render(<Button>button</Button>);
await clickButton(wrapper);
expect(wrapper.container.querySelector('.ant-btn')).toHaveAttribute(
'ant-click-animating-without-extra-node',
);
const { container } = render(<Button>button</Button>);
await clickButton(container);
expect(document.querySelector('.ant-wave')).toBeTruthy();
});
it('should not have click wave effect for link type button', async () => {
const wrapper = render(<Button type="link">button</Button>);
await clickButton(wrapper);
expect(wrapper.container.querySelector('.ant-btn')).not.toHaveAttribute(
'ant-click-animating-without-extra-node',
);
const { container } = render(<Button type="link">button</Button>);
await clickButton(container);
expect(document.querySelector('.ant-wave')).toBeFalsy();
});
it('should not have click wave effect for text type button', async () => {
const wrapper = render(<Button type="text">button</Button>);
await clickButton(wrapper);
expect(wrapper.container.querySelector('.ant-btn')).not.toHaveAttribute(
'ant-click-animating-without-extra-node',
);
});
it('should handle transitionstart', async () => {
const wrapper = render(<Button type="primary">button</Button>);
await clickButton(wrapper);
const buttonNode = wrapper.container.querySelector('.ant-btn')!;
fireEvent(buttonNode, new Event('transitionstart'));
expect(wrapper.container.querySelector('.ant-btn')).toHaveAttribute(
'ant-click-animating-without-extra-node',
);
wrapper.unmount();
fireEvent(buttonNode, new Event('transitionstart'));
});
it('should run resetEffect in transitionstart', async () => {
const wrapper = render(<Button type="primary">button</Button>);
assertsExist(waveInstanceMock);
const resetEffect = jest.spyOn(waveInstanceMock, 'resetEffect');
await clickButton(wrapper);
expect(resetEffect).toHaveBeenCalledTimes(1);
await userEvent
.setup({ advanceTimers: jest.advanceTimersByTime })
.click(wrapper.container.querySelector('.ant-btn')!);
expect(resetEffect).toHaveBeenCalledTimes(2);
waveInstanceMock.animationStart = false;
fireEvent(wrapper.container.querySelector('.ant-btn')!, new Event('transitionstart'));
expect(resetEffect).toHaveBeenCalledTimes(3);
resetEffect.mockRestore();
});
it('should handle transitionend', async () => {
const wrapper = render(<Button type="primary">button</Button>);
assertsExist(waveInstanceMock);
const resetEffect = jest.spyOn(waveInstanceMock, 'resetEffect');
await clickButton(wrapper);
expect(resetEffect).toHaveBeenCalledTimes(1);
const event = new Event('animationend');
Object.assign(event, { animationName: 'fadeEffect' });
fireEvent(wrapper.container.querySelector('.ant-btn')!, event);
expect(resetEffect).toHaveBeenCalledTimes(2);
resetEffect.mockRestore();
});
it('Wave on falsy element', async () => {
const { default: Wave } = jest.requireActual('../../_util/wave');
let waveInstance: any;
render(
<Wave
ref={(node: any) => {
waveInstance = node;
}}
/>,
);
waveInstance.resetEffect();
const { container } = render(<Button type="text">button</Button>);
await clickButton(container);
expect(document.querySelector('.ant-wave')).toBeFalsy();
});
});

View File

@ -3,7 +3,7 @@ import React, { useState } from 'react';
import type { ConfigConsumerProps } from '..';
import ConfigProvider, { ConfigContext } from '..';
import mountTest from '../../../tests/shared/mountTest';
import { act, fireEvent, render } from '../../../tests/utils';
import { fireEvent, render } from '../../../tests/utils';
import Button from '../../button';
import Input from '../../input';
import Table from '../../table';
@ -16,26 +16,6 @@ describe('ConfigProvider', () => {
</ConfigProvider>
));
it('Content Security Policy', () => {
jest.useFakeTimers();
const csp = { nonce: 'test-antd' };
const { container } = render(
<ConfigProvider csp={csp}>
<Button />
</ConfigProvider>,
);
fireEvent.click(container.querySelector('button')!);
act(() => {
jest.runAllTimers();
});
const styles = Array.from(document.body.querySelectorAll<HTMLStyleElement>('style'));
expect(styles[styles.length - 1].nonce).toEqual(csp.nonce);
jest.useRealTimers();
});
it('autoInsertSpaceInButton', () => {
const text = '确定';
const { container } = render(

View File

@ -3,20 +3,24 @@ import Switch from '..';
import focusTest from '../../../tests/shared/focusTest';
import mountTest from '../../../tests/shared/mountTest';
import rtlTest from '../../../tests/shared/rtlTest';
import { waitFakeTimer, fireEvent, render } from '../../../tests/utils';
import { fireEvent, render } from '../../../tests/utils';
import { resetWarned } from '../../_util/warning';
jest.mock('rc-util/lib/Dom/isVisible', () => {
const mockFn = () => true;
return mockFn;
});
describe('Switch', () => {
focusTest(Switch, { refFocus: true });
mountTest(Switch);
rtlTest(Switch);
it('should has click wave effect', async () => {
it('should has click wave effect', () => {
jest.useFakeTimers();
const { container } = render(<Switch />);
fireEvent.click(container.querySelector('.ant-switch')!);
await waitFakeTimer();
expect(container.querySelector('button')!.getAttribute('ant-click-animating')).toBe('true');
expect(document.querySelector('.ant-wave')).toBeTruthy();
jest.clearAllTimers();
jest.useRealTimers();
});

View File

@ -90,7 +90,7 @@ const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
);
return wrapSSR(
<Wave insertExtraNode>
<Wave>
<RcSwitch
{...props}
prefixCls={prefixCls}

View File

@ -48,6 +48,7 @@ import type { ComponentToken as UploadComponentToken } from '../../upload/style'
import type { ComponentToken as TourComponentToken } from '../../tour/style';
import type { ComponentToken as QRCodeComponentToken } from '../../qrcode/style';
import type { ComponentToken as AppComponentToken } from '../../app/style';
import type { ComponentToken as WaveToken } from '../../_util/wave/style';
export interface ComponentTokenMap {
Affix?: {};
@ -112,4 +113,7 @@ export interface ComponentTokenMap {
Tour?: TourComponentToken;
QRCode?: QRCodeComponentToken;
App?: AppComponentToken;
/** @private Internal TS definition. Do not use. */
Wave?: WaveToken;
}

View File

@ -589,7 +589,6 @@ exports[`Tour step support Primary 1`] = `
</span>
</button>
<button
ant-click-animating-without-extra-node="false"
class="ant-btn ant-btn-default ant-btn-sm ant-tour-next-btn"
type="button"
>