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. */
|
/** @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>
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user