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