fix: button wave not work

pull/6373/head
tangjinzhou 2023-03-20 16:45:26 +08:00
parent 7d1418d9f4
commit dcc3bb10cb
8 changed files with 352 additions and 181 deletions

View File

@ -1,178 +0,0 @@
import { nextTick, defineComponent, getCurrentInstance, onMounted, onBeforeUnmount } from 'vue';
import TransitionEvents from './css-animation/Event';
import raf from './raf';
import { findDOMNode } from './props-util';
import useConfigInject from '../config-provider/hooks/useConfigInject';
let styleForPesudo: HTMLStyleElement;
// 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;
}
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 default defineComponent({
compatConfig: { MODE: 3 },
name: 'Wave',
props: {
insertExtraNode: Boolean,
disabled: Boolean,
},
setup(props, { slots, expose }) {
const instance = getCurrentInstance();
const { csp, prefixCls } = useConfigInject('', props);
expose({
csp,
});
let eventIns = null;
let clickWaveTimeoutId = null;
let animationStartId = null;
let animationStart = false;
let extraNode = null;
let isUnmounted = false;
const onTransitionStart = e => {
if (isUnmounted) return;
const node = findDOMNode(instance);
if (!e || e.target !== node) {
return;
}
if (!animationStart) {
resetEffect(node);
}
};
const onTransitionEnd = (e: any) => {
if (!e || e.animationName !== 'fadeEffect') {
return;
}
resetEffect(e.target);
};
const getAttributeName = () => {
const { insertExtraNode } = props;
return insertExtraNode
? `${prefixCls.value}-click-animating`
: `${prefixCls.value}-click-animating-without-extra-node`;
};
const onClick = (node: HTMLElement, waveColor: string) => {
const { insertExtraNode, disabled } = props;
if (disabled || !node || isHidden(node) || node.className.indexOf('-leave') >= 0) {
return;
}
extraNode = document.createElement('div');
extraNode.className = `${prefixCls.value}-click-animating-node`;
const attributeName = getAttributeName();
node.removeAttribute(attributeName);
node.setAttribute(attributeName, 'true');
// Not white or transparent or grey
styleForPesudo = styleForPesudo || document.createElement('style');
if (
waveColor &&
waveColor !== '#ffffff' &&
waveColor !== 'rgb(255, 255, 255)' &&
isNotGrey(waveColor) &&
!/rgba\(\d*, \d*, \d*, 0\)/.test(waveColor) && // any transparent rgba color
waveColor !== 'transparent'
) {
// Add nonce if CSP exist
if (csp.value?.nonce) {
styleForPesudo.nonce = csp.value.nonce;
}
extraNode.style.borderColor = waveColor;
styleForPesudo.innerHTML = `
[${prefixCls.value}-click-animating-without-extra-node='true']::after, .${prefixCls.value}-click-animating-node {
--antd-wave-shadow-color: ${waveColor};
}`;
if (!document.body.contains(styleForPesudo)) {
document.body.appendChild(styleForPesudo);
}
}
if (insertExtraNode) {
node.appendChild(extraNode);
}
TransitionEvents.addStartEventListener(node, onTransitionStart);
TransitionEvents.addEndEventListener(node, onTransitionEnd);
};
const resetEffect = (node: HTMLElement) => {
if (!node || node === extraNode || !(node instanceof Element)) {
return;
}
const { insertExtraNode } = props;
const attributeName = getAttributeName();
node.setAttribute(attributeName, 'false'); // edge has bug on `removeAttribute` #14466
if (styleForPesudo) {
styleForPesudo.innerHTML = '';
}
if (insertExtraNode && extraNode && node.contains(extraNode)) {
node.removeChild(extraNode);
}
TransitionEvents.removeStartEventListener(node, onTransitionStart);
TransitionEvents.removeEndEventListener(node, onTransitionEnd);
};
const bindAnimationEvent = (node: HTMLElement) => {
if (
!node ||
!node.getAttribute ||
node.getAttribute('disabled') ||
node.className.indexOf('disabled') >= 0
) {
return;
}
const newClick = (e: MouseEvent) => {
// Fix radio button click twice
if ((e.target as any).tagName === 'INPUT' || isHidden(e.target as HTMLElement)) {
return;
}
resetEffect(node);
// Get wave color from target
const waveColor =
getComputedStyle(node).getPropertyValue('border-top-color') || // Firefox Compatible
getComputedStyle(node).getPropertyValue('border-color') ||
getComputedStyle(node).getPropertyValue('background-color');
clickWaveTimeoutId = setTimeout(() => onClick(node, waveColor), 0);
raf.cancel(animationStartId);
animationStart = true;
// Render to trigger transition event cost 3 frames. Let's delay 10 frames to reset this.
animationStartId = raf(() => {
animationStart = false;
}, 10);
};
node.addEventListener('click', newClick, true);
return {
cancel: () => {
node.removeEventListener('click', newClick, true);
},
};
};
onMounted(() => {
nextTick(() => {
const node = findDOMNode(instance);
if (node.nodeType !== 1) {
return;
}
eventIns = bindAnimationEvent(node);
});
});
onBeforeUnmount(() => {
if (eventIns) {
eventIns.cancel();
}
clearTimeout(clickWaveTimeoutId);
isUnmounted = true;
});
return () => {
return slots.default?.()[0];
};
},
});

View File

@ -0,0 +1,164 @@
import type { CSSProperties } from 'vue';
import { onBeforeUnmount, onMounted, Transition, render, defineComponent, ref } from 'vue';
import useState from '../hooks/useState';
import { objectType } from '../type';
import { getTargetWaveColor } from './util';
import wrapperRaf from '../raf';
function validateNum(value: number) {
return Number.isNaN(value) ? 0 : value;
}
export interface WaveEffectProps {
className: string;
target: HTMLElement;
}
const WaveEffect = defineComponent({
props: {
target: objectType<HTMLElement>(),
className: String,
},
setup(props) {
const divRef = ref<HTMLDivElement | null>(null);
const [color, setWaveColor] = useState<string | null>(null);
const [borderRadius, setBorderRadius] = useState<number[]>([]);
const [left, setLeft] = useState(0);
const [top, setTop] = useState(0);
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const [enabled, setEnabled] = useState(false);
function syncPos() {
const { target } = props;
const nodeStyle = getComputedStyle(target);
// Get wave color from target
setWaveColor(getTargetWaveColor(target));
const isStatic = nodeStyle.position === 'static';
// Rect
const { borderLeftWidth, borderTopWidth } = nodeStyle;
setLeft(isStatic ? target.offsetLeft : validateNum(-parseFloat(borderLeftWidth)));
setTop(isStatic ? target.offsetTop : validateNum(-parseFloat(borderTopWidth)));
setWidth(target.offsetWidth);
setHeight(target.offsetHeight);
// Get border radius
const {
borderTopLeftRadius,
borderTopRightRadius,
borderBottomLeftRadius,
borderBottomRightRadius,
} = nodeStyle;
setBorderRadius(
[
borderTopLeftRadius,
borderTopRightRadius,
borderBottomRightRadius,
borderBottomLeftRadius,
].map(radius => validateNum(parseFloat(radius))),
);
}
// Add resize observer to follow size
let resizeObserver: ResizeObserver;
let rafId: number;
let timeoutId: any;
const clear = () => {
clearTimeout(timeoutId);
wrapperRaf.cancel(rafId);
resizeObserver?.disconnect();
};
const removeDom = () => {
const holder = divRef.value?.parentElement;
if (holder) {
render(null, holder);
if (holder.parentElement) {
holder.parentElement.removeChild(holder);
}
}
};
onMounted(() => {
clear();
timeoutId = setTimeout(() => {
removeDom();
}, 5000);
const { target } = props;
if (target) {
// We need delay to check position here
// since UI may change after click
rafId = wrapperRaf(() => {
syncPos();
setEnabled(true);
});
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(syncPos);
resizeObserver.observe(target);
}
}
});
onBeforeUnmount(() => {
clear();
});
const onTransitionend = (e: TransitionEvent) => {
if (e.propertyName === 'opacity') {
removeDom();
}
};
return () => {
if (!enabled.value) {
return null;
}
const waveStyle = {
left: `${left.value}px`,
top: `${top.value}px`,
width: `${width.value}px`,
height: `${height.value}px`,
borderRadius: borderRadius.value.map(radius => `${radius}px`).join(' '),
} as CSSProperties & {
[name: string]: number | string;
};
if (color) {
waveStyle['--wave-color'] = color.value as string;
}
return (
<Transition
appear
name="wave-motion"
appearFromClass="wave-motion-appear"
appearActiveClass="wave-motion-appear"
appearToClass="wave-motion-appear wave-motion-appear-active"
>
<div
ref={divRef}
class={props.className}
style={waveStyle}
onTransitionend={onTransitionend}
/>
</Transition>
);
};
},
});
function showWaveEffect(node: HTMLElement, className: string) {
// Create holder
const holder = document.createElement('div');
holder.style.position = 'absolute';
holder.style.left = `0px`;
holder.style.top = `0px`;
node?.insertBefore(holder, node?.firstChild);
render(<WaveEffect target={node} className={className} />, holder);
}
export default showWaveEffect;

View File

@ -0,0 +1,96 @@
import {
computed,
defineComponent,
getCurrentInstance,
nextTick,
onBeforeUnmount,
onMounted,
watch,
} from 'vue';
import useConfigInject from '../../config-provider/hooks/useConfigInject';
import isVisible from '../../vc-util/Dom/isVisible';
import classNames from '../classNames';
import { findDOMNode } from '../props-util';
import useStyle from './style';
import useWave from './useWave';
export interface WaveProps {
disabled?: boolean;
}
export default defineComponent({
compatConfig: { MODE: 3 },
name: 'Wave',
props: {
disabled: Boolean,
},
setup(props, { slots }) {
const instance = getCurrentInstance();
const { prefixCls } = useConfigInject('wave', props);
// ============================== Style ===============================
const [, hashId] = useStyle(prefixCls);
// =============================== Wave ===============================
const showWave = useWave(
instance,
computed(() => classNames(prefixCls.value, hashId.value)),
);
let onClick: (e: MouseEvent) => void;
const clear = () => {
const node = findDOMNode(instance);
node.removeEventListener('click', onClick, true);
};
onMounted(() => {
watch(
() => props.disabled,
() => {
clear();
nextTick(() => {
const node = findDOMNode(instance);
if (!node || node.nodeType !== 1 || props.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);
});
},
{
immediate: true,
flush: 'post',
},
);
});
onBeforeUnmount(() => {
clear();
});
return () => {
// ============================== Render ==============================
const children = slots.default?.()[0];
return children;
};
},
});

View File

@ -0,0 +1,38 @@
import { genComponentStyleHook } from '../../theme/internal';
import type { FullToken, GenerateStyle } from '../../theme/internal';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ComponentToken {}
export type WaveToken = FullToken<'Wave'>;
const genWaveStyle: GenerateStyle<WaveToken> = token => {
const { componentCls, colorPrimary } = token;
return {
[componentCls]: {
position: 'absolute',
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 6px currentcolor`,
opacity: 0,
},
},
},
};
};
export default genComponentStyleHook('Wave', token => [genWaveStyle(token)]);

View File

@ -0,0 +1,16 @@
import type { ComponentInternalInstance, Ref } from 'vue';
import { findDOMNode } from '../props-util';
import showWaveEffect from './WaveEffect';
export default function useWave(
instance: ComponentInternalInstance | null,
className: Ref<string>,
): VoidFunction {
function showWave() {
const node = findDOMNode(instance);
showWaveEffect(node, className.value);
}
return showWave;
}

View File

@ -0,0 +1,35 @@
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

@ -154,7 +154,7 @@ const Switch = defineComponent({
return () =>
wrapSSR(
<Wave insertExtraNode>
<Wave>
<button
{...omit(props, [
'prefixCls',

View File

@ -48,7 +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';
import type { ComponentToken as WaveToken } from '../../_util/wave/style';
export interface ComponentTokenMap {
Affix?: {};
@ -117,5 +117,5 @@ export interface ComponentTokenMap {
// App?: AppComponentToken;
// /** @private Internal TS definition. Do not use. */
// Wave?: WaveToken;
Wave?: WaveToken;
}