feat: add wave animation
parent
d7ca354b87
commit
23ebeea4b7
|
@ -1,5 +1,12 @@
|
||||||
<template>
|
<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">
|
<slot name="loading">
|
||||||
<LoadingOutlined v-if="loading" />
|
<LoadingOutlined v-if="loading" />
|
||||||
</slot>
|
</slot>
|
||||||
|
@ -9,15 +16,16 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, Fragment } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { buttonProps, buttonEmits, ButtonSlots } from './meta'
|
import { buttonProps, buttonEmits, ButtonSlots } from './meta'
|
||||||
import { getCssVarColor } from '@/utils/colorAlgorithm'
|
import { getCssVarColor } from '@/utils/colorAlgorithm'
|
||||||
import { useThemeInject } from '../theme/hook'
|
import { useThemeInject } from '../theme/hook'
|
||||||
import LoadingOutlined from '@ant-design/icons-vue/LoadingOutlined'
|
import LoadingOutlined from '@ant-design/icons-vue/LoadingOutlined'
|
||||||
import { defaultColor } from '../theme/meta'
|
import { defaultColor } from '../theme/meta'
|
||||||
|
import { Wave } from '../wave'
|
||||||
|
|
||||||
const props = defineProps(buttonProps)
|
const props = defineProps(buttonProps)
|
||||||
|
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||||
const emit = defineEmits(buttonEmits)
|
const emit = defineEmits(buttonEmits)
|
||||||
defineSlots<ButtonSlots>()
|
defineSlots<ButtonSlots>()
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
|
@ -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);
|
||||||
|
};
|
Loading…
Reference in New Issue