Skip to content

Commit 23ebeea

Browse files
committed
feat: add wave animation
1 parent d7ca354 commit 23ebeea

File tree

7 files changed

+343
-3
lines changed

7 files changed

+343
-3
lines changed

packages/ui/src/components/button/Button.vue

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
<template>
2-
<button :class="rootClass" @click="handleClick" :disabled="disabled" :style="cssVars">
2+
<button
3+
ref="buttonRef"
4+
:class="rootClass"
5+
@click="handleClick"
6+
:disabled="disabled"
7+
:style="cssVars"
8+
>
9+
<Wave :target="buttonRef" />
310
<slot name="loading">
411
<LoadingOutlined v-if="loading" />
512
</slot>
@@ -9,15 +16,16 @@
916
</template>
1017

1118
<script setup lang="ts">
12-
import { computed, Fragment } from 'vue'
19+
import { computed, ref } from 'vue'
1320
import { buttonProps, buttonEmits, ButtonSlots } from './meta'
1421
import { getCssVarColor } from '@/utils/colorAlgorithm'
1522
import { useThemeInject } from '../theme/hook'
1623
import LoadingOutlined from '@ant-design/icons-vue/LoadingOutlined'
1724
import { defaultColor } from '../theme/meta'
25+
import { Wave } from '../wave'
1826
1927
const props = defineProps(buttonProps)
20-
28+
const buttonRef = ref<HTMLButtonElement | null>(null)
2129
const emit = defineEmits(buttonEmits)
2230
defineSlots<ButtonSlots>()
2331
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
<template>
2+
<div v-if="show && !disabled" ref="divRef" style="position: absolute; left: 0; top: 0">
3+
<Transition
4+
appear
5+
name="ant-wave-motion"
6+
appearFromClass="ant-wave-motion-appear"
7+
appearActiveClass="ant-wave-motion-appear"
8+
appearToClass="ant-wave-motion-appear ant-wave-motion-appear-active"
9+
>
10+
<div
11+
v-if="show"
12+
:style="waveStyle"
13+
class="ant-wave-motion"
14+
@transitionend="onTransitionend"
15+
/>
16+
</Transition>
17+
</div>
18+
</template>
19+
<script setup lang="ts">
20+
import wrapperRaf from '@/utils/raf'
21+
import {
22+
computed,
23+
nextTick,
24+
onBeforeUnmount,
25+
onMounted,
26+
ref,
27+
shallowRef,
28+
Transition,
29+
watch,
30+
} from 'vue'
31+
import { getTargetWaveColor } from './util'
32+
import isVisible from '@/utils/isVisible'
33+
34+
const props = defineProps<{
35+
disabled?: boolean
36+
target: HTMLElement
37+
}>()
38+
39+
const divRef = shallowRef<HTMLDivElement | null>(null)
40+
41+
const show = defineModel<boolean>('show')
42+
43+
const color = ref<string | null>(null)
44+
const borderRadius = ref<number[]>([])
45+
const left = ref(0)
46+
const top = ref(0)
47+
const width = ref(0)
48+
const height = ref(0)
49+
50+
function validateNum(value: number) {
51+
return Number.isNaN(value) ? 0 : value
52+
}
53+
function syncPos() {
54+
const { target } = props
55+
if (!target) {
56+
return
57+
}
58+
const nodeStyle = getComputedStyle(target)
59+
60+
// Get wave color from target
61+
color.value = getTargetWaveColor(target)
62+
63+
const isStatic = nodeStyle.position === 'static'
64+
65+
// Rect
66+
const { borderLeftWidth, borderTopWidth } = nodeStyle
67+
left.value = isStatic ? target.offsetLeft : validateNum(-parseFloat(borderLeftWidth))
68+
top.value = isStatic ? target.offsetTop : validateNum(-parseFloat(borderTopWidth))
69+
width.value = target.offsetWidth
70+
height.value = target.offsetHeight
71+
72+
// Get border radius
73+
const {
74+
borderTopLeftRadius,
75+
borderTopRightRadius,
76+
borderBottomLeftRadius,
77+
borderBottomRightRadius,
78+
} = nodeStyle
79+
80+
borderRadius.value = [
81+
borderTopLeftRadius,
82+
borderTopRightRadius,
83+
borderBottomRightRadius,
84+
borderBottomLeftRadius,
85+
].map(radius => validateNum(parseFloat(radius)))
86+
}
87+
// Add resize observer to follow size
88+
let resizeObserver: ResizeObserver
89+
let rafId: number
90+
let timeoutId: any
91+
let onClick: (e: MouseEvent) => void
92+
const clear = () => {
93+
clearTimeout(timeoutId)
94+
wrapperRaf.cancel(rafId)
95+
resizeObserver?.disconnect()
96+
const { target } = props
97+
target?.removeEventListener('click', onClick, true)
98+
}
99+
100+
const init = () => {
101+
clear()
102+
const { target } = props
103+
if (target) {
104+
target?.removeEventListener('click', onClick, true)
105+
if (!target || target.nodeType !== 1) {
106+
return
107+
}
108+
// Click handler
109+
onClick = (e: MouseEvent) => {
110+
// Fix radio button click twice
111+
if (
112+
(e.target as HTMLElement).tagName === 'INPUT' ||
113+
!isVisible(e.target as HTMLElement) ||
114+
// No need wave
115+
!target.getAttribute ||
116+
target.getAttribute('disabled') ||
117+
(target as HTMLInputElement).disabled ||
118+
target.className.includes('disabled') ||
119+
target.className.includes('-leave')
120+
) {
121+
return
122+
}
123+
show.value = false
124+
nextTick(() => {
125+
show.value = true
126+
})
127+
}
128+
129+
// Bind events
130+
target.addEventListener('click', onClick, true)
131+
// We need delay to check position here
132+
// since UI may change after click
133+
rafId = wrapperRaf(() => {
134+
syncPos()
135+
})
136+
137+
if (typeof ResizeObserver !== 'undefined') {
138+
resizeObserver = new ResizeObserver(syncPos)
139+
resizeObserver.observe(target)
140+
}
141+
}
142+
}
143+
onMounted(() => {
144+
nextTick(() => {
145+
init()
146+
})
147+
})
148+
149+
watch(
150+
() => props.target,
151+
() => {
152+
init()
153+
},
154+
{
155+
flush: 'post',
156+
},
157+
)
158+
159+
onBeforeUnmount(() => {
160+
clear()
161+
})
162+
163+
const onTransitionend = (e: TransitionEvent) => {
164+
if (e.propertyName === 'opacity') {
165+
show.value = false
166+
}
167+
}
168+
169+
// Auto hide wave after 5 seconds, transition end not work
170+
watch(show, () => {
171+
clearTimeout(timeoutId)
172+
if (show.value) {
173+
timeoutId = setTimeout(() => {
174+
show.value = false
175+
}, 5000)
176+
}
177+
})
178+
179+
const waveStyle = computed(() => {
180+
const style = {
181+
left: `${left.value}px`,
182+
top: `${top.value}px`,
183+
width: `${width.value}px`,
184+
height: `${height.value}px`,
185+
borderRadius: borderRadius.value.map(radius => `${radius}px`).join(' '),
186+
}
187+
if (color.value) {
188+
style['--wave-color'] = color.value
189+
}
190+
return style
191+
})
192+
</script>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { App, Plugin } from 'vue'
2+
import Wave from './Wave.vue'
3+
import './style/index.css'
4+
5+
export { default as Wave } from './Wave.vue'
6+
7+
/* istanbul ignore next */
8+
Wave.install = function (app: App) {
9+
app.component('AWave', Wave)
10+
return app
11+
}
12+
export default Wave as typeof Wave & Plugin
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
@reference '../../../style/tailwind.css';
2+
3+
.ant-wave-motion {
4+
@apply absolute;
5+
@apply bg-transparent;
6+
@apply pointer-events-none;
7+
@apply box-border;
8+
@apply text-accent;
9+
@apply opacity-20;
10+
box-shadow: 0 0 0 0 currentcolor;
11+
12+
&:where(.ant-wave-motion-appear) {
13+
transition:
14+
box-shadow 0.4s cubic-bezier(0.08, 0.82, 0.17, 1),
15+
opacity 2s cubic-bezier(0.08, 0.82, 0.17, 1);
16+
&:where(.ant-wave-motion-appear-active) {
17+
@apply opacity-0;
18+
box-shadow: 0 0 0 6px currentcolor;
19+
}
20+
}
21+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
export function isNotGrey(color: string) {
2+
// eslint-disable-next-line no-useless-escape
3+
const match = (color || '').match(/rgba?\((\d*), (\d*), (\d*)(, [\d.]*)?\)/);
4+
if (match && match[1] && match[2] && match[3]) {
5+
return !(match[1] === match[2] && match[2] === match[3]);
6+
}
7+
return true;
8+
}
9+
10+
export function isValidWaveColor(color: string) {
11+
return (
12+
color &&
13+
color !== '#fff' &&
14+
color !== '#ffffff' &&
15+
color !== 'rgb(255, 255, 255)' &&
16+
color !== 'rgba(255, 255, 255, 1)' &&
17+
isNotGrey(color) &&
18+
!/rgba\((?:\d*, ){3}0\)/.test(color) && // any transparent rgba color
19+
color !== 'transparent'
20+
);
21+
}
22+
23+
export function getTargetWaveColor(node: HTMLElement) {
24+
const { borderTopColor, borderColor, backgroundColor } = getComputedStyle(node);
25+
if (isValidWaveColor(borderTopColor)) {
26+
return borderTopColor;
27+
}
28+
if (isValidWaveColor(borderColor)) {
29+
return borderColor;
30+
}
31+
if (isValidWaveColor(backgroundColor)) {
32+
return backgroundColor;
33+
}
34+
return null;
35+
}

packages/ui/src/utils/isVisible.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export default (element: HTMLElement | SVGGraphicsElement): boolean => {
2+
if (!element) {
3+
return false;
4+
}
5+
6+
if ((element as HTMLElement).offsetParent) {
7+
return true;
8+
}
9+
10+
if ((element as SVGGraphicsElement).getBBox) {
11+
const box = (element as SVGGraphicsElement).getBBox();
12+
if (box.width || box.height) {
13+
return true;
14+
}
15+
}
16+
17+
if ((element as HTMLElement).getBoundingClientRect) {
18+
const box = (element as HTMLElement).getBoundingClientRect();
19+
if (box.width || box.height) {
20+
return true;
21+
}
22+
}
23+
24+
return false;
25+
};

packages/ui/src/utils/raf.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
let raf = (callback: FrameRequestCallback) => setTimeout(callback, 16) as any;
2+
let caf = (num: number) => clearTimeout(num);
3+
4+
if (typeof window !== 'undefined' && 'requestAnimationFrame' in window) {
5+
raf = (callback: FrameRequestCallback) => window.requestAnimationFrame(callback);
6+
caf = (handle: number) => window.cancelAnimationFrame(handle);
7+
}
8+
9+
let rafUUID = 0;
10+
const rafIds = new Map<number, number>();
11+
12+
function cleanup(id: number) {
13+
rafIds.delete(id);
14+
}
15+
16+
export default function wrapperRaf(callback: () => void, times = 1): number {
17+
rafUUID += 1;
18+
const id = rafUUID;
19+
20+
function callRef(leftTimes: number) {
21+
if (leftTimes === 0) {
22+
// Clean up
23+
cleanup(id);
24+
25+
// Trigger
26+
callback();
27+
} else {
28+
// Next raf
29+
const realId = raf(() => {
30+
callRef(leftTimes - 1);
31+
});
32+
33+
// Bind real raf id
34+
rafIds.set(id, realId);
35+
}
36+
}
37+
38+
callRef(times);
39+
40+
return id;
41+
}
42+
43+
wrapperRaf.cancel = (id: number) => {
44+
const realId = rafIds.get(id);
45+
cleanup(realId);
46+
return caf(realId);
47+
};

0 commit comments

Comments
 (0)