button.tsx 9.8 KB

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