import * as React from 'react'; import isPropValid from '@emotion/is-prop-valid'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; import ExternalLink from 'app/components/links/externalLink'; import Link from 'app/components/links/link'; import Tooltip from 'app/components/tooltip'; import mergeRefs from 'app/utils/mergeRefs'; import {Theme} from 'app/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 Props = { priority?: 'default' | 'primary' | 'danger' | 'link' | 'success' | 'form'; size?: 'zero' | 'xsmall' | 'small'; align?: 'center' | 'left' | 'right'; disabled?: boolean; busy?: boolean; to?: string | object; href?: string; icon?: React.ReactNode; title?: React.ComponentProps<typeof Tooltip>['title']; external?: boolean; borderless?: boolean; label?: string; tooltipProps?: Omit<Tooltip['props'], 'children' | 'title' | 'skipWrapper'>; onClick?: (e: React.MouseEvent) => void; forwardRef?: React.Ref<ButtonElement>; name?: string; // This is only used with `<ButtonBar>` barId?: string; }; type ButtonProps = Omit<React.HTMLProps<ButtonElement>, keyof Props | 'ref'> & Props; type Url = ButtonProps['to'] | ButtonProps['href']; class BaseButton extends React.Component<ButtonProps, {}> { static defaultProps: ButtonProps = { disabled: false, align: 'center', }; // Intercept onClick and propagate handleClick = (e: React.MouseEvent) => { const {disabled, busy, onClick} = this.props; // Don't allow clicks when disabled or busy if (disabled || busy) { e.preventDefault(); e.stopPropagation(); return; } if (typeof onClick !== 'function') { return; } onClick(e); }; getUrl = <T extends Url>(prop: T): T | undefined => this.props.disabled ? undefined : prop; render() { const { size, to, href, title, icon, children, label, borderless, align, priority, disabled, tooltipProps, // destructure from `buttonProps` // not necessary, but just in case someone re-orders props onClick: _onClick, ...buttonProps } = this.props; // For `aria-label` const screenReaderLabel = label || (typeof children === 'string' ? children : undefined); // 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={this.getUrl(to)} href={this.getUrl(href)} size={size} priority={priority} borderless={borderless} {...buttonProps} onClick={this.handleClick} role="button" > <ButtonLabel align={align} size={size} priority={priority} borderless={borderless} > {icon && ( <Icon size={size} hasChildren={!!children}> {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 = React.forwardRef<ButtonElement, ButtonProps>((props, ref) => ( <BaseButton forwardRef={ref} {...props} /> )); Button.displayName = 'Button'; export default Button; type StyledButtonProps = ButtonProps & {theme: Theme}; const getFontSize = ({size, priority, theme}: StyledButtonProps) => { if (priority === 'link') { return 'inherit'; } switch (size) { case 'xsmall': case 'small': return theme.fontSizeSmall; default: return theme.fontSizeMedium; } }; const getFontWeight = ({priority, borderless}: StyledButtonProps) => `font-weight: ${priority === 'link' || borderless ? 'inherit' : 600};`; const getBoxShadow = (active: boolean) => ({priority, borderless, disabled}: StyledButtonProps) => { if (disabled || borderless || priority === 'link') { return 'box-shadow: none'; } return `box-shadow: ${active ? 'inset' : ''} 0 2px rgba(0, 0, 0, 0.05)`; }; const getColors = ({priority, disabled, borderless, theme}: StyledButtonProps) => { const themeName = disabled ? 'disabled' : priority || 'default'; const { color, colorActive, background, backgroundActive, border, borderActive, focusShadow, } = theme.button[themeName]; return css` color: ${color}; background-color: ${background}; border: 1px solid ${priority !== 'link' && !borderless && !!border ? border : 'transparent'}; &:hover { color: ${color}; } &:hover, &:focus, &:active { color: ${colorActive || color}; background: ${backgroundActive}; border-color: ${priority !== 'link' && !borderless && (borderActive || border) ? borderActive || border : 'transparent'}; } &.focus-visible { ${focusShadow && `box-shadow: ${focusShadow} 0 0 0 3px;`} } `; }; const StyledButton = styled( React.forwardRef<any, ButtonProps>( ( {forwardRef, size: _size, external, to, href, disabled, ...otherProps}: Props, 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)), } )<Props>` display: inline-block; line-height: 1; border-radius: ${p => p.theme.button.borderRadius}; padding: 0; text-transform: none; ${getFontWeight}; font-size: ${getFontSize}; ${getColors}; ${getBoxShadow(false)}; cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')}; opacity: ${p => (p.busy || p.disabled) && '0.65'}; &:active { ${getBoxShadow(true)}; } &:focus { outline: none; } ${p => (p.borderless || p.priority === 'link') && 'border-color: transparent'}; `; /** * Get label padding determined by size */ const getLabelPadding = ({ size, priority, }: Pick<StyledButtonProps, 'size' | 'priority' | 'borderless'>) => { if (priority === 'link') { return '0'; } switch (size) { case 'zero': return '0'; case 'xsmall': return '5px 8px'; case 'small': return '9px 12px'; default: return '12px 16px'; } }; const buttonLabelPropKeys = ['size', 'priority', 'borderless', 'align']; type ButtonLabelProps = Pick<ButtonProps, 'size' | 'priority' | 'borderless' | 'align'>; const ButtonLabel = styled('span', { shouldForwardProp: prop => typeof prop === 'string' && isPropValid(prop) && !buttonLabelPropKeys.includes(prop), })<ButtonLabelProps>` display: grid; grid-auto-flow: column; align-items: center; justify-content: ${p => p.align}; padding: ${getLabelPadding}; `; type IconProps = { size?: ButtonProps['size']; hasChildren?: boolean; }; const getIconMargin = ({size, hasChildren}: IconProps) => { // If button is only an icon, then it shouldn't have margin if (!hasChildren) { return '0'; } return size && size.endsWith('small') ? '6px' : '8px'; }; const Icon = styled('span')<IconProps & Omit<StyledButtonProps, 'theme'>>` display: flex; align-items: center; margin-right: ${getIconMargin}; height: ${getFontSize}; `; /** * Also export these styled components so we can use them as selectors */ export {StyledButton, ButtonLabel, Icon};