Memoize Button

This commit is contained in:
somebody1234 2024-11-21 21:14:10 +10:00
parent 6eb7541657
commit 27efdfd257

View File

@ -1,5 +1,6 @@
/** @file A styled button. */ /** @file A styled button. */
import { import {
memo,
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState, 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. */ /** A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */
export const Button = forwardRef(function Button( export const Button = memo(
props: ButtonProps, forwardRef(function Button(props: ButtonProps, ref: ForwardedRef<HTMLButtonElement>) {
ref: ForwardedRef<HTMLButtonElement>, const {
) { className,
const { contentClassName,
className, children,
contentClassName, variant,
children, icon,
variant, loading = false,
icon, isActive,
loading = false, showIconOnHover,
isActive, iconPosition,
showIconOnHover, size,
iconPosition, fullWidth,
size, rounded,
fullWidth, tooltip,
rounded, tooltipPlacement,
tooltip, testId,
tooltipPlacement, loaderPosition = 'full',
testId, extraClickZone: extraClickZoneProp,
loaderPosition = 'full', onPress = () => {},
extraClickZone: extraClickZoneProp, variants = BUTTON_STYLES,
onPress = () => {}, ...ariaProps
variants = BUTTON_STYLES, } = props
...ariaProps const focusChildProps = useFocusChild()
} = props
const focusChildProps = useFocusChild()
const [implicitlyLoading, setImplicitlyLoading] = useState(false) const [implicitlyLoading, setImplicitlyLoading] = useState(false)
const contentRef = useRef<HTMLSpanElement>(null) const contentRef = useRef<HTMLSpanElement>(null)
const loaderRef = useRef<HTMLSpanElement>(null) const loaderRef = useRef<HTMLSpanElement>(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 = { const goodDefaults = {
...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }), ...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }),
'data-testid': testId, '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 tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null
const isLoading = loading || implicitlyLoading const isIconOnly = (children == null || children === '' || children === false) && icon != null
const isDisabled = props.isDisabled ?? isLoading const shouldShowTooltip = (() => {
const shouldUseVisualTooltip = shouldShowTooltip && isDisabled if (tooltip === false) {
return false
useLayoutEffect(() => { } else if (isIconOnly) {
const delay = 350 return true
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 (
<span className={styles.icon()}>
<StatelessSpinner state="loading-medium" size={16} />
</span>
)
} else if (icon == null) {
return null
} else { } else {
/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */ return tooltip != null
const actualIcon = typeof icon === 'function' ? icon(render) : icon
if (typeof actualIcon === 'string') {
return <SvgMask src={actualIcon} className={styles.icon()} />
} else {
return <span className={styles.icon()}>{actualIcon}</span>
}
} }
})() })()
// Icon only button const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null
if (isIconOnly) {
return <span className={styles.extraClickZone()}>{iconComponent}</span> const isLoading = loading || implicitlyLoading
} else { const isDisabled = props.isDisabled ?? isLoading
// Default button const shouldUseVisualTooltip = shouldShowTooltip && isDisabled
return (
<> useLayoutEffect(() => {
{iconComponent} const delay = 350
<span className={styles.text()}>
{/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */} if (isLoading) {
{typeof children === 'function' ? children(render) : children} const loaderAnimation = loaderRef.current?.animate(
</span> [{ 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({ const styles = variants({
targetRef: contentRef, isDisabled,
children: tooltipElement, isActive,
isDisabled: !shouldUseVisualTooltip, loading: isLoading,
...(tooltipPlacement && { overlayPositionProps: { placement: tooltipPlacement } }), fullWidth,
}) size,
rounded,
variant,
iconPosition,
showIconOnHover,
extraClickZone: extraClickZoneProp,
iconOnly: isIconOnly,
})
const button = ( const childrenFactory = (render: aria.ButtonRenderProps | aria.LinkRenderProps): ReactNode => {
<Tag const iconComponent = (() => {
// @ts-expect-error ts errors are expected here because we are merging props with different types if (isLoading && loaderPosition === 'icon') {
ref={ref} return (
// @ts-expect-error ts errors are expected here because we are merging props with different types <span className={styles.icon()}>
{...aria.mergeProps<aria.ButtonProps>()(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) => (
<span className={styles.wrapper()}>
<span
ref={contentRef}
className={styles.content({ className: contentClassName })}
{...targetProps}
>
{}
{childrenFactory(render)}
</span>
{isLoading && loaderPosition === 'full' && (
<span ref={loaderRef} className={styles.loader()}>
<StatelessSpinner state="loading-medium" size={16} /> <StatelessSpinner state="loading-medium" size={16} />
</span> </span>
)} )
</span> } else if (icon == null) {
)} return null
</Tag> } 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 ( if (typeof actualIcon === 'string') {
tooltipElement == null ? button return <SvgMask src={actualIcon} className={styles.icon()} />
: shouldUseVisualTooltip ? } else {
<> return <span className={styles.icon()}>{actualIcon}</span>
{button} }
{visualTooltip} }
</> })()
: <TooltipTrigger delay={0} closeDelay={0}> // Icon only button
{button} if (isIconOnly) {
return <span className={styles.extraClickZone()}>{iconComponent}</span>
} else {
// Default button
return (
<>
{iconComponent}
<span className={styles.text()}>
{/* @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}
</span>
</>
)
}
}
<Tooltip {...(tooltipPlacement != null ? { placement: tooltipPlacement } : {})}> const { tooltip: visualTooltip, targetProps } = useVisualTooltip({
{tooltipElement} targetRef: contentRef,
</Tooltip> children: tooltipElement,
</TooltipTrigger> isDisabled: !shouldUseVisualTooltip,
) ...(tooltipPlacement && { overlayPositionProps: { placement: tooltipPlacement } }),
}) })
const button = (
<Tag
// @ts-expect-error ts errors are expected here because we are merging props with different types
ref={ref}
// @ts-expect-error ts errors are expected here because we are merging props with different types
{...aria.mergeProps<aria.ButtonProps>()(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) => (
<span className={styles.wrapper()}>
<span
ref={contentRef}
className={styles.content({ className: contentClassName })}
{...targetProps}
>
{}
{childrenFactory(render)}
</span>
{isLoading && loaderPosition === 'full' && (
<span ref={loaderRef} className={styles.loader()}>
<StatelessSpinner state="loading-medium" size={16} />
</span>
)}
</span>
)}
</Tag>
)
return (
tooltipElement == null ? button
: shouldUseVisualTooltip ?
<>
{button}
{visualTooltip}
</>
: <TooltipTrigger delay={0} closeDelay={0}>
{button}
<Tooltip {...(tooltipPlacement != null ? { placement: tooltipPlacement } : {})}>
{tooltipElement}
</Tooltip>
</TooltipTrigger>
)
}),
)