better code: IconBtn as special case of Btn

Massimo Melina committed Mar 2, 2024 at 00:12 UTC 7b09e0fd7e4e76cbab3ae180f27ab07fd49d9abe
2 files changed +44 -93
admin/src/FileField.ts
-1
@@ -2,7 +2,6 @@
2
3 import { FieldProps, StringField } from '@hfs/mui-grid-form'
4 import { createElement as h } from 'react'
5 -import { InputAdornment } from '@mui/material'
5 import { Eject } from '@mui/icons-material'
6 import { IconBtn, useBreakpoint } from './mui'
7 import { newDialog } from '@hfs/shared'
admin/src/mui.ts
+44 -92
@@ -3,17 +3,15 @@
3
4 import { PauseCircle, PlayCircle, Refresh, SvgIconComponent } from '@mui/icons-material'
5 import { SxProps } from '@mui/system'
6 -import {
7 - createElement as h, FC, forwardRef, Fragment, ReactElement, ReactNode, useCallback, useEffect, useRef,
8 - ForwardedRef, useState, useMemo
9 -} from 'react'
6 +import { createElement as h, forwardRef, Fragment, ReactElement, ReactNode, useCallback, useEffect, useRef,
7 + ForwardedRef, useState, useMemo } 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, restartAnimation, useBatch, useStateMounted } from '@hfs/shared'
12 import { Promisable, StringField } from '@hfs/mui-grid-form'
13 import { alertDialog, confirmDialog, toast } from './dialog'
16 -import { LoadingButton, LoadingButtonProps } from '@mui/lab'
14 +import { LoadingButton } from '@mui/lab'
15 import { Link as RouterLink } from 'react-router-dom'
16 import { SvgIconProps } from '@mui/material/SvgIcon/SvgIcon'
17 import _ from 'lodash'
@@ -119,116 +117,70 @@ function useRefPass<T=unknown>(forwarded: ForwardedRef<any>) {
117 })
118 }
119
122 -interface IconBtnProps extends Omit<IconButtonProps, 'disabled'|'title'|'onClick'> {
123 - title?: ReactNode
124 - icon: SvgIconComponent
125 - disabled?: boolean | string
126 - progress?: boolean | number
127 - link?: string
128 - confirm?: string
129 - doneMessage?: boolean | string // displayed only if the result of onClick !== false
130 - tooltipProps?: Partial<TooltipProps>
131 - modified?: boolean
132 - onClick?: (...args: Parameters<NonNullable<IconButtonProps['onClick']>>) => Promisable<any>
133 -}
120 +interface IconBtnProps extends Omit<BtnProps, 'icon' | 'children'> { icon: SvgIconComponent }
121 +export const IconBtn = forwardRef((props: IconBtnProps, ref: ForwardedRef<HTMLButtonElement>) =>
122 + h(Btn, { ref, ...props }))
123
135 -export const IconBtn = forwardRef(({ title, icon, onClick, disabled, progress, link, tooltipProps, confirm, doneMessage, sx, modified, ...rest }: IconBtnProps, forwarded: ForwardedRef<HTMLButtonElement>) => {
136 - const [loading, setLoading] = useStateMounted(false)
137 - if (typeof disabled === 'string')
138 - title = disabled
139 - if (link)
140 - onClick = () => window.open(link)
141 - disabled = loading || Boolean(progress) || disabled === undefined ? undefined : Boolean(disabled)
142 - const ref = useRefPass<HTMLButtonElement>(forwarded)
143 - let ret: ReturnType<FC> = h(IconButton, {
144 - ref,
145 - 'aria-hidden': disabled,
146 - ..._.merge(modifiedProps(modified),
147 - { disabled, sx: { height: 'fit-content', ...sx } },
148 - rest),
149 - async onClick(...args) {
150 - if (confirm && !await confirmDialog(confirm)) return
151 - const ret = onClick?.apply(this,args)
152 - if (ret && ret instanceof Promise) {
153 - setLoading(true)
154 - ret.then(x => x !== false && execDoneMessage(doneMessage, ref.current), alertDialog)
155 - .finally(()=> setLoading(false))
156 - }
157 - }
158 - },
159 - (progress || loading) && progress !== false // false is also useful to inhibit behavior with loading
160 - && h(CircularProgress, {
161 - ...(typeof progress === 'number' ? { value: progress*100, variant: 'determinate' } : null),
162 - style: { position:'absolute', top: '10%', left: '10%', width: '80%', height: '80%' }
163 - }),
164 - h(icon)
165 - )
166 - const aria = rest['aria-label'] ?? (_.isString(title) ? title : undefined)
167 - if (title) {
168 - if (disabled)
169 - ret = h('span', { role: 'button', 'aria-label': aria, 'aria-disabled': disabled }, ret)
170 - ret = hTooltip(title, aria, ret, tooltipProps)
171 - }
172 - return ret
173 -})
174 -
175 -interface BtnProps extends Omit<LoadingButtonProps,'disabled'|'title'|'onClick'> {
124 +interface BtnProps extends Omit<ButtonProps & IconButtonProps,'disabled'|'title'|'onClick'> {
125 icon?: SvgIconComponent
126 title?: ReactNode
127 disabled?: boolean | string
128 progress?: boolean | number
129 link?: string
130 confirm?: boolean | ReactNode
182 - labelFrom?: Breakpoint
131 + labelFrom?: Breakpoint | false
132 doneMessage?: boolean | string // displayed only if the result of onClick !== false
184 - tooltipProps?: TooltipProps
133 + tooltipProps?: Partial<TooltipProps>
134 + modified?: boolean
135 + loading?: boolean
136 onClick?: (...args: Parameters<NonNullable<ButtonProps['onClick']>>) => Promisable<any>
137 }
138
188 -export const Btn = forwardRef(({ icon, title, onClick, disabled, progress, link, tooltipProps, confirm, doneMessage, labelFrom, children, ...rest }: BtnProps, ref: any) => {
189 - const [loading, setLoading] = useStateMounted(false)
190 - if (typeof disabled === 'string') {
139 +export const Btn = forwardRef(({ icon, title, onClick, disabled, progress, link, tooltipProps, confirm, doneMessage, labelFrom, children, modified, loading, ...rest }: BtnProps, forwarded: ForwardedRef<HTMLButtonElement>) => {
140 + const [loadingState, setLoadingState] = useStateMounted(false)
141 + if (typeof disabled === 'string')
142 title = disabled
192 - disabled = true
193 - }
143 + disabled = loadingState || Boolean(progress) || disabled === undefined ? undefined : Boolean(disabled)
144 if (link)
145 onClick = () => window.open(link)
146 const showLabel = useBreakpoint(labelFrom || 'xs')
197 - let ret: ReturnType<FC> = h(LoadingButton, {
147 + const ref = useRefPass<HTMLButtonElement>(forwarded)
148 + const common = _.merge(modifiedProps(modified), {
149 ref,
199 - variant: 'contained',
200 - startIcon: icon && h(icon),
201 - loading: Boolean(loading || progress),
202 - loadingPosition: icon && 'start',
203 - loadingIndicator: typeof progress !== 'number' ? undefined
204 - : h(CircularProgress, { size: '1rem', value: progress*100, variant: 'determinate' }),
150 disabled,
151 'aria-hidden': disabled,
207 - ...rest,
208 - children: showLabel && children,
209 - sx: {
210 - ...rest.sx,
211 - ...!showLabel && {
212 - minWidth: 'auto',
213 - px: 1,
214 - py: '7px',
215 - '& span': { mx:0 },
216 - }
217 - },
218 - async onClick(...args) {
152 + async onClick(...args: any[]) {
153 if (confirm && !await confirmDialog(confirm === true ? "Are you sure?" : confirm)) return
220 - const ret = onClick?.apply(this,args)
154 + const ret = onClick?.apply(this, args as any)
155 if (ret && ret instanceof Promise) {
222 - setLoading(true)
156 + setLoadingState(true)
157 ret.then(x => x !== false && execDoneMessage(doneMessage), alertDialog)
224 - .finally(()=> setLoading(false))
158 + .finally(()=> setLoadingState(false))
159 }
226 - }
227 - })
160 + },
161 + } as const, rest)
162 + let ret: ReactElement = children || !icon ? h(LoadingButton, _.merge({
163 + variant: 'contained',
164 + startIcon: icon && h(icon),
165 + loading: Boolean(loading || loadingState || progress),
166 + loadingPosition: icon && 'start',
167 + loadingIndicator: typeof progress !== 'number' ? undefined
168 + : h(CircularProgress, { size: '1rem', value: progress*100, variant: 'determinate' }),
169 + children: showLabel && children,
170 + } as const, common, !showLabel && { sx: { minWidth: 'auto', px: 1, py: '7px', '& span': { mx:0 }, } }))
171 + : h(IconButton, _.merge(common, { sx: { height: 'fit-content' } }),
172 + (progress || loadingState) && progress !== false // false is also useful to inhibit behavior with loading
173 + && h(CircularProgress, {
174 + ...(typeof progress === 'number' ? { value: progress*100, variant: 'determinate' } : null),
175 + style: { position:'absolute', top: '10%', left: '10%', width: '80%', height: '80%' }
176 + }),
177 + h(icon)
178 + )
179 +
180 const aria = rest['aria-label'] ?? (_.isString(title) ? title : undefined)
181 if (title) {
230 - // having this span-wrapper conditioned by if(disabled) is causing a strange (harmless?) warning by mui-popper as soon as you click, so we don't
231 - ret = h('span', { role: 'button', 'aria-label': aria, 'aria-disabled': disabled }, ret)
182 + if (disabled) // having this span-wrapper conditioned by if(disabled) is causing a (harmless?) warning by mui-popper if the element becomes disabled after you click (file cut button does), but otherwise we have a bigger problem with a11y, with this being seen as a button
183 + ret = h('span', { role: 'button', 'aria-label': aria, 'aria-disabled': disabled }, ret)
184 ret = hTooltip(title, aria, ret, tooltipProps)
185 }
186 return ret
@@ -280,7 +232,7 @@ export function useToggleButton(onTitle: string, offTitle: undefined | string, i
232 const props = iconBtn(state)
233 const el = useMemo(() => h(IconBtn, {
234 size: 'small',
283 - color: state ? 'primary' : 'default',
235 + color: state ? 'primary' : undefined,
236 title: state || offTitle === undefined ? onTitle : offTitle,
237 'aria-label': onTitle, // aria should be steady, and rely on aria-pressed
238 'aria-pressed': state,