button.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  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, TooltipProps} 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;
  18. /**
  19. * Props shared across different types of button components
  20. */
  21. interface CommonButtonProps {
  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. * The button is an external link. Similar to the `Link` `external` property.
  54. */
  55. external?: boolean;
  56. /**
  57. * The icon to render inside of the button. The size will be set
  58. * appropriately based on the size of the button.
  59. */
  60. icon?: React.ReactNode;
  61. /**
  62. * Used when the button is part of a form.
  63. */
  64. name?: string;
  65. /**
  66. * The semantic "priority" of the button. Use `primary` when the action is
  67. * contextually the primary action, `danger` if the button will do something
  68. * destructive, `link` for visual similarity to a link.
  69. */
  70. priority?: 'default' | 'primary' | 'danger' | 'link';
  71. /**
  72. * The size of the button
  73. */
  74. size?: 'zero' | 'xs' | 'sm' | 'md';
  75. /**
  76. * Display a tooltip for the button.
  77. */
  78. title?: TooltipProps['title'];
  79. /**
  80. * Additional properites for the Tooltip when `title` is set.
  81. */
  82. tooltipProps?: Omit<TooltipProps, 'children' | 'title' | 'skipWrapper'>;
  83. /**
  84. * Userful in scenarios where the border of the button should blend with the
  85. * background behind the button.
  86. */
  87. translucentBorder?: boolean;
  88. }
  89. /**
  90. * Helper type to extraxct the HTML element props for use in button prop
  91. * interfaces.
  92. *
  93. * XXX(epurkhiser): Right now all usages of this use ButtonElement, but in the
  94. * future ButtonElement should go away and be replaced with HTMLButtonElement
  95. * and HTMLAnchorElement respectively
  96. */
  97. type ElementProps<E> = Omit<React.ButtonHTMLAttributes<E>, 'label' | 'size' | 'title'>;
  98. interface BaseButtonProps extends CommonButtonProps, ElementProps<ButtonElement> {
  99. /**
  100. * For use with `href` and `data:` or `blob:` schemes. Tells the browser to
  101. * download the contents.
  102. *
  103. * See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-download
  104. *
  105. * @deprecated Use LnikButton instead
  106. */
  107. download?: HTMLAnchorElement['download'];
  108. /**
  109. * @internal Used in the Button forwardRef
  110. */
  111. forwardRef?: React.Ref<ButtonElement>;
  112. /**
  113. * When set the button acts as an anchor link. Use with `external` to have
  114. * the link open in a new tab.
  115. *
  116. * @deprecated Use LinkButton instead
  117. */
  118. href?: string;
  119. /**
  120. * Similar to `href`, but for internal links within the app.
  121. *
  122. * @deprecated Use LinkButton instead
  123. */
  124. to?: string | object;
  125. }
  126. interface ButtonPropsWithoutAriaLabel extends BaseButtonProps {
  127. children: React.ReactNode;
  128. }
  129. interface ButtonPropsWithAriaLabel extends BaseButtonProps {
  130. 'aria-label': string;
  131. children?: never;
  132. }
  133. type ButtonProps = ButtonPropsWithoutAriaLabel | ButtonPropsWithAriaLabel;
  134. interface BaseLinkButtonProps extends CommonButtonProps, ElementProps<ButtonElement> {
  135. /**
  136. * @internal Used in the Button forwardRef
  137. */
  138. forwardRef?: React.Ref<ButtonElement>;
  139. }
  140. interface ToLinkButtonProps extends BaseLinkButtonProps {
  141. /**
  142. * Similar to `href`, but for internal links within the app.
  143. */
  144. to: string | object;
  145. }
  146. interface HrefLinkButtonProps extends BaseLinkButtonProps {
  147. /**
  148. * When set the button acts as an anchor link. Use with `external` to have
  149. * the link open in a new tab.
  150. */
  151. href: string;
  152. /**
  153. * For use with `href` and `data:` or `blob:` schemes. Tells the browser to
  154. * download the contents.
  155. *
  156. * See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-download
  157. */
  158. download?: HTMLAnchorElement['download'];
  159. }
  160. interface ToLinkButtonPropsWithChildren extends ToLinkButtonProps {
  161. children: React.ReactNode;
  162. }
  163. interface ToLinkButtonPropsWithAriaLabel extends ToLinkButtonProps {
  164. 'aria-label': string;
  165. children?: never;
  166. }
  167. interface HrefLinkButtonPropsWithChildren extends HrefLinkButtonProps {
  168. children: React.ReactNode;
  169. }
  170. interface HrefLinkButtonPropsWithAriaLabel extends HrefLinkButtonProps {
  171. 'aria-label': string;
  172. children?: never;
  173. }
  174. type LinkButtonProps =
  175. | ToLinkButtonPropsWithChildren
  176. | ToLinkButtonPropsWithAriaLabel
  177. | HrefLinkButtonPropsWithChildren
  178. | HrefLinkButtonPropsWithAriaLabel;
  179. function BaseButton({
  180. size = 'md',
  181. to,
  182. busy,
  183. href,
  184. title,
  185. icon,
  186. children,
  187. 'aria-label': ariaLabel,
  188. borderless,
  189. translucentBorder,
  190. priority,
  191. disabled = false,
  192. tooltipProps,
  193. onClick,
  194. analyticsEventName,
  195. analyticsEventKey,
  196. analyticsParams,
  197. ...buttonProps
  198. }: ButtonProps) {
  199. // Fallbacking aria-label to string children is not necessary as screen
  200. // readers natively understand that scenario. Leaving it here for a bunch of
  201. // our tests that query by aria-label.
  202. const accessibleLabel =
  203. ariaLabel ?? (typeof children === 'string' ? children : undefined);
  204. const useButtonTrackingLogger = () => {
  205. const hasAnalyticsDebug = window.localStorage?.getItem('DEBUG_ANALYTICS') === '1';
  206. const hasCustomAnalytics = analyticsEventName || analyticsEventKey || analyticsParams;
  207. if (!hasCustomAnalytics || !hasAnalyticsDebug) {
  208. return () => {};
  209. }
  210. return () => {
  211. // eslint-disable-next-line no-console
  212. console.log('buttonAnalyticsEvent', {
  213. eventKey: analyticsEventKey,
  214. eventName: analyticsEventName,
  215. priority,
  216. href,
  217. ...analyticsParams,
  218. });
  219. };
  220. };
  221. const useButtonTracking =
  222. HookStore.get('react-hook:use-button-tracking')[0] ?? useButtonTrackingLogger;
  223. const buttonTracking = useButtonTracking({
  224. analyticsEventName,
  225. analyticsEventKey,
  226. analyticsParams: {
  227. priority,
  228. href,
  229. ...analyticsParams,
  230. },
  231. 'aria-label': accessibleLabel || '',
  232. });
  233. const handleClick = useCallback(
  234. (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
  235. // Don't allow clicks when disabled or busy
  236. if (disabled || busy) {
  237. e.preventDefault();
  238. e.stopPropagation();
  239. return;
  240. }
  241. buttonTracking();
  242. onClick?.(e);
  243. },
  244. [disabled, busy, onClick, buttonTracking]
  245. );
  246. const hasChildren = Array.isArray(children)
  247. ? children.some(child => !!child)
  248. : !!children;
  249. // Buttons come in 4 flavors: <Link>, <ExternalLink>, <a>, and <button>.
  250. // Let's use props to determine which to serve up, so we don't have to think about it.
  251. // *Note* you must still handle tabindex manually.
  252. const button = (
  253. <StyledButton
  254. aria-label={accessibleLabel}
  255. aria-disabled={disabled}
  256. busy={busy}
  257. disabled={disabled}
  258. to={!disabled ? to : undefined}
  259. href={!disabled ? href : undefined}
  260. size={size}
  261. priority={priority}
  262. borderless={borderless}
  263. translucentBorder={translucentBorder}
  264. {...buttonProps}
  265. onClick={handleClick}
  266. role="button"
  267. >
  268. {priority !== 'link' && (
  269. <InteractionStateLayer
  270. higherOpacity={priority && ['primary', 'danger'].includes(priority)}
  271. />
  272. )}
  273. <ButtonLabel size={size} borderless={borderless}>
  274. {icon && (
  275. <Icon size={size} hasChildren={hasChildren}>
  276. {icon}
  277. </Icon>
  278. )}
  279. {children}
  280. </ButtonLabel>
  281. </StyledButton>
  282. );
  283. // Doing this instead of using `Tooltip`'s `disabled` prop so that we can minimize snapshot nesting
  284. if (title) {
  285. return (
  286. <Tooltip skipWrapper {...tooltipProps} title={title}>
  287. {button}
  288. </Tooltip>
  289. );
  290. }
  291. return button;
  292. }
  293. const Button = reactForwardRef<ButtonElement, ButtonProps>((props, ref) => (
  294. <BaseButton forwardRef={ref} {...props} />
  295. ));
  296. Button.displayName = 'Button';
  297. interface StyledButtonPropsWithAriaLabel extends ButtonPropsWithoutAriaLabel {
  298. theme: Theme;
  299. }
  300. interface StyledButtonPropsWithoutAriaLabel extends ButtonPropsWithAriaLabel {
  301. theme: Theme;
  302. }
  303. type StyledButtonProps =
  304. | StyledButtonPropsWithAriaLabel
  305. | StyledButtonPropsWithoutAriaLabel;
  306. const getBoxShadow = ({
  307. priority,
  308. borderless,
  309. translucentBorder,
  310. disabled,
  311. size,
  312. theme,
  313. }: StyledButtonProps) => {
  314. if (disabled || borderless || priority === 'link') {
  315. return 'box-shadow: none';
  316. }
  317. const themeName = disabled ? 'disabled' : priority || 'default';
  318. const {borderTranslucent} = theme.button[themeName];
  319. const translucentBorderString = translucentBorder
  320. ? `0 0 0 1px ${borderTranslucent},`
  321. : '';
  322. const dropShadow = size === 'xs' ? theme.dropShadowLight : theme.dropShadowMedium;
  323. return `
  324. box-shadow: ${translucentBorderString} ${dropShadow};
  325. &:active {
  326. box-shadow: ${translucentBorderString} inset ${dropShadow};
  327. }
  328. `;
  329. };
  330. const getColors = ({
  331. size,
  332. priority,
  333. disabled,
  334. borderless,
  335. translucentBorder,
  336. theme,
  337. }: StyledButtonProps) => {
  338. const themeName = disabled ? 'disabled' : priority || 'default';
  339. const {color, colorActive, background, border, borderActive, focusBorder, focusShadow} =
  340. theme.button[themeName];
  341. const getFocusState = () => {
  342. switch (priority) {
  343. case 'primary':
  344. case 'danger':
  345. return `
  346. border-color: ${focusBorder};
  347. box-shadow: ${focusBorder} 0 0 0 1px, ${focusShadow} 0 0 0 4px;`;
  348. default:
  349. if (translucentBorder) {
  350. return `
  351. border-color: ${focusBorder};
  352. box-shadow: ${focusBorder} 0 0 0 2px;`;
  353. }
  354. return `
  355. border-color: ${focusBorder};
  356. box-shadow: ${focusBorder} 0 0 0 1px;`;
  357. }
  358. };
  359. const getBackgroundColor = () => {
  360. switch (priority) {
  361. case 'primary':
  362. case 'danger':
  363. return `background-color: ${background};`;
  364. default:
  365. if (borderless) {
  366. return `background-color: transparent;`;
  367. }
  368. return `background-color: ${background};`;
  369. }
  370. };
  371. return css`
  372. color: ${color};
  373. ${getBackgroundColor()}
  374. border: 1px solid ${borderless || priority === 'link' ? 'transparent' : border};
  375. ${translucentBorder && `border-width: 0;`}
  376. &:hover {
  377. color: ${color};
  378. }
  379. ${size !== 'zero' &&
  380. `
  381. &:hover,
  382. &:active,
  383. &[aria-expanded="true"] {
  384. color: ${colorActive || color};
  385. border-color: ${borderless || priority === 'link' ? 'transparent' : borderActive};
  386. }
  387. &.focus-visible {
  388. color: ${colorActive || color};
  389. border-color: ${borderActive};
  390. }
  391. `}
  392. &.focus-visible {
  393. ${getFocusState()}
  394. z-index: 1;
  395. }
  396. `;
  397. };
  398. const getSizeStyles = ({size = 'md', translucentBorder, theme}: StyledButtonProps) => {
  399. const buttonSize = size === 'zero' ? 'md' : size;
  400. const formStyles = theme.form[buttonSize];
  401. const buttonPadding = theme.buttonPadding[buttonSize];
  402. // If using translucent borders, rewrite size styles to
  403. // prevent layout shifts
  404. const borderStyles = !translucentBorder
  405. ? {}
  406. : {
  407. height: formStyles.height - 2,
  408. minHeight: formStyles.minHeight - 2,
  409. paddingTop: buttonPadding.paddingTop - 1,
  410. paddingBottom: buttonPadding.paddingBottom - 1,
  411. margin: 1,
  412. };
  413. return {...formStyles, ...buttonPadding, ...borderStyles};
  414. };
  415. const StyledButton = styled(
  416. reactForwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(
  417. (
  418. {
  419. forwardRef,
  420. size: _size,
  421. title: _title,
  422. external,
  423. to,
  424. href,
  425. disabled,
  426. ...props
  427. }: ButtonProps,
  428. forwardRefAlt
  429. ) => {
  430. // XXX: There may be two forwarded refs here, one potentially passed from a
  431. // wrapped Tooltip, another from callers of Button.
  432. const ref = mergeRefs([forwardRef, forwardRefAlt]);
  433. // Get component to use based on existence of `to` or `href` properties
  434. // Can be react-router `Link`, `a`, or `button`
  435. if (to) {
  436. return <Link {...props} ref={ref} to={to} disabled={disabled} />;
  437. }
  438. if (href && external) {
  439. return <ExternalLink {...props} ref={ref} href={href} disabled={disabled} />;
  440. }
  441. if (href) {
  442. return <a {...props} ref={ref} href={href} />;
  443. }
  444. // The default `type` of a native button element is `submit` when inside
  445. // of a form. This is typically not what we want, and if we do want it we
  446. // should explicitly set type submit.
  447. props.type ??= 'button';
  448. return <button {...props} ref={ref} disabled={disabled} />;
  449. }
  450. ),
  451. {
  452. shouldForwardProp: prop =>
  453. prop === 'forwardRef' ||
  454. prop === 'external' ||
  455. (typeof prop === 'string' && isPropValid(prop)),
  456. }
  457. )<ButtonProps>`
  458. position: relative;
  459. display: inline-block;
  460. border-radius: ${p => p.theme.borderRadius};
  461. text-transform: none;
  462. font-weight: 600;
  463. ${getColors};
  464. ${getSizeStyles};
  465. ${getBoxShadow};
  466. cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
  467. opacity: ${p => (p.busy || p.disabled) && '0.65'};
  468. transition:
  469. background 0.1s,
  470. border 0.1s,
  471. box-shadow 0.1s;
  472. ${p =>
  473. p.priority === 'link' &&
  474. `font-size: inherit; font-weight: inherit; padding: 0; height: auto; min-height: auto;`}
  475. ${p => p.size === 'zero' && `height: auto; min-height: auto; padding: ${space(0.25)};`}
  476. &:focus {
  477. outline: none;
  478. }
  479. `;
  480. const buttonLabelPropKeys = ['size', 'borderless'];
  481. type ButtonLabelProps = Pick<ButtonProps, 'size' | 'borderless'>;
  482. const ButtonLabel = styled('span', {
  483. shouldForwardProp: prop =>
  484. typeof prop === 'string' && isPropValid(prop) && !buttonLabelPropKeys.includes(prop),
  485. })<ButtonLabelProps>`
  486. height: 100%;
  487. display: flex;
  488. align-items: center;
  489. justify-content: center;
  490. white-space: nowrap;
  491. `;
  492. type ChildrenIconProps = {
  493. hasChildren?: boolean;
  494. size?: ButtonProps['size'];
  495. };
  496. const getIconMargin = ({size, hasChildren}: ChildrenIconProps) => {
  497. // If button is only an icon, then it shouldn't have margin
  498. if (!hasChildren) {
  499. return '0';
  500. }
  501. switch (size) {
  502. case 'xs':
  503. case 'zero':
  504. return space(0.75);
  505. default:
  506. return space(1);
  507. }
  508. };
  509. interface IconProps extends ChildrenIconProps, Omit<StyledButtonProps, 'theme'> {}
  510. const Icon = styled('span')<IconProps>`
  511. display: flex;
  512. align-items: center;
  513. margin-right: ${getIconMargin};
  514. flex-shrink: 0;
  515. `;
  516. const LinkButton = Button as React.ComponentType<LinkButtonProps>;
  517. export {
  518. Button,
  519. ButtonProps,
  520. BaseButtonProps,
  521. LinkButton,
  522. LinkButtonProps,
  523. // Also export these styled components so we can use them as selectors
  524. StyledButton,
  525. ButtonLabel,
  526. };