button.tsx 13 KB

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