feat: add wave animation
parent
d7ca354b87
commit
23ebeea4b7
|
@ -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>()
|
||||
|
||||
|
|
|
@ -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