button.tsx 12 KB

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