diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx index a95537df2a..1307c0fc9f 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx @@ -1,5 +1,6 @@ /** @file A styled button. */ import { + memo, useLayoutEffect, useRef, useState, @@ -283,215 +284,214 @@ export const BUTTON_STYLES = tv({ }) /** A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */ -export const Button = forwardRef(function Button( - props: ButtonProps, - ref: ForwardedRef, -) { - const { - className, - contentClassName, - children, - variant, - icon, - loading = false, - isActive, - showIconOnHover, - iconPosition, - size, - fullWidth, - rounded, - tooltip, - tooltipPlacement, - testId, - loaderPosition = 'full', - extraClickZone: extraClickZoneProp, - onPress = () => {}, - variants = BUTTON_STYLES, - ...ariaProps - } = props - const focusChildProps = useFocusChild() +export const Button = memo( + forwardRef(function Button(props: ButtonProps, ref: ForwardedRef) { + const { + className, + contentClassName, + children, + variant, + icon, + loading = false, + isActive, + showIconOnHover, + iconPosition, + size, + fullWidth, + rounded, + tooltip, + tooltipPlacement, + testId, + loaderPosition = 'full', + extraClickZone: extraClickZoneProp, + onPress = () => {}, + variants = BUTTON_STYLES, + ...ariaProps + } = props + const focusChildProps = useFocusChild() - const [implicitlyLoading, setImplicitlyLoading] = useState(false) - const contentRef = useRef(null) - const loaderRef = useRef(null) + const [implicitlyLoading, setImplicitlyLoading] = useState(false) + const contentRef = useRef(null) + const loaderRef = useRef(null) - const isLink = ariaProps.href != null + const isLink = ariaProps.href != null - const Tag = isLink ? aria.Link : aria.Button + const Tag = isLink ? aria.Link : aria.Button - const goodDefaults = { - ...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }), - 'data-testid': testId, - } - - const isIconOnly = (children == null || children === '' || children === false) && icon != null - const shouldShowTooltip = (() => { - if (tooltip === false) { - return false - } else if (isIconOnly) { - return true - } else { - return tooltip != null + const goodDefaults = { + ...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }), + 'data-testid': testId, } - })() - const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null - const isLoading = loading || implicitlyLoading - const isDisabled = props.isDisabled ?? isLoading - const shouldUseVisualTooltip = shouldShowTooltip && isDisabled - - useLayoutEffect(() => { - const delay = 350 - - if (isLoading) { - const loaderAnimation = loaderRef.current?.animate( - [{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }], - { duration: delay, easing: 'linear', delay: 0, fill: 'forwards' }, - ) - const contentAnimation = - loaderPosition !== 'full' ? null : ( - contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], { - duration: 0, - easing: 'linear', - delay, - fill: 'forwards', - }) - ) - - return () => { - loaderAnimation?.cancel() - contentAnimation?.cancel() - } - } else { - return () => {} - } - }, [isLoading, loaderPosition]) - - const handlePress = (event: aria.PressEvent): void => { - if (!isDisabled) { - const result = onPress?.(event) - - if (result instanceof Promise) { - setImplicitlyLoading(true) - void result.finally(() => { - setImplicitlyLoading(false) - }) - } - } - } - - const styles = variants({ - isDisabled, - isActive, - loading: isLoading, - fullWidth, - size, - rounded, - variant, - iconPosition, - showIconOnHover, - extraClickZone: extraClickZoneProp, - iconOnly: isIconOnly, - }) - - const childrenFactory = (render: aria.ButtonRenderProps | aria.LinkRenderProps): ReactNode => { - const iconComponent = (() => { - if (isLoading && loaderPosition === 'icon') { - return ( - - - - ) - } else if (icon == null) { - return null + const isIconOnly = (children == null || children === '' || children === false) && icon != null + const shouldShowTooltip = (() => { + if (tooltip === false) { + return false + } else if (isIconOnly) { + return true } else { - /* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */ - const actualIcon = typeof icon === 'function' ? icon(render) : icon - - if (typeof actualIcon === 'string') { - return - } else { - return {actualIcon} - } + return tooltip != null } })() - // Icon only button - if (isIconOnly) { - return {iconComponent} - } else { - // Default button - return ( - <> - {iconComponent} - - {/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */} - {typeof children === 'function' ? children(render) : children} - - - ) + const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null + + const isLoading = loading || implicitlyLoading + const isDisabled = props.isDisabled ?? isLoading + const shouldUseVisualTooltip = shouldShowTooltip && isDisabled + + useLayoutEffect(() => { + const delay = 350 + + if (isLoading) { + const loaderAnimation = loaderRef.current?.animate( + [{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }], + { duration: delay, easing: 'linear', delay: 0, fill: 'forwards' }, + ) + const contentAnimation = + loaderPosition !== 'full' ? null : ( + contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], { + duration: 0, + easing: 'linear', + delay, + fill: 'forwards', + }) + ) + + return () => { + loaderAnimation?.cancel() + contentAnimation?.cancel() + } + } else { + return () => {} + } + }, [isLoading, loaderPosition]) + + const handlePress = (event: aria.PressEvent): void => { + if (!isDisabled) { + const result = onPress?.(event) + + if (result instanceof Promise) { + setImplicitlyLoading(true) + void result.finally(() => { + setImplicitlyLoading(false) + }) + } + } } - } - const { tooltip: visualTooltip, targetProps } = useVisualTooltip({ - targetRef: contentRef, - children: tooltipElement, - isDisabled: !shouldUseVisualTooltip, - ...(tooltipPlacement && { overlayPositionProps: { placement: tooltipPlacement } }), - }) + const styles = variants({ + isDisabled, + isActive, + loading: isLoading, + fullWidth, + size, + rounded, + variant, + iconPosition, + showIconOnHover, + extraClickZone: extraClickZoneProp, + iconOnly: isIconOnly, + }) - const button = ( - ()(goodDefaults, ariaProps, focusChildProps, { - isDisabled, - // we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger - // onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered - onPressEnd: (e) => { - if (!isDisabled) { - handlePress(e) - } - }, - className: aria.composeRenderProps(className, (classNames, states) => - styles.base({ className: classNames, ...states }), - ), - })} - > - {(render: aria.ButtonRenderProps | aria.LinkRenderProps) => ( - - - {} - {childrenFactory(render)} - - - {isLoading && loaderPosition === 'full' && ( - + const childrenFactory = (render: aria.ButtonRenderProps | aria.LinkRenderProps): ReactNode => { + const iconComponent = (() => { + if (isLoading && loaderPosition === 'icon') { + return ( + - )} - - )} - - ) + ) + } else if (icon == null) { + return null + } else { + /* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */ + const actualIcon = typeof icon === 'function' ? icon(render) : icon - return ( - tooltipElement == null ? button - : shouldUseVisualTooltip ? - <> - {button} - {visualTooltip} - - : - {button} + if (typeof actualIcon === 'string') { + return + } else { + return {actualIcon} + } + } + })() + // Icon only button + if (isIconOnly) { + return {iconComponent} + } else { + // Default button + return ( + <> + {iconComponent} + + {/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */} + {typeof children === 'function' ? children(render) : children} + + + ) + } + } - - {tooltipElement} - - - ) -}) + const { tooltip: visualTooltip, targetProps } = useVisualTooltip({ + targetRef: contentRef, + children: tooltipElement, + isDisabled: !shouldUseVisualTooltip, + ...(tooltipPlacement && { overlayPositionProps: { placement: tooltipPlacement } }), + }) + + const button = ( + ()(goodDefaults, ariaProps, focusChildProps, { + isDisabled, + // we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger + // onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered + onPressEnd: (e) => { + if (!isDisabled) { + handlePress(e) + } + }, + className: aria.composeRenderProps(className, (classNames, states) => + styles.base({ className: classNames, ...states }), + ), + })} + > + {(render: aria.ButtonRenderProps | aria.LinkRenderProps) => ( + + + {} + {childrenFactory(render)} + + + {isLoading && loaderPosition === 'full' && ( + + + + )} + + )} + + ) + + return ( + tooltipElement == null ? button + : shouldUseVisualTooltip ? + <> + {button} + {visualTooltip} + + : + {button} + + + {tooltipElement} + + + ) + }), +)