button.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import * as React from 'react';
  2. import isPropValid from '@emotion/is-prop-valid';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import ExternalLink from 'app/components/links/externalLink';
  6. import Link from 'app/components/links/link';
  7. import Tooltip from 'app/components/tooltip';
  8. import mergeRefs from 'app/utils/mergeRefs';
  9. import {Theme} from 'app/utils/theme';
  10. /**
  11. * The button can actually also be an anchor or React router Link (which seems
  12. * to be poorly typed as `any`). So this is a bit of a workaround to receive
  13. * the proper html attributes.
  14. */
  15. type ButtonElement = HTMLButtonElement & HTMLAnchorElement & any;
  16. type Props = {
  17. priority?: 'default' | 'primary' | 'danger' | 'link' | 'success' | 'form';
  18. size?: 'zero' | 'xsmall' | 'small';
  19. align?: 'center' | 'left' | 'right';
  20. disabled?: boolean;
  21. busy?: boolean;
  22. to?: string | object;
  23. href?: string;
  24. icon?: React.ReactNode;
  25. title?: React.ComponentProps<typeof Tooltip>['title'];
  26. external?: boolean;
  27. borderless?: boolean;
  28. label?: string;
  29. tooltipProps?: Omit<Tooltip['props'], 'children' | 'title' | 'skipWrapper'>;
  30. onClick?: (e: React.MouseEvent) => void;
  31. forwardRef?: React.Ref<ButtonElement>;
  32. name?: string;
  33. // This is only used with `<ButtonBar>`
  34. barId?: string;
  35. };
  36. type ButtonProps = Omit<React.HTMLProps<ButtonElement>, keyof Props | 'ref'> & Props;
  37. type Url = ButtonProps['to'] | ButtonProps['href'];
  38. class BaseButton extends React.Component<ButtonProps, {}> {
  39. static defaultProps: ButtonProps = {
  40. disabled: false,
  41. align: 'center',
  42. };
  43. // Intercept onClick and propagate
  44. handleClick = (e: React.MouseEvent) => {
  45. const {disabled, busy, onClick} = this.props;
  46. // Don't allow clicks when disabled or busy
  47. if (disabled || busy) {
  48. e.preventDefault();
  49. e.stopPropagation();
  50. return;
  51. }
  52. if (typeof onClick !== 'function') {
  53. return;
  54. }
  55. onClick(e);
  56. };
  57. getUrl = <T extends Url>(prop: T): T | undefined =>
  58. this.props.disabled ? undefined : prop;
  59. render() {
  60. const {
  61. size,
  62. to,
  63. href,
  64. title,
  65. icon,
  66. children,
  67. label,
  68. borderless,
  69. align,
  70. priority,
  71. disabled,
  72. tooltipProps,
  73. // destructure from `buttonProps`
  74. // not necessary, but just in case someone re-orders props
  75. onClick: _onClick,
  76. ...buttonProps
  77. } = this.props;
  78. // For `aria-label`
  79. const screenReaderLabel =
  80. label || (typeof children === 'string' ? children : undefined);
  81. // Buttons come in 4 flavors: <Link>, <ExternalLink>, <a>, and <button>.
  82. // Let's use props to determine which to serve up, so we don't have to think about it.
  83. // *Note* you must still handle tabindex manually.
  84. const button = (
  85. <StyledButton
  86. aria-label={screenReaderLabel}
  87. aria-disabled={disabled}
  88. disabled={disabled}
  89. to={this.getUrl(to)}
  90. href={this.getUrl(href)}
  91. size={size}
  92. priority={priority}
  93. borderless={borderless}
  94. {...buttonProps}
  95. onClick={this.handleClick}
  96. role="button"
  97. >
  98. <ButtonLabel
  99. align={align}
  100. size={size}
  101. priority={priority}
  102. borderless={borderless}
  103. >
  104. {icon && (
  105. <Icon size={size} hasChildren={!!children}>
  106. {icon}
  107. </Icon>
  108. )}
  109. {children}
  110. </ButtonLabel>
  111. </StyledButton>
  112. );
  113. // Doing this instead of using `Tooltip`'s `disabled` prop so that we can minimize snapshot nesting
  114. if (title) {
  115. return (
  116. <Tooltip skipWrapper {...tooltipProps} title={title}>
  117. {button}
  118. </Tooltip>
  119. );
  120. }
  121. return button;
  122. }
  123. }
  124. const Button = React.forwardRef<ButtonElement, ButtonProps>((props, ref) => (
  125. <BaseButton forwardRef={ref} {...props} />
  126. ));
  127. Button.displayName = 'Button';
  128. export default Button;
  129. type StyledButtonProps = ButtonProps & {theme: Theme};
  130. const getFontSize = ({size, priority, theme}: StyledButtonProps) => {
  131. if (priority === 'link') {
  132. return 'inherit';
  133. }
  134. switch (size) {
  135. case 'xsmall':
  136. case 'small':
  137. return theme.fontSizeSmall;
  138. default:
  139. return theme.fontSizeMedium;
  140. }
  141. };
  142. const getFontWeight = ({priority, borderless}: StyledButtonProps) =>
  143. `font-weight: ${priority === 'link' || borderless ? 'inherit' : 600};`;
  144. const getBoxShadow =
  145. (active: boolean) =>
  146. ({priority, borderless, disabled}: StyledButtonProps) => {
  147. if (disabled || borderless || priority === 'link') {
  148. return 'box-shadow: none';
  149. }
  150. return `box-shadow: ${active ? 'inset' : ''} 0 2px rgba(0, 0, 0, 0.05)`;
  151. };
  152. const getColors = ({priority, disabled, borderless, theme}: StyledButtonProps) => {
  153. const themeName = disabled ? 'disabled' : priority || 'default';
  154. const {
  155. color,
  156. colorActive,
  157. background,
  158. backgroundActive,
  159. border,
  160. borderActive,
  161. focusShadow,
  162. } = theme.button[themeName];
  163. return css`
  164. color: ${color};
  165. background-color: ${background};
  166. border: 1px solid
  167. ${priority !== 'link' && !borderless && !!border ? border : 'transparent'};
  168. &:hover {
  169. color: ${color};
  170. }
  171. &:hover,
  172. &:focus,
  173. &:active {
  174. color: ${colorActive || color};
  175. background: ${backgroundActive};
  176. border-color: ${priority !== 'link' && !borderless && (borderActive || border)
  177. ? borderActive || border
  178. : 'transparent'};
  179. }
  180. &.focus-visible {
  181. ${focusShadow && `box-shadow: ${focusShadow} 0 0 0 3px;`}
  182. }
  183. `;
  184. };
  185. const StyledButton = styled(
  186. React.forwardRef<any, ButtonProps>(
  187. (
  188. {forwardRef, size: _size, external, to, href, disabled, ...otherProps}: Props,
  189. forwardRefAlt
  190. ) => {
  191. // XXX: There may be two forwarded refs here, one potentially passed from a
  192. // wrapped Tooltip, another from callers of Button.
  193. const ref = mergeRefs([forwardRef, forwardRefAlt]);
  194. // only pass down title to child element if it is a string
  195. const {title, ...props} = otherProps;
  196. if (typeof title === 'string') {
  197. props[title] = title;
  198. }
  199. // Get component to use based on existence of `to` or `href` properties
  200. // Can be react-router `Link`, `a`, or `button`
  201. if (to) {
  202. return <Link ref={ref} to={to} disabled={disabled} {...props} />;
  203. }
  204. if (!href) {
  205. return <button ref={ref} disabled={disabled} {...props} />;
  206. }
  207. if (external && href) {
  208. return <ExternalLink ref={ref} href={href} disabled={disabled} {...props} />;
  209. }
  210. return <a ref={ref} {...props} href={href} />;
  211. }
  212. ),
  213. {
  214. shouldForwardProp: prop =>
  215. prop === 'forwardRef' ||
  216. prop === 'external' ||
  217. (typeof prop === 'string' && isPropValid(prop)),
  218. }
  219. )<Props>`
  220. display: inline-block;
  221. line-height: 1;
  222. border-radius: ${p => p.theme.button.borderRadius};
  223. padding: 0;
  224. text-transform: none;
  225. ${getFontWeight};
  226. font-size: ${getFontSize};
  227. ${getColors};
  228. ${getBoxShadow(false)};
  229. cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
  230. opacity: ${p => (p.busy || p.disabled) && '0.65'};
  231. &:active {
  232. ${getBoxShadow(true)};
  233. }
  234. &:focus {
  235. outline: none;
  236. }
  237. ${p => (p.borderless || p.priority === 'link') && 'border-color: transparent'};
  238. `;
  239. /**
  240. * Get label padding determined by size
  241. */
  242. const getLabelPadding = ({
  243. size,
  244. priority,
  245. }: Pick<StyledButtonProps, 'size' | 'priority' | 'borderless'>) => {
  246. if (priority === 'link') {
  247. return '0';
  248. }
  249. switch (size) {
  250. case 'zero':
  251. return '0';
  252. case 'xsmall':
  253. return '5px 8px';
  254. case 'small':
  255. return '9px 12px';
  256. default:
  257. return '12px 16px';
  258. }
  259. };
  260. const buttonLabelPropKeys = ['size', 'priority', 'borderless', 'align'];
  261. type ButtonLabelProps = Pick<ButtonProps, 'size' | 'priority' | 'borderless' | 'align'>;
  262. const ButtonLabel = styled('span', {
  263. shouldForwardProp: prop =>
  264. typeof prop === 'string' && isPropValid(prop) && !buttonLabelPropKeys.includes(prop),
  265. })<ButtonLabelProps>`
  266. display: grid;
  267. grid-auto-flow: column;
  268. align-items: center;
  269. justify-content: ${p => p.align};
  270. padding: ${getLabelPadding};
  271. `;
  272. type IconProps = {
  273. size?: ButtonProps['size'];
  274. hasChildren?: boolean;
  275. };
  276. const getIconMargin = ({size, hasChildren}: IconProps) => {
  277. // If button is only an icon, then it shouldn't have margin
  278. if (!hasChildren) {
  279. return '0';
  280. }
  281. return size && size.endsWith('small') ? '6px' : '8px';
  282. };
  283. const Icon = styled('span')<IconProps & Omit<StyledButtonProps, 'theme'>>`
  284. display: flex;
  285. align-items: center;
  286. margin-right: ${getIconMargin};
  287. height: ${getFontSize};
  288. `;
  289. /**
  290. * Also export these styled components so we can use them as selectors
  291. */
  292. export {StyledButton, ButtonLabel, Icon};