button.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. import {forwardRef as reactForwardRef, useCallback} 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. type TooltipProps = React.ComponentProps<typeof Tooltip>;
  18. interface BaseButtonProps
  19. extends Omit<
  20. React.ButtonHTMLAttributes<ButtonElement>,
  21. 'ref' | 'label' | 'size' | 'title'
  22. > {
  23. /**
  24. * Positions the text within the button.
  25. */
  26. align?: 'center' | 'left' | 'right';
  27. /**
  28. * Used by ButtonBar to determine active status.
  29. */
  30. barId?: string;
  31. /**
  32. * Removes borders from the button.
  33. */
  34. borderless?: boolean;
  35. /**
  36. * Indicates that the button is "doing" something.
  37. */
  38. busy?: boolean;
  39. /**
  40. * Test ID for the button.
  41. */
  42. 'data-test-id'?: string;
  43. /**
  44. * Disables the button, assigning appropriate aria attributes and disallows
  45. * interactions with the button.
  46. */
  47. disabled?: boolean;
  48. /**
  49. * For use with `href` and `data:` or `blob:` schemes. Tells the browser to
  50. * download the contents.
  51. *
  52. * See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-download
  53. */
  54. download?: HTMLAnchorElement['download'];
  55. /**
  56. * The button is an external link. Similar to the `Link` `external` property.
  57. */
  58. external?: boolean;
  59. /**
  60. * @internal Used in the Button forwardRef
  61. */
  62. forwardRef?: React.Ref<ButtonElement>;
  63. /**
  64. * When set the button acts as an anchor link. Use with `external` to have
  65. * the link open in a new tab.
  66. */
  67. href?: string;
  68. /**
  69. * The icon to render inside of the button. The size will be set
  70. * appropriately based on the size of the button.
  71. */
  72. icon?: React.ReactNode;
  73. /**
  74. * Used when the button is part of a form.
  75. */
  76. name?: string;
  77. /**
  78. * Callback for when the button is clicked.
  79. */
  80. onClick?: (e: React.MouseEvent) => void;
  81. /**
  82. * The semantic "priority" of the button. Use `primary` when the action is
  83. * contextually the primary action, `danger` if the button will do something
  84. * destructive, `link` for visual similarity to a link.
  85. */
  86. priority?: 'default' | 'primary' | 'danger' | 'link' | 'form';
  87. /**
  88. * @deprecated Use `external`
  89. */
  90. rel?: HTMLAnchorElement['rel'];
  91. /**
  92. * The size of the button
  93. */
  94. size?: 'zero' | 'xsmall' | 'small';
  95. /**
  96. * @deprecated Use `external`
  97. */
  98. target?: HTMLAnchorElement['target'];
  99. /**
  100. * Display a tooltip for the button.
  101. */
  102. title?: TooltipProps['title'];
  103. /**
  104. * Similar to `href`, but for internal links within the app.
  105. */
  106. to?: string | object;
  107. /**
  108. * Additional properites for the Tooltip when `title` is set.
  109. */
  110. tooltipProps?: Omit<TooltipProps, 'children' | 'title' | 'skipWrapper'>;
  111. /**
  112. * Userful in scenarios where the border of the button should blend with the
  113. * background behind the button.
  114. */
  115. translucentBorder?: boolean;
  116. }
  117. export interface ButtonPropsWithoutAriaLabel extends BaseButtonProps {
  118. children: React.ReactNode;
  119. }
  120. export interface ButtonPropsWithAriaLabel extends BaseButtonProps {
  121. 'aria-label': string;
  122. children?: never;
  123. }
  124. export type ButtonProps = ButtonPropsWithoutAriaLabel | ButtonPropsWithAriaLabel;
  125. type Url = ButtonProps['to'] | ButtonProps['href'];
  126. function BaseButton({
  127. size,
  128. to,
  129. busy,
  130. href,
  131. title,
  132. icon,
  133. children,
  134. 'aria-label': ariaLabel,
  135. borderless,
  136. translucentBorder,
  137. align = 'center',
  138. priority,
  139. disabled = false,
  140. tooltipProps,
  141. onClick,
  142. ...buttonProps
  143. }: ButtonProps) {
  144. // Intercept onClick and propagate
  145. const handleClick = useCallback(
  146. (e: React.MouseEvent) => {
  147. // Don't allow clicks when disabled or busy
  148. if (disabled || busy) {
  149. e.preventDefault();
  150. e.stopPropagation();
  151. return;
  152. }
  153. if (typeof onClick !== 'function') {
  154. return;
  155. }
  156. onClick(e);
  157. },
  158. [onClick, busy, disabled]
  159. );
  160. function getUrl<T extends Url>(prop: T): T | undefined {
  161. return disabled ? undefined : prop;
  162. }
  163. // Fallbacking aria-label to string children is not necessary as screen readers natively understand that scenario.
  164. // Leaving it here for a bunch of our tests that query by aria-label.
  165. const screenReaderLabel =
  166. ariaLabel || (typeof children === 'string' ? children : undefined);
  167. const hasChildren = Array.isArray(children)
  168. ? children.some(child => !!child)
  169. : !!children;
  170. // Buttons come in 4 flavors: <Link>, <ExternalLink>, <a>, and <button>.
  171. // Let's use props to determine which to serve up, so we don't have to think about it.
  172. // *Note* you must still handle tabindex manually.
  173. const button = (
  174. <StyledButton
  175. aria-label={screenReaderLabel}
  176. aria-disabled={disabled}
  177. disabled={disabled}
  178. to={getUrl(to)}
  179. href={getUrl(href)}
  180. size={size}
  181. priority={priority}
  182. borderless={borderless}
  183. translucentBorder={translucentBorder}
  184. {...buttonProps}
  185. onClick={handleClick}
  186. role="button"
  187. >
  188. <ButtonLabel align={align} size={size} borderless={borderless}>
  189. {icon && (
  190. <Icon size={size} hasChildren={hasChildren}>
  191. {icon}
  192. </Icon>
  193. )}
  194. {children}
  195. </ButtonLabel>
  196. </StyledButton>
  197. );
  198. // Doing this instead of using `Tooltip`'s `disabled` prop so that we can minimize snapshot nesting
  199. if (title) {
  200. return (
  201. <Tooltip skipWrapper {...tooltipProps} title={title}>
  202. {button}
  203. </Tooltip>
  204. );
  205. }
  206. return button;
  207. }
  208. const Button = reactForwardRef<ButtonElement, ButtonProps>((props, ref) => (
  209. <BaseButton forwardRef={ref} {...props} />
  210. ));
  211. Button.displayName = 'Button';
  212. export default Button;
  213. type StyledButtonProps = ButtonProps & {theme: Theme};
  214. const getBoxShadow = ({
  215. priority,
  216. borderless,
  217. translucentBorder,
  218. disabled,
  219. theme,
  220. }: StyledButtonProps) => {
  221. const themeName = disabled ? 'disabled' : priority || 'default';
  222. const {borderTranslucent} = theme.button[themeName];
  223. const translucentBorderString = translucentBorder
  224. ? `0 0 0 1px ${borderTranslucent},`
  225. : '';
  226. if (disabled || borderless || priority === 'link') {
  227. return 'box-shadow: none';
  228. }
  229. return `
  230. box-shadow: ${translucentBorderString} ${theme.dropShadowLight};
  231. &:active {
  232. box-shadow: ${translucentBorderString} inset ${theme.dropShadowLight};
  233. }
  234. `;
  235. };
  236. const getColors = ({
  237. size,
  238. priority,
  239. disabled,
  240. borderless,
  241. translucentBorder,
  242. theme,
  243. }: StyledButtonProps) => {
  244. const themeName = disabled ? 'disabled' : priority || 'default';
  245. const {
  246. color,
  247. colorActive,
  248. background,
  249. backgroundActive,
  250. border,
  251. borderActive,
  252. focusBorder,
  253. focusShadow,
  254. } = theme.button[themeName];
  255. const getFocusState = () => {
  256. switch (priority) {
  257. case 'primary':
  258. case 'danger':
  259. return `
  260. border-color: ${focusBorder};
  261. box-shadow: ${focusBorder} 0 0 0 1px, ${focusShadow} 0 0 0 4px;`;
  262. default:
  263. if (translucentBorder) {
  264. return `
  265. border-color: ${focusBorder};
  266. box-shadow: ${focusBorder} 0 0 0 2px;`;
  267. }
  268. return `
  269. border-color: ${focusBorder};
  270. box-shadow: ${focusBorder} 0 0 0 1px;`;
  271. }
  272. };
  273. const getBackgroundColor = () => {
  274. switch (priority) {
  275. case 'primary':
  276. case 'danger':
  277. return `background-color: ${background};`;
  278. default:
  279. if (borderless) {
  280. return `background-color: transparent;`;
  281. }
  282. return `background-color: ${background};`;
  283. }
  284. };
  285. return css`
  286. color: ${color};
  287. ${getBackgroundColor()}
  288. border: 1px solid ${borderless || priority === 'link' ? 'transparent' : border};
  289. ${translucentBorder && `border-width: 0;`}
  290. &:hover {
  291. color: ${color};
  292. }
  293. ${size !== 'zero' &&
  294. `
  295. &:hover,
  296. &:active,
  297. &.focus-visible,
  298. &[aria-expanded="true"] {
  299. color: ${colorActive || color};
  300. background: ${backgroundActive};
  301. border-color: ${borderless || priority === 'link' ? 'transparent' : borderActive};
  302. }`}
  303. &.focus-visible {
  304. ${getFocusState()}
  305. z-index: 1;
  306. }
  307. `;
  308. };
  309. const getSizeStyles = ({size, translucentBorder, theme}: StyledButtonProps) => {
  310. const buttonSize = size === 'small' || size === 'xsmall' ? size : 'default';
  311. const formStyles = theme.form[buttonSize];
  312. const buttonPadding = theme.buttonPadding[buttonSize];
  313. return {
  314. ...formStyles,
  315. ...buttonPadding,
  316. // If using translucent borders, rewrite size styles to
  317. // prevent layout shifts
  318. ...(translucentBorder && {
  319. height: formStyles.height - 2,
  320. minHeight: formStyles.minHeight - 2,
  321. paddingTop: buttonPadding.paddingTop - 1,
  322. paddingBottom: buttonPadding.paddingBottom - 1,
  323. margin: 1,
  324. }),
  325. };
  326. };
  327. const StyledButton = styled(
  328. reactForwardRef<any, ButtonProps>(
  329. (
  330. {forwardRef, size: _size, external, to, href, disabled, ...otherProps}: ButtonProps,
  331. forwardRefAlt
  332. ) => {
  333. // XXX: There may be two forwarded refs here, one potentially passed from a
  334. // wrapped Tooltip, another from callers of Button.
  335. const ref = mergeRefs([forwardRef, forwardRefAlt]);
  336. // only pass down title to child element if it is a string
  337. const {title, ...props} = otherProps;
  338. if (typeof title === 'string') {
  339. props[title] = title;
  340. }
  341. // Get component to use based on existence of `to` or `href` properties
  342. // Can be react-router `Link`, `a`, or `button`
  343. if (to) {
  344. return <Link ref={ref} to={to} disabled={disabled} {...props} />;
  345. }
  346. if (!href) {
  347. return <button ref={ref} disabled={disabled} {...props} />;
  348. }
  349. if (external && href) {
  350. return <ExternalLink ref={ref} href={href} disabled={disabled} {...props} />;
  351. }
  352. return <a ref={ref} {...props} href={href} />;
  353. }
  354. ),
  355. {
  356. shouldForwardProp: prop =>
  357. prop === 'forwardRef' ||
  358. prop === 'external' ||
  359. (typeof prop === 'string' && isPropValid(prop)),
  360. }
  361. )<ButtonProps>`
  362. display: inline-block;
  363. border-radius: ${p => p.theme.button.borderRadius};
  364. text-transform: none;
  365. font-weight: 600;
  366. ${getColors};
  367. ${getSizeStyles}
  368. ${getBoxShadow};
  369. cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
  370. opacity: ${p => (p.busy || p.disabled) && '0.65'};
  371. transition: background 0.1s, border 0.1s, box-shadow 0.1s;
  372. ${p => p.priority === 'link' && `font-size: inherit; font-weight: inherit; padding: 0;`}
  373. ${p => p.size === 'zero' && `height: auto; min-height: auto; padding: ${space(0.25)};`}
  374. &:focus {
  375. outline: none;
  376. }
  377. `;
  378. const buttonLabelPropKeys = ['size', 'borderless', 'align'];
  379. type ButtonLabelProps = Pick<ButtonProps, 'size' | 'borderless' | 'align'>;
  380. const ButtonLabel = styled('span', {
  381. shouldForwardProp: prop =>
  382. typeof prop === 'string' && isPropValid(prop) && !buttonLabelPropKeys.includes(prop),
  383. })<ButtonLabelProps>`
  384. height: 100%;
  385. display: flex;
  386. align-items: center;
  387. justify-content: ${p => p.align};
  388. white-space: nowrap;
  389. `;
  390. type IconProps = {
  391. hasChildren?: boolean;
  392. size?: ButtonProps['size'];
  393. };
  394. const getIconMargin = ({size, hasChildren}: IconProps) => {
  395. // If button is only an icon, then it shouldn't have margin
  396. if (!hasChildren) {
  397. return '0';
  398. }
  399. return size && ['xsmall', 'zero'].includes(size) ? '6px' : '8px';
  400. };
  401. const Icon = styled('span')<IconProps & Omit<StyledButtonProps, 'theme'>>`
  402. display: flex;
  403. align-items: center;
  404. margin-right: ${getIconMargin};
  405. `;
  406. /**
  407. * Also export these styled components so we can use them as selectors
  408. */
  409. export {StyledButton, ButtonLabel, Icon};