diff --git a/components/_util/__tests__/util.test.tsx b/components/_util/__tests__/util.test.tsx index d82d8d93c977..70b8950c4c70 100644 --- a/components/_util/__tests__/util.test.tsx +++ b/components/_util/__tests__/util.test.tsx @@ -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(); diff --git a/components/_util/__tests__/wave.test.tsx b/components/_util/__tests__/wave.test.tsx index e166cb2347bc..c3308b8694f5 100644 --- a/components/_util/__tests__/wave.test.tsx +++ b/components/_util/__tests__/wave.test.tsx @@ -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(styles).filter( - (style: HTMLStyleElement) => !style.hasAttribute('data-css-hash'), - ); + function getWaveStyle() { + const styleObj: Record = {}; + const { style } = document.querySelector('.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( , ); - 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( , ); - 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( , ); - container.querySelector('button')?.click(); - await waitFakeTimer(); - let styles: HTMLCollectionOf | 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( , ); - container.querySelector('button')?.click(); - await waitFakeTimer(); - let styles: HTMLCollectionOf | 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(
button
, ); - container.querySelector('div')?.click(); - await waitFakeTimer(); - let styles: HTMLCollectionOf | 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(
button
, ); - container.querySelector('div')?.click(); - await waitFakeTimer(); - let styles: HTMLCollectionOf | 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(
button
, ); - container.querySelector('div')?.click(); - await waitFakeTimer(); - let styles: HTMLCollectionOf | 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( , ); - container.querySelector('button')?.click(); - await waitFakeTimer(); - let styles: HTMLCollectionOf | HTMLStyleElement[] = ( - container.querySelector('button')?.getRootNode() as HTMLButtonElement - ).getElementsByTagName('style'); - styles = filterStyles(styles); - expect(styles.length).toBe(0); - unmount(); - }); - it('ConfigProvider csp', async () => { - const { container, unmount } = render( - - - - - , - ); - container.querySelector('button')?.click(); - await waitFakeTimer(); - let styles: HTMLCollectionOf | HTMLStyleElement[] = ( - container.querySelector('button')?.getRootNode() as HTMLButtonElement - ).getElementsByTagName('style'); - styles = filterStyles(styles); - expect(styles[0].getAttribute('nonce')).toBe('YourNonceCode'); + fireEvent.click(container.querySelector('button')!); + expect(document.querySelector('.ant-wave')).toBeFalsy(); + unmount(); }); - it('bindAnimationEvent should return when node is null', () => { - const ref = React.createRef(); - render( - + it('not show when disabled', () => { + const { container } = render( + , ); - 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(); - render( - + it('not show when hidden', () => { + (global as any).isVisible = false; + + const { container } = render( + , ); - 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(); - render( - + it('not show when is input', () => { + const { container } = render( + , ); - 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()).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( , ); + 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;'); + const style = getWaveStyle(); + expect(style['--wave-color']).toEqual('red'); + unmount(); }); it('Wave style should append to validate element', () => { - jest.useFakeTimers(); const { container } = render(
@@ -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(); }); }); diff --git a/components/_util/raf.ts b/components/_util/raf.ts deleted file mode 100644 index 8954b623a4e2..000000000000 --- a/components/_util/raf.ts +++ /dev/null @@ -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 diff --git a/components/_util/wave/WaveEffect.tsx b/components/_util/wave/WaveEffect.tsx new file mode 100644 index 000000000000..4ef27eed9097 --- /dev/null +++ b/components/_util/wave/WaveEffect.tsx @@ -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 = (props) => { + const { className, left, top, width, height, color, borderRadius, scale } = props; + const divRef = React.useRef(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 ( + { + if (event.deadline || (event as TransitionEvent).propertyName === 'opacity') { + const holder = divRef.current?.parentElement!; + unmount(holder).then(() => { + holder.parentElement?.removeChild(holder); + }); + } + return false; + }} + > + {({ className: motionClassName }) => ( +
+ )} + + ); +}; + +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( + validateNum(parseFloat(radius) * scale))} + />, + holder, + ); +} diff --git a/components/_util/wave/index.ts b/components/_util/wave/index.ts new file mode 100644 index 000000000000..f32405e1c728 --- /dev/null +++ b/components/_util/wave/index.ts @@ -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 = (props) => { + const { children, disabled } = props; + const { getPrefixCls } = useContext(ConfigContext); + const containerRef = useRef(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; diff --git a/components/_util/wave/index.tsx b/components/_util/wave/index.tsx deleted file mode 100644 index c01745fbd415..000000000000 --- a/components/_util/wave/index.tsx +++ /dev/null @@ -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 { - static contextType = ConfigContext; - - private instance?: { - cancel: () => void; - }; - - private containerRef = React.createRef(); - - 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 = this.containerRef; - if (supportRef(children)) { - ref = composeRef((children as any).ref, this.containerRef as any); - } - - return cloneElement(children, { ref }); - }; - - render() { - return {this.renderWave}; - } -} - -const Wave = forwardRef((props, ref) => { - useStyle(); - return ; -}); - -export default Wave; diff --git a/components/_util/wave/style.ts b/components/_util/wave/style.ts index 798735844b38..825344dedaec 100644 --- a/components/_util/wave/style.ts +++ b/components/_util/wave/style.ts @@ -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 {} -const genWaveStyle: GenerateStyle = (token) => { - const waveEffect = new Keyframes('waveEffect', { - '100%': { - boxShadow: `0 0 0 6px var(--antd-wave-shadow-color)`, - }, - }); - - const fadeEffect = new Keyframes('fadeEffect', { - '100%': { - opacity: 0, - }, - }); +export interface WaveToken extends FullToken<'Wave'> {} - 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}`, +const genWaveStyle: GenerateStyle = (token) => { + const { componentCls, colorPrimary } = token; + return { + [componentCls]: { + position: 'fixed', + background: 'transparent', + pointerEvents: 'none', + boxSizing: 'border-box', + color: `var(--wave-color, ${colorPrimary})`, + + boxShadow: `0 0 0 0 currentcolor`, + opacity: 0.2, + + // =================== 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)]); diff --git a/components/_util/wave/useWave.ts b/components/_util/wave/useWave.ts new file mode 100644 index 000000000000..0b23ed4b37a1 --- /dev/null +++ b/components/_util/wave/useWave.ts @@ -0,0 +1,18 @@ +import showWaveEffect from './WaveEffect'; + +export default function useWave( + nodeRef: React.RefObject, + 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; +} diff --git a/components/_util/wave/util.ts b/components/_util/wave/util.ts new file mode 100644 index 000000000000..9f6b1f0a1d1e --- /dev/null +++ b/components/_util/wave/util.ts @@ -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; +} diff --git a/components/button/__tests__/wave.test.tsx b/components/button/__tests__/wave.test.tsx index edeeb3aab23a..71d76e28ae11 100644 --- a/components/button/__tests__/wave.test.tsx +++ b/components/button/__tests__/wave.test.tsx @@ -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) => ( - { - 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(); - await clickButton(wrapper); - expect(wrapper.container.querySelector('.ant-btn')).toHaveAttribute( - 'ant-click-animating-without-extra-node', - ); + const { container } = render(); + await clickButton(container); + expect(document.querySelector('.ant-wave')).toBeTruthy(); }); it('should have click wave effect for default button', async () => { - const wrapper = render(); - await clickButton(wrapper); - expect(wrapper.container.querySelector('.ant-btn')).toHaveAttribute( - 'ant-click-animating-without-extra-node', - ); + const { container } = render(); + await clickButton(container); + expect(document.querySelector('.ant-wave')).toBeTruthy(); }); it('should not have click wave effect for link type button', async () => { - const wrapper = render(); - await clickButton(wrapper); - expect(wrapper.container.querySelector('.ant-btn')).not.toHaveAttribute( - 'ant-click-animating-without-extra-node', - ); + const { container } = render(); + await clickButton(container); + expect(document.querySelector('.ant-wave')).toBeFalsy(); }); it('should not have click wave effect for text type button', async () => { - const wrapper = render(); - 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(); - 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(); - 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(); - 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( - { - waveInstance = node; - }} - />, - ); - waveInstance.resetEffect(); + const { container } = render(); + await clickButton(container); + expect(document.querySelector('.ant-wave')).toBeFalsy(); }); }); diff --git a/components/config-provider/__tests__/index.test.tsx b/components/config-provider/__tests__/index.test.tsx index 1fbb048f2979..ee0a36fd1f00 100644 --- a/components/config-provider/__tests__/index.test.tsx +++ b/components/config-provider/__tests__/index.test.tsx @@ -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', () => { )); - it('Content Security Policy', () => { - jest.useFakeTimers(); - - const csp = { nonce: 'test-antd' }; - const { container } = render( - -