Files
2024-02-15 17:01:50 +00:00

599 lines
16 KiB
TypeScript

import {forwardRef as reactForwardRef, useCallback} from 'react';
import isPropValid from '@emotion/is-prop-valid';
import type {Theme} from '@emotion/react';
import {css} from '@emotion/react';
import styled from '@emotion/styled';
import InteractionStateLayer from 'sentry/components/interactionStateLayer';
import ExternalLink from 'sentry/components/links/externalLink';
import Link from 'sentry/components/links/link';
import type {TooltipProps} from 'sentry/components/tooltip';
import {Tooltip} from 'sentry/components/tooltip';
import type {SVGIconProps} from 'sentry/icons/svgIcon';
import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults';
import HookStore from 'sentry/stores/hookStore';
import {space} from 'sentry/styles/space';
import mergeRefs from 'sentry/utils/mergeRefs';
/**
* The button can actually also be an anchor or React router Link (which seems
* to be poorly typed as `any`). So this is a bit of a workaround to receive
* the proper html attributes.
*/
type ButtonElement = HTMLButtonElement | HTMLAnchorElement;
/**
* Props shared across different types of button components
*/
interface CommonButtonProps {
/**
* Used when you want to overwrite the default Reload event key for analytics
*/
analyticsEventKey?: string;
/**
* Used when you want to send an Amplitude Event. By default, Amplitude events are not sent so
* you must pass in a eventName to send an Amplitude event.
*/
analyticsEventName?: string;
/**
* Adds extra parameters to the analytics tracking
*/
analyticsParams?: Record<string, any>;
/**
* Used by ButtonBar to determine active status.
*/
barId?: string;
/**
* Removes borders from the button.
*/
borderless?: boolean;
/**
* Indicates that the button is "doing" something.
*/
busy?: boolean;
/**
* Disables the button, assigning appropriate aria attributes and disallows
* interactions with the button.
*/
disabled?: boolean;
/**
* The button is an external link. Similar to the `Link` `external` property.
*/
external?: boolean;
/**
* The icon to render inside of the button. The size will be set
* appropriately based on the size of the button.
*/
icon?: React.ReactNode;
/**
* Used when the button is part of a form.
*/
name?: string;
/**
* The semantic "priority" of the button. Use `primary` when the action is
* contextually the primary action, `danger` if the button will do something
* destructive, `link` for visual similarity to a link.
*/
priority?: 'default' | 'primary' | 'danger' | 'link';
/**
* The size of the button
*/
size?: 'zero' | 'xs' | 'sm' | 'md';
/**
* Display a tooltip for the button.
*/
title?: TooltipProps['title'];
/**
* Additional properites for the Tooltip when `title` is set.
*/
tooltipProps?: Omit<TooltipProps, 'children' | 'title' | 'skipWrapper'>;
/**
* Userful in scenarios where the border of the button should blend with the
* background behind the button.
*/
translucentBorder?: boolean;
}
/**
* Helper type to extraxct the HTML element props for use in button prop
* interfaces.
*
* XXX(epurkhiser): Right now all usages of this use ButtonElement, but in the
* future ButtonElement should go away and be replaced with HTMLButtonElement
* and HTMLAnchorElement respectively
*/
type ElementProps<E> = Omit<React.ButtonHTMLAttributes<E>, 'label' | 'size' | 'title'>;
interface BaseButtonProps extends CommonButtonProps, ElementProps<ButtonElement> {
/**
* For use with `href` and `data:` or `blob:` schemes. Tells the browser to
* download the contents.
*
* See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-download
*
* @deprecated Use LnikButton instead
*/
download?: HTMLAnchorElement['download'];
/**
* @internal Used in the Button forwardRef
*/
forwardRef?: React.Ref<ButtonElement>;
/**
* When set the button acts as an anchor link. Use with `external` to have
* the link open in a new tab.
*
* @deprecated Use LinkButton instead
*/
href?: string;
/**
* Similar to `href`, but for internal links within the app.
*
* @deprecated Use LinkButton instead
*/
to?: string | object;
}
interface ButtonPropsWithoutAriaLabel extends BaseButtonProps {
children: React.ReactNode;
}
interface ButtonPropsWithAriaLabel extends BaseButtonProps {
'aria-label': string;
children?: never;
}
type ButtonProps = ButtonPropsWithoutAriaLabel | ButtonPropsWithAriaLabel;
interface BaseLinkButtonProps extends CommonButtonProps, ElementProps<ButtonElement> {
/**
* @internal Used in the Button forwardRef
*/
forwardRef?: React.Ref<ButtonElement>;
}
interface ToLinkButtonProps extends BaseLinkButtonProps {
/**
* Similar to `href`, but for internal links within the app.
*/
to: string | object;
}
interface HrefLinkButtonProps extends BaseLinkButtonProps {
/**
* When set the button acts as an anchor link. Use with `external` to have
* the link open in a new tab.
*/
href: string;
/**
* For use with `href` and `data:` or `blob:` schemes. Tells the browser to
* download the contents.
*
* See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-download
*/
download?: HTMLAnchorElement['download'];
}
interface ToLinkButtonPropsWithChildren extends ToLinkButtonProps {
children: React.ReactNode;
}
interface ToLinkButtonPropsWithAriaLabel extends ToLinkButtonProps {
'aria-label': string;
children?: never;
}
interface HrefLinkButtonPropsWithChildren extends HrefLinkButtonProps {
children: React.ReactNode;
}
interface HrefLinkButtonPropsWithAriaLabel extends HrefLinkButtonProps {
'aria-label': string;
children?: never;
}
type LinkButtonProps =
| ToLinkButtonPropsWithChildren
| ToLinkButtonPropsWithAriaLabel
| HrefLinkButtonPropsWithChildren
| HrefLinkButtonPropsWithAriaLabel;
/**
* Default sizes to use for SVGIcon
*/
const ICON_SIZES: Partial<
Record<NonNullable<BaseButtonProps['size']>, SVGIconProps['size']>
> = {
xs: 'xs',
sm: 'sm',
md: 'sm',
};
function BaseButton({
size = 'md',
to,
busy,
href,
title,
icon,
children,
'aria-label': ariaLabel,
borderless,
translucentBorder,
priority,
disabled = false,
tooltipProps,
onClick,
analyticsEventName,
analyticsEventKey,
analyticsParams,
...buttonProps
}: ButtonProps) {
// Fallbacking aria-label to string children is not necessary as screen
// readers natively understand that scenario. Leaving it here for a bunch of
// our tests that query by aria-label.
const accessibleLabel =
ariaLabel ?? (typeof children === 'string' ? children : undefined);
const useButtonTrackingLogger = () => {
const hasAnalyticsDebug = window.localStorage?.getItem('DEBUG_ANALYTICS') === '1';
const hasCustomAnalytics = analyticsEventName || analyticsEventKey || analyticsParams;
if (!hasCustomAnalytics || !hasAnalyticsDebug) {
return () => {};
}
return () => {
// eslint-disable-next-line no-console
console.log('buttonAnalyticsEvent', {
eventKey: analyticsEventKey,
eventName: analyticsEventName,
priority,
href,
...analyticsParams,
});
};
};
const useButtonTracking =
HookStore.get('react-hook:use-button-tracking')[0] ?? useButtonTrackingLogger;
const buttonTracking = useButtonTracking({
analyticsEventName,
analyticsEventKey,
analyticsParams: {
priority,
href,
...analyticsParams,
},
'aria-label': accessibleLabel || '',
});
const handleClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
// Don't allow clicks when disabled or busy
if (disabled || busy) {
e.preventDefault();
e.stopPropagation();
return;
}
buttonTracking();
onClick?.(e);
},
[disabled, busy, onClick, buttonTracking]
);
const hasChildren = Array.isArray(children)
? children.some(child => !!child)
: !!children;
// Buttons come in 4 flavors: <Link>, <ExternalLink>, <a>, and <button>.
// Let's use props to determine which to serve up, so we don't have to think about it.
// *Note* you must still handle tabindex manually.
const button = (
<StyledButton
aria-label={accessibleLabel}
aria-disabled={disabled}
busy={busy}
disabled={disabled}
to={!disabled ? to : undefined}
href={!disabled ? href : undefined}
size={size}
priority={priority}
borderless={borderless}
translucentBorder={translucentBorder}
{...buttonProps}
onClick={handleClick}
role="button"
>
{priority !== 'link' && (
<InteractionStateLayer
higherOpacity={priority && ['primary', 'danger'].includes(priority)}
/>
)}
<ButtonLabel size={size} borderless={borderless}>
{icon && (
<Icon size={size} hasChildren={hasChildren}>
<IconDefaultsProvider size={ICON_SIZES[size]}>{icon}</IconDefaultsProvider>
</Icon>
)}
{children}
</ButtonLabel>
</StyledButton>
);
// Doing this instead of using `Tooltip`'s `disabled` prop so that we can minimize snapshot nesting
if (title) {
return (
<Tooltip skipWrapper {...tooltipProps} title={title}>
{button}
</Tooltip>
);
}
return button;
}
const Button = reactForwardRef<ButtonElement, ButtonProps>((props, ref) => (
<BaseButton forwardRef={ref} {...props} />
));
Button.displayName = 'Button';
interface StyledButtonPropsWithAriaLabel extends ButtonPropsWithoutAriaLabel {
theme: Theme;
}
interface StyledButtonPropsWithoutAriaLabel extends ButtonPropsWithAriaLabel {
theme: Theme;
}
type StyledButtonProps =
| StyledButtonPropsWithAriaLabel
| StyledButtonPropsWithoutAriaLabel;
const getBoxShadow = ({
priority,
borderless,
translucentBorder,
disabled,
size,
theme,
}: StyledButtonProps) => {
if (disabled || borderless || priority === 'link') {
return 'box-shadow: none';
}
const themeName = disabled ? 'disabled' : priority || 'default';
const {borderTranslucent} = theme.button[themeName];
const translucentBorderString = translucentBorder
? `0 0 0 1px ${borderTranslucent},`
: '';
const dropShadow = size === 'xs' ? theme.dropShadowLight : theme.dropShadowMedium;
return `
box-shadow: ${translucentBorderString} ${dropShadow};
&:active {
box-shadow: ${translucentBorderString} inset ${dropShadow};
}
`;
};
const getColors = ({
size,
priority,
disabled,
borderless,
translucentBorder,
theme,
}: StyledButtonProps) => {
const themeName = disabled ? 'disabled' : priority || 'default';
const {color, colorActive, background, border, borderActive, focusBorder, focusShadow} =
theme.button[themeName];
const getFocusState = () => {
switch (priority) {
case 'primary':
case 'danger':
return `
border-color: ${focusBorder};
box-shadow: ${focusBorder} 0 0 0 1px, ${focusShadow} 0 0 0 4px;`;
default:
if (translucentBorder) {
return `
border-color: ${focusBorder};
box-shadow: ${focusBorder} 0 0 0 2px;`;
}
return `
border-color: ${focusBorder};
box-shadow: ${focusBorder} 0 0 0 1px;`;
}
};
const getBackgroundColor = () => {
switch (priority) {
case 'primary':
case 'danger':
return `background-color: ${background};`;
default:
if (borderless) {
return `background-color: transparent;`;
}
return `background-color: ${background};`;
}
};
return css`
color: ${color};
${getBackgroundColor()}
border: 1px solid ${borderless || priority === 'link' ? 'transparent' : border};
${translucentBorder && `border-width: 0;`}
&:hover {
color: ${color};
}
${
size !== 'zero' &&
`
&:hover,
&:active,
&[aria-expanded="true"] {
color: ${colorActive || color};
border-color: ${borderless || priority === 'link' ? 'transparent' : borderActive};
}
&:focus-visible {
color: ${colorActive || color};
border-color: ${borderActive};
}
`
}
&:focus-visible {
${getFocusState()}
z-index: 1;
}
`;
};
const getSizeStyles = ({size = 'md', translucentBorder, theme}: StyledButtonProps) => {
const buttonSize = size === 'zero' ? 'md' : size;
const formStyles = theme.form[buttonSize];
const buttonPadding = theme.buttonPadding[buttonSize];
// If using translucent borders, rewrite size styles to
// prevent layout shifts
const borderStyles = !translucentBorder
? {}
: {
height: formStyles.height - 2,
minHeight: formStyles.minHeight - 2,
paddingTop: buttonPadding.paddingTop - 1,
paddingBottom: buttonPadding.paddingBottom - 1,
margin: 1,
};
return {...formStyles, ...buttonPadding, ...borderStyles};
};
const StyledButton = styled(
reactForwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(
(
{
forwardRef,
size: _size,
title: _title,
external,
to,
href,
disabled,
...props
}: ButtonProps,
forwardRefAlt
) => {
// XXX: There may be two forwarded refs here, one potentially passed from a
// wrapped Tooltip, another from callers of Button.
const ref = mergeRefs([forwardRef, forwardRefAlt]);
// Get component to use based on existence of `to` or `href` properties
// Can be react-router `Link`, `a`, or `button`
if (to) {
return <Link {...props} ref={ref} to={to} disabled={disabled} />;
}
if (href && external) {
return <ExternalLink {...props} ref={ref} href={href} disabled={disabled} />;
}
if (href) {
return <a {...props} ref={ref} href={href} />;
}
// The default `type` of a native button element is `submit` when inside
// of a form. This is typically not what we want, and if we do want it we
// should explicitly set type submit.
props.type ??= 'button';
return <button {...props} ref={ref} disabled={disabled} />;
}
),
{
shouldForwardProp: prop =>
prop === 'forwardRef' ||
prop === 'external' ||
(typeof prop === 'string' && isPropValid(prop)),
}
)<ButtonProps>`
position: relative;
display: inline-block;
border-radius: ${p => p.theme.borderRadius};
text-transform: none;
font-weight: 600;
${getColors};
${getSizeStyles};
${getBoxShadow};
cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
opacity: ${p => (p.busy || p.disabled) && '0.65'};
transition:
background 0.1s,
border 0.1s,
box-shadow 0.1s;
${p =>
p.priority === 'link' &&
`font-size: inherit; font-weight: inherit; padding: 0; height: auto; min-height: auto;`}
${p => p.size === 'zero' && `height: auto; min-height: auto; padding: ${space(0.25)};`}
&:focus {
outline: none;
}
`;
const buttonLabelPropKeys = ['size', 'borderless'];
type ButtonLabelProps = Pick<ButtonProps, 'size' | 'borderless'>;
const ButtonLabel = styled('span', {
shouldForwardProp: prop =>
typeof prop === 'string' && isPropValid(prop) && !buttonLabelPropKeys.includes(prop),
})<ButtonLabelProps>`
height: 100%;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
`;
type ChildrenIconProps = {
hasChildren?: boolean;
size?: ButtonProps['size'];
};
const getIconMargin = ({size, hasChildren}: ChildrenIconProps) => {
// If button is only an icon, then it shouldn't have margin
if (!hasChildren) {
return '0';
}
switch (size) {
case 'xs':
case 'zero':
return space(0.75);
default:
return space(1);
}
};
interface IconProps extends ChildrenIconProps, Omit<StyledButtonProps, 'theme'> {}
const Icon = styled('span')<IconProps>`
display: flex;
align-items: center;
margin-right: ${getIconMargin};
flex-shrink: 0;
`;
const LinkButton = Button as React.ComponentType<LinkButtonProps>;
export type {ButtonProps, BaseButtonProps, LinkButtonProps};
export {Button, LinkButton, StyledButton, ButtonLabel};