button.tsx 16 KB

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