mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 03:32:23 +03:00
Memoize Button
This commit is contained in:
parent
6eb7541657
commit
27efdfd257
@ -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<HTMLButtonElement>,
|
||||
) {
|
||||
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<HTMLButtonElement>) {
|
||||
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<HTMLSpanElement>(null)
|
||||
const loaderRef = useRef<HTMLSpanElement>(null)
|
||||
const [implicitlyLoading, setImplicitlyLoading] = useState(false)
|
||||
const contentRef = 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 = {
|
||||
...(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 (
|
||||
<span className={styles.icon()}>
|
||||
<StatelessSpinner state="loading-medium" size={16} />
|
||||
</span>
|
||||
)
|
||||
} 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 <SvgMask src={actualIcon} className={styles.icon()} />
|
||||
} else {
|
||||
return <span className={styles.icon()}>{actualIcon}</span>
|
||||
}
|
||||
return tooltip != null
|
||||
}
|
||||
})()
|
||||
// Icon only 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>
|
||||
</>
|
||||
)
|
||||
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 = (
|
||||
<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()}>
|
||||
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>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Tag>
|
||||
)
|
||||
)
|
||||
} 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}
|
||||
</>
|
||||
: <TooltipTrigger delay={0} closeDelay={0}>
|
||||
{button}
|
||||
if (typeof actualIcon === 'string') {
|
||||
return <SvgMask src={actualIcon} className={styles.icon()} />
|
||||
} else {
|
||||
return <span className={styles.icon()}>{actualIcon}</span>
|
||||
}
|
||||
}
|
||||
})()
|
||||
// Icon only 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 } : {})}>
|
||||
{tooltipElement}
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
)
|
||||
})
|
||||
const { tooltip: visualTooltip, targetProps } = useVisualTooltip({
|
||||
targetRef: contentRef,
|
||||
children: tooltipElement,
|
||||
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>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user