123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479 |
- import {forwardRef as reactForwardRef, useCallback} from 'react';
- import isPropValid from '@emotion/is-prop-valid';
- import {css} from '@emotion/react';
- import styled from '@emotion/styled';
- import ExternalLink from 'sentry/components/links/externalLink';
- import Link from 'sentry/components/links/link';
- import Tooltip from 'sentry/components/tooltip';
- import space from 'sentry/styles/space';
- import mergeRefs from 'sentry/utils/mergeRefs';
- import {Theme} from 'sentry/utils/theme';
- /**
- * 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 & any;
- type TooltipProps = React.ComponentProps<typeof Tooltip>;
- type ButtonSize = 'zero' | 'xs' | 'sm' | 'md';
- interface BaseButtonProps
- extends Omit<
- React.ButtonHTMLAttributes<ButtonElement>,
- 'ref' | 'label' | 'size' | 'title'
- > {
- /**
- * Positions the text within the button.
- */
- align?: 'center' | 'left' | 'right';
- /**
- * 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;
- /**
- * Test ID for the button.
- */
- 'data-test-id'?: string;
- /**
- * Disables the button, assigning appropriate aria attributes and disallows
- * interactions with the button.
- */
- disabled?: boolean;
- /**
- * 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'];
- /**
- * The button is an external link. Similar to the `Link` `external` property.
- */
- external?: boolean;
- /**
- * @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.
- */
- href?: string;
- /**
- * 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;
- /**
- * Callback for when the button is clicked.
- */
- onClick?: (e: React.MouseEvent) => void;
- /**
- * 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' | 'form';
- /**
- * @deprecated Use `external`
- */
- rel?: HTMLAnchorElement['rel'];
- /**
- * The size of the button
- */
- size?: ButtonSize;
- /**
- * @deprecated Use `external`
- */
- target?: HTMLAnchorElement['target'];
- /**
- * Display a tooltip for the button.
- */
- title?: TooltipProps['title'];
- /**
- * Similar to `href`, but for internal links within the app.
- */
- to?: string | object;
- /**
- * 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;
- }
- export interface ButtonPropsWithoutAriaLabel extends BaseButtonProps {
- children: React.ReactNode;
- }
- export interface ButtonPropsWithAriaLabel extends BaseButtonProps {
- 'aria-label': string;
- children?: never;
- }
- export type ButtonProps = ButtonPropsWithoutAriaLabel | ButtonPropsWithAriaLabel;
- type Url = ButtonProps['to'] | ButtonProps['href'];
- function BaseButton({
- size = 'md',
- to,
- busy,
- href,
- title,
- icon,
- children,
- 'aria-label': ariaLabel,
- borderless,
- translucentBorder,
- align = 'center',
- priority,
- disabled = false,
- tooltipProps,
- onClick,
- ...buttonProps
- }: ButtonProps) {
- // Intercept onClick and propagate
- const handleClick = useCallback(
- (e: React.MouseEvent) => {
- // Don't allow clicks when disabled or busy
- if (disabled || busy) {
- e.preventDefault();
- e.stopPropagation();
- return;
- }
- if (typeof onClick !== 'function') {
- return;
- }
- onClick(e);
- },
- [onClick, busy, disabled]
- );
- function getUrl<T extends Url>(prop: T): T | undefined {
- return disabled ? undefined : prop;
- }
- // 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 screenReaderLabel =
- ariaLabel || (typeof children === 'string' ? children : undefined);
- 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={screenReaderLabel}
- aria-disabled={disabled}
- disabled={disabled}
- to={getUrl(to)}
- href={getUrl(href)}
- size={size}
- priority={priority}
- borderless={borderless}
- translucentBorder={translucentBorder}
- {...buttonProps}
- onClick={handleClick}
- role="button"
- >
- <ButtonLabel align={align} 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';
- export default Button;
- type StyledButtonProps = ButtonProps & {theme: Theme};
- const getBoxShadow = ({
- priority,
- borderless,
- translucentBorder,
- disabled,
- theme,
- }: StyledButtonProps) => {
- const themeName = disabled ? 'disabled' : priority || 'default';
- const {borderTranslucent} = theme.button[themeName];
- const translucentBorderString = translucentBorder
- ? `0 0 0 1px ${borderTranslucent},`
- : '';
- if (disabled || borderless || priority === 'link') {
- return 'box-shadow: none';
- }
- return `
- box-shadow: ${translucentBorderString} ${theme.dropShadowLight};
- &:active {
- box-shadow: ${translucentBorderString} inset ${theme.dropShadowLight};
- }
- `;
- };
- const getColors = ({
- size,
- priority,
- disabled,
- borderless,
- translucentBorder,
- theme,
- }: StyledButtonProps) => {
- const themeName = disabled ? 'disabled' : priority || 'default';
- const {
- color,
- colorActive,
- background,
- backgroundActive,
- 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};
- background: ${backgroundActive};
- 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];
- return {
- ...formStyles,
- ...buttonPadding,
- // If using translucent borders, rewrite size styles to
- // prevent layout shifts
- ...(translucentBorder && {
- height: formStyles.height - 2,
- minHeight: formStyles.minHeight - 2,
- paddingTop: buttonPadding.paddingTop - 1,
- paddingBottom: buttonPadding.paddingBottom - 1,
- margin: 1,
- }),
- };
- };
- export const getButtonStyles = ({theme, ...props}: StyledButtonProps) => {
- return css`
- display: inline-block;
- border-radius: ${theme.button.borderRadius};
- text-transform: none;
- font-weight: 600;
- ${getColors({...props, theme})};
- ${getSizeStyles({...props, theme})};
- ${getBoxShadow({...props, theme})};
- cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
- opacity: ${(props.busy || props.disabled) && '0.65'};
- transition: background 0.1s, border 0.1s, box-shadow 0.1s;
- ${props.priority === 'link' &&
- `font-size: inherit; font-weight: inherit; padding: 0; height: auto; min-height: auto;`}
- ${props.size === 'zero' && `height: auto; min-height: auto; padding: ${space(0.25)};`}
- &:focus {
- outline: none;
- }
- `;
- };
- const StyledButton = styled(
- reactForwardRef<any, ButtonProps>(
- (
- {forwardRef, size: _size, external, to, href, disabled, ...otherProps}: 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]);
- // only pass down title to child element if it is a string
- const {title, ...props} = otherProps;
- if (typeof title === 'string') {
- props[title] = title;
- }
- // Get component to use based on existence of `to` or `href` properties
- // Can be react-router `Link`, `a`, or `button`
- if (to) {
- return <Link ref={ref} to={to} disabled={disabled} {...props} />;
- }
- if (!href) {
- return <button ref={ref} disabled={disabled} {...props} />;
- }
- if (external && href) {
- return <ExternalLink ref={ref} href={href} disabled={disabled} {...props} />;
- }
- return <a ref={ref} {...props} href={href} />;
- }
- ),
- {
- shouldForwardProp: prop =>
- prop === 'forwardRef' ||
- prop === 'external' ||
- (typeof prop === 'string' && isPropValid(prop)),
- }
- )<ButtonProps>`
- ${getButtonStyles};
- `;
- const buttonLabelPropKeys = ['size', 'borderless', 'align'];
- type ButtonLabelProps = Pick<ButtonProps, 'size' | 'borderless' | 'align'>;
- const ButtonLabel = styled('span', {
- shouldForwardProp: prop =>
- typeof prop === 'string' && isPropValid(prop) && !buttonLabelPropKeys.includes(prop),
- })<ButtonLabelProps>`
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: ${p => p.align};
- white-space: nowrap;
- `;
- type IconProps = {
- hasChildren?: boolean;
- size?: ButtonProps['size'];
- };
- const getIconMargin = ({size, hasChildren}: IconProps) => {
- // If button is only an icon, then it shouldn't have margin
- if (!hasChildren) {
- return '0';
- }
- switch (size) {
- case 'xs':
- case 'zero':
- return '6px';
- default:
- return '8px';
- }
- };
- const Icon = styled('span')<IconProps & Omit<StyledButtonProps, 'theme'>>`
- display: flex;
- align-items: center;
- margin-right: ${getIconMargin};
- `;
- /**
- * Also export these styled components so we can use them as selectors
- */
- export {StyledButton, ButtonLabel, Icon};
|