button.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. import React from 'react';
  2. import {Link} from 'react-router';
  3. import isPropValid from '@emotion/is-prop-valid';
  4. import {css} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import ExternalLink from 'app/components/links/externalLink';
  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 = (active: boolean) => ({
  145. priority,
  146. borderless,
  147. disabled,
  148. }: StyledButtonProps) => {
  149. if (disabled || borderless || priority === 'link') {
  150. return 'box-shadow: none';
  151. }
  152. return `box-shadow: ${active ? 'inset' : ''} 0 2px rgba(0, 0, 0, 0.05)`;
  153. };
  154. const getColors = ({priority, disabled, borderless, theme}: StyledButtonProps) => {
  155. const themeName = disabled ? 'disabled' : priority || 'default';
  156. const {
  157. color,
  158. colorActive,
  159. background,
  160. backgroundActive,
  161. border,
  162. borderActive,
  163. focusShadow,
  164. } = theme.button[themeName];
  165. return css`
  166. color: ${color};
  167. background-color: ${background};
  168. border: 1px solid
  169. ${priority !== 'link' && !borderless && !!border ? border : 'transparent'};
  170. &:hover {
  171. color: ${color};
  172. }
  173. &:hover,
  174. &:focus,
  175. &:active {
  176. color: ${colorActive || color};
  177. background: ${backgroundActive};
  178. border-color: ${priority !== 'link' && !borderless && (borderActive || border)
  179. ? borderActive || border
  180. : 'transparent'};
  181. }
  182. &.focus-visible {
  183. ${focusShadow && `box-shadow: ${focusShadow} 0 0 0 3px;`}
  184. }
  185. `;
  186. };
  187. const StyledButton = styled(
  188. React.forwardRef<any, ButtonProps>(
  189. (
  190. {forwardRef, size: _size, external, to, href, ...otherProps}: Props,
  191. forwardRefAlt
  192. ) => {
  193. // XXX: There may be two forwarded refs here, one potentially passed from a
  194. // wrapped Tooltip, another from callers of Button.
  195. const ref = mergeRefs([forwardRef, forwardRefAlt]);
  196. // only pass down title to child element if it is a string
  197. const {title, ...props} = otherProps;
  198. if (typeof title === 'string') {
  199. props[title] = title;
  200. }
  201. // Get component to use based on existence of `to` or `href` properties
  202. // Can be react-router `Link`, `a`, or `button`
  203. if (to) {
  204. return <Link ref={ref} to={to} {...props} />;
  205. }
  206. if (!href) {
  207. return <button ref={ref} {...props} />;
  208. }
  209. if (external && href) {
  210. return <ExternalLink ref={ref} href={href} {...props} />;
  211. }
  212. return <a ref={ref} {...props} href={href} />;
  213. }
  214. ),
  215. {
  216. shouldForwardProp: prop =>
  217. prop === 'forwardRef' ||
  218. prop === 'external' ||
  219. (typeof prop === 'string' && isPropValid(prop) && prop !== 'disabled'),
  220. }
  221. )<Props>`
  222. display: inline-block;
  223. line-height: 1;
  224. border-radius: ${p => p.theme.button.borderRadius};
  225. padding: 0;
  226. text-transform: none;
  227. ${getFontWeight};
  228. font-size: ${getFontSize};
  229. ${getColors};
  230. ${getBoxShadow(false)};
  231. cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
  232. opacity: ${p => (p.busy || p.disabled) && '0.65'};
  233. &:active {
  234. ${getBoxShadow(true)};
  235. }
  236. &:focus {
  237. outline: none;
  238. }
  239. ${p => (p.borderless || p.priority === 'link') && 'border-color: transparent'};
  240. `;
  241. /**
  242. * Get label padding determined by size
  243. */
  244. const getLabelPadding = ({
  245. size,
  246. priority,
  247. }: Pick<StyledButtonProps, 'size' | 'priority' | 'borderless'>) => {
  248. if (priority === 'link') {
  249. return '0';
  250. }
  251. switch (size) {
  252. case 'zero':
  253. return '0';
  254. case 'xsmall':
  255. return '5px 8px';
  256. case 'small':
  257. return '9px 12px';
  258. default:
  259. return '12px 16px';
  260. }
  261. };
  262. const buttonLabelPropKeys = ['size', 'priority', 'borderless', 'align'];
  263. type ButtonLabelProps = Pick<ButtonProps, 'size' | 'priority' | 'borderless' | 'align'>;
  264. const ButtonLabel = styled('span', {
  265. shouldForwardProp: prop =>
  266. typeof prop === 'string' && isPropValid(prop) && !buttonLabelPropKeys.includes(prop),
  267. })<ButtonLabelProps>`
  268. display: grid;
  269. grid-auto-flow: column;
  270. align-items: center;
  271. justify-content: ${p => p.align};
  272. padding: ${getLabelPadding};
  273. `;
  274. type IconProps = {
  275. size?: ButtonProps['size'];
  276. hasChildren?: boolean;
  277. };
  278. const getIconMargin = ({size, hasChildren}: IconProps) => {
  279. // If button is only an icon, then it shouldn't have margin
  280. if (!hasChildren) {
  281. return '0';
  282. }
  283. return size && size.endsWith('small') ? '6px' : '8px';
  284. };
  285. const Icon = styled('span')<IconProps & Omit<StyledButtonProps, 'theme'>>`
  286. display: flex;
  287. align-items: center;
  288. margin-right: ${getIconMargin};
  289. height: ${getFontSize};
  290. `;
  291. /**
  292. * Also export these styled components so we can use them as selectors
  293. */
  294. export {StyledButton, ButtonLabel, Icon};