123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566 |
- import {forwardRef as reactForwardRef, useCallback} from 'react';
- import isPropValid from '@emotion/is-prop-valid';
- import {css, Theme} 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 {Tooltip, TooltipProps} from 'sentry/components/tooltip';
- 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 LnikButton 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;
- 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 useButtonTracking = HookStore.get('react-hook:use-button-tracking')[0];
- 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}>
- {icon}
- </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 {
- Button,
- ButtonProps,
- LinkButton,
- LinkButtonProps,
- // Also export these styled components so we can use them as selectors
- StyledButton,
- ButtonLabel,
- };
|