admin: success animation for icon buttons with async action
Massimo Melina committed
Feb 21, 2024 at 11:37 UTC
fcc10d4067334d8f073f4aa911dcc6c11b01fd36
3 files changed
+34
-8
admin/src/index.scss
+8
-1
@@ -1,6 +1,9 @@
1
@use '../../shared/main';
2
3
-:root { height: 100dvh; }
3
+:root {
4
+ height: 100dvh;
5
+ --success: #5c5;
6
+}
7
body {
8
margin: 0;
9
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
@@ -68,3 +71,7 @@ h2.MuiDialogTitle-root { /* less padding */
71
0% {opacity: 1}
72
50% {opacity: 0.2}
73
}
74
+@keyframes success {
75
+ 50% { transform: scale(1.5); color: var(--success); }
76
+ 100% { transform: inherit; color: inherit; }
77
+}
admin/src/mui.ts
+24
-6
@@ -3,12 +3,12 @@
3
4
import { PauseCircle, PlayCircle, Refresh, SvgIconComponent } from '@mui/icons-material'
5
import { SxProps } from '@mui/system'
6
-import { createElement as h, FC, forwardRef, Fragment, ReactElement, ReactNode, useCallback, useEffect,
7
- useState } from 'react'
6
+import { createElement as h, FC, forwardRef, Fragment, ReactElement, ReactNode, useCallback, useEffect, useRef,
7
+ ForwardedRef, useState } from 'react'
8
import { Box, BoxProps, Breakpoint, ButtonProps, CircularProgress, IconButton, IconButtonProps, Link, LinkProps,
9
Tooltip, TooltipProps, useMediaQuery } from '@mui/material'
10
import { formatPerc, isIpLan, isIpLocalHost, prefix, WIKI_URL } from '../../src/cross'
11
-import { dontBotherWithKeys, useBatch, useStateMounted } from '@hfs/shared'
11
+import { dontBotherWithKeys, restartAnimation, useBatch, useStateMounted } from '@hfs/shared'
12
import { Promisable, StringField } from '@hfs/mui-grid-form'
13
import { alertDialog, confirmDialog, toast } from './dialog'
14
import { LoadingButton, LoadingButtonProps } from '@mui/lab'
@@ -108,6 +108,20 @@ export function modifiedProps(modified: boolean) {
108
return modified ? { sx: { outline: '2px solid' } } : undefined
109
}
110
111
+function useRefPass<T=unknown>(forwarded: ForwardedRef<any>) {
112
+ const ref = useRef<T | null>(null)
113
+ return Object.assign(ref, {
114
+ pass(el: T){
115
+ ref.current = el
116
+ if (_.isFunction(forwarded))
117
+ forwarded(el)
118
+ else if (forwarded)
119
+ forwarded.current = el
120
+ },
121
+
122
+ })
123
+}
124
+
125
interface IconBtnProps extends Omit<IconButtonProps, 'disabled'|'title'|'onClick'> {
126
title?: ReactNode
127
icon: SvgIconComponent
@@ -121,13 +135,14 @@ interface IconBtnProps extends Omit<IconButtonProps, 'disabled'|'title'|'onClick
135
onClick?: (...args: Parameters<NonNullable<IconButtonProps['onClick']>>) => Promisable<any>
136
}
137
124
-export const IconBtn = forwardRef(({ title, icon, onClick, disabled, progress, link, tooltipProps, confirm, doneMessage, sx, modified, ...rest }: IconBtnProps, ref: any) => {
138
+export const IconBtn = forwardRef(({ title, icon, onClick, disabled, progress, link, tooltipProps, confirm, doneMessage, sx, modified, ...rest }: IconBtnProps, forwarded: ForwardedRef<HTMLButtonElement>) => {
139
const [loading, setLoading] = useStateMounted(false)
140
if (typeof disabled === 'string')
141
title = disabled
142
if (link)
143
onClick = () => window.open(link)
144
disabled = Boolean(loading || progress || disabled)
145
+ const ref = useRefPass<HTMLButtonElement>(forwarded)
146
let ret: ReturnType<FC> = h(IconButton, {
147
ref,
148
disabled,
@@ -138,7 +153,8 @@ export const IconBtn = forwardRef(({ title, icon, onClick, disabled, progress, l
153
const ret = onClick?.apply(this,args)
154
if (ret && ret instanceof Promise) {
155
setLoading(true)
141
- ret.then(x => x !== false && execDoneMessage(doneMessage), alertDialog).finally(()=> setLoading(false))
156
+ ret.then(x => x !== false && execDoneMessage(doneMessage, ref.current), alertDialog)
157
+ .finally(()=> setLoading(false))
158
}
159
}
160
},
@@ -216,7 +232,9 @@ export const Btn = forwardRef(({ icon, title, onClick, disabled, progress, link,
232
return ret
233
})
234
219
-function execDoneMessage(msg: boolean | string | undefined) {
235
+function execDoneMessage(msg: boolean | string | undefined, el?: HTMLElement | null) {
236
+ if (el)
237
+ restartAnimation(el, 'success .5s')
238
if (msg)
239
toast(msg === true ? "Operation completed" : msg, 'success')
240
}
shared/index.ts
+2
-1
@@ -31,7 +31,8 @@ export function domOn<K extends keyof WindowEventMap>(eventName: K, cb: (ev: Win
31
return () => target.removeEventListener(eventName, cb)
32
}
33
34
-export function restartAnimation(e: HTMLElement, animation: string) {
34
+export function restartAnimation(e: HTMLElement | null, animation: string) {
35
+ if (!e) return
36
e.style.animation = ''
37
void e.offsetWidth
38
e.style.animation = animation