feat: add wave animation

feat/vapor
tangjinzhou 2025-07-31 15:48:39 +08:00
parent d7ca354b87
commit 23ebeea4b7
7 changed files with 343 additions and 3 deletions

View File

@ -1,5 +1,12 @@
<template>
<button :class="rootClass" @click="handleClick" :disabled="disabled" :style="cssVars">
<button
ref="buttonRef"
:class="rootClass"
@click="handleClick"
:disabled="disabled"
:style="cssVars"
>
<Wave :target="buttonRef" />
<slot name="loading">
<LoadingOutlined v-if="loading" />
</slot>
@ -9,15 +16,16 @@
</template>
<script setup lang="ts">
import { computed, Fragment } from 'vue'
import { computed, ref } from 'vue'
import { buttonProps, buttonEmits, ButtonSlots } from './meta'
import { getCssVarColor } from '@/utils/colorAlgorithm'
import { useThemeInject } from '../theme/hook'
import LoadingOutlined from '@ant-design/icons-vue/LoadingOutlined'
import { defaultColor } from '../theme/meta'
import { Wave } from '../wave'
const props = defineProps(buttonProps)
const buttonRef = ref<HTMLButtonElement | null>(null)
const emit = defineEmits(buttonEmits)
defineSlots<ButtonSlots>()

View File

@ -0,0 +1,192 @@
<template>
<div v-if="show && !disabled" ref="divRef" style="position: absolute; left: 0; top: 0">
<Transition
appear
name="ant-wave-motion"
appearFromClass="ant-wave-motion-appear"
appearActiveClass="ant-wave-motion-appear"
appearToClass="ant-wave-motion-appear ant-wave-motion-appear-active"
>
<div
v-if="show"
:style="waveStyle"
class="ant-wave-motion"
@transitionend="onTransitionend"
/>
</Transition>
</div>
</template>
<script setup lang="ts">
import wrapperRaf from '@/utils/raf'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
Transition,
watch,
} from 'vue'
import { getTargetWaveColor } from './util'
import isVisible from '@/utils/isVisible'
const props = defineProps<{
disabled?: boolean
target: HTMLElement
}>()
const divRef = shallowRef<HTMLDivElement | null>(null)
const show = defineModel<boolean>('show')
const color = ref<string | null>(null)
const borderRadius = ref<number[]>([])
const left = ref(0)
const top = ref(0)
const width = ref(0)
const height = ref(0)
function validateNum(value: number) {
return Number.isNaN(value) ? 0 : value
}
function syncPos() {
const { target } = props
if (!target) {
return
}
const nodeStyle = getComputedStyle(target)
// Get wave color from target
color.value = getTargetWaveColor(target)
const isStatic = nodeStyle.position === 'static'
// Rect
const { borderLeftWidth, borderTopWidth } = nodeStyle
left.value = isStatic ? target.offsetLeft : validateNum(-parseFloat(borderLeftWidth))
top.value = isStatic ? target.offsetTop : validateNum(-parseFloat(borderTopWidth))
width.value = target.offsetWidth
height.value = target.offsetHeight
// Get border radius
const {
borderTopLeftRadius,
borderTopRightRadius,
borderBottomLeftRadius,
borderBottomRightRadius,
} = nodeStyle
borderRadius.value = [
borderTopLeftRadius,
borderTopRightRadius,
borderBottomRightRadius,
borderBottomLeftRadius,
].map(radius => validateNum(parseFloat(radius)))
}
// Add resize observer to follow size
let resizeObserver: ResizeObserver
let rafId: number
let timeoutId: any
let onClick: (e: MouseEvent) => void
const clear = () => {
clearTimeout(timeoutId)
wrapperRaf.cancel(rafId)
resizeObserver?.disconnect()
const { target } = props
target?.removeEventListener('click', onClick, true)
}
const init = () => {
clear()
const { target } = props
if (target) {
target?.removeEventListener('click', onClick, true)
if (!target || target.nodeType !== 1) {
return
}
// Click handler
onClick = (e: MouseEvent) => {
// Fix radio button click twice
if (
(e.target as HTMLElement).tagName === 'INPUT' ||
!isVisible(e.target as HTMLElement) ||
// No need wave
!target.getAttribute ||
target.getAttribute('disabled') ||
(target as HTMLInputElement).disabled ||
target.className.includes('disabled') ||
target.className.includes('-leave')
) {
return
}
show.value = false
nextTick(() => {
show.value = true
})
}
// Bind events
target.addEventListener('click', onClick, true)
// We need delay to check position here
// since UI may change after click
rafId = wrapperRaf(() => {
syncPos()
})
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(syncPos)
resizeObserver.observe(target)
}
}
}
onMounted(() => {
nextTick(() => {
init()
})
})
watch(
() => props.target,
() => {
init()
},
{
flush: 'post',
},
)
onBeforeUnmount(() => {
clear()
})
const onTransitionend = (e: TransitionEvent) => {
if (e.propertyName === 'opacity') {
show.value = false
}
}
// Auto hide wave after 5 seconds, transition end not work
watch(show, () => {
clearTimeout(timeoutId)
if (show.value) {
timeoutId = setTimeout(() => {
show.value = false
}, 5000)
}
})
const waveStyle = computed(() => {
const style = {
left: `${left.value}px`,
top: `${top.value}px`,
width: `${width.value}px`,
height: `${height.value}px`,
borderRadius: borderRadius.value.map(radius => `${radius}px`).join(' '),
}
if (color.value) {
style['--wave-color'] = color.value
}
return style
})
</script>

View File

@ -0,0 +1,12 @@
import { App, Plugin } from 'vue'
import Wave from './Wave.vue'
import './style/index.css'
export { default as Wave } from './Wave.vue'
/* istanbul ignore next */
Wave.install = function (app: App) {
app.component('AWave', Wave)
return app
}
export default Wave as typeof Wave & Plugin

View File

@ -0,0 +1,21 @@
@reference '../../../style/tailwind.css';
.ant-wave-motion {
@apply absolute;
@apply bg-transparent;
@apply pointer-events-none;
@apply box-border;
@apply text-accent;
@apply opacity-20;
box-shadow: 0 0 0 0 currentcolor;
&:where(.ant-wave-motion-appear) {
transition:
box-shadow 0.4s cubic-bezier(0.08, 0.82, 0.17, 1),
opacity 2s cubic-bezier(0.08, 0.82, 0.17, 1);
&:where(.ant-wave-motion-appear-active) {
@apply opacity-0;
box-shadow: 0 0 0 6px currentcolor;
}
}
}

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

@ -0,0 +1,25 @@
export default (element: HTMLElement | SVGGraphicsElement): boolean => {
if (!element) {
return false;
}
if ((element as HTMLElement).offsetParent) {
return true;
}
if ((element as SVGGraphicsElement).getBBox) {
const box = (element as SVGGraphicsElement).getBBox();
if (box.width || box.height) {
return true;
}
}
if ((element as HTMLElement).getBoundingClientRect) {
const box = (element as HTMLElement).getBoundingClientRect();
if (box.width || box.height) {
return true;
}
}
return false;
};

View File

@ -0,0 +1,47 @@
let raf = (callback: FrameRequestCallback) => setTimeout(callback, 16) as any;
let caf = (num: number) => clearTimeout(num);
if (typeof window !== 'undefined' && 'requestAnimationFrame' in window) {
raf = (callback: FrameRequestCallback) => window.requestAnimationFrame(callback);
caf = (handle: number) => window.cancelAnimationFrame(handle);
}
let rafUUID = 0;
const rafIds = new Map<number, number>();
function cleanup(id: number) {
rafIds.delete(id);
}
export default function wrapperRaf(callback: () => void, times = 1): number {
rafUUID += 1;
const id = rafUUID;
function callRef(leftTimes: number) {
if (leftTimes === 0) {
// Clean up
cleanup(id);
// Trigger
callback();
} else {
// Next raf
const realId = raf(() => {
callRef(leftTimes - 1);
});
// Bind real raf id
rafIds.set(id, realId);
}
}
callRef(times);
return id;
}
wrapperRaf.cancel = (id: number) => {
const realId = rafIds.get(id);
cleanup(realId);
return caf(realId);
};