Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 23ebeea

Browse files
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)

AltStyle によって変換されたページ (->オリジナル) /