button.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  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 LnikButton 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 useButtonTracking = HookStore.get('react-hook:use-button-tracking')[0];
  205. const buttonTracking = useButtonTracking?.({
  206. analyticsEventName,
  207. analyticsEventKey,
  208. analyticsParams: {
  209. priority,
  210. href,
  211. ...analyticsParams,
  212. },
  213. 'aria-label': accessibleLabel || '',
  214. });
  215. const handleClick = useCallback(
  216. (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
  217. // Don't allow clicks when disabled or busy
  218. if (disabled || busy) {
  219. e.preventDefault();
  220. e.stopPropagation();
  221. return;
  222. }
  223. buttonTracking?.();
  224. onClick?.(e);
  225. },
  226. [disabled, busy, onClick, buttonTracking]
  227. );
  228. const hasChildren = Array.isArray(children)
  229. ? children.some(child => !!child)
  230. : !!children;
  231. // Buttons come in 4 flavors: <Link>, <ExternalLink>, <a>, and <button>.
  232. // Let's use props to determine which to serve up, so we don't have to think about it.
  233. // *Note* you must still handle tabindex manually.
  234. const button = (
  235. <StyledButton
  236. aria-label={accessibleLabel}
  237. aria-disabled={disabled}
  238. busy={busy}
  239. disabled={disabled}
  240. to={!disabled ? to : undefined}
  241. href={!disabled ? href : undefined}
  242. size={size}
  243. priority={priority}
  244. borderless={borderless}
  245. translucentBorder={translucentBorder}
  246. {...buttonProps}
  247. onClick={handleClick}
  248. role="button"
  249. >
  250. {priority !== 'link' && (
  251. <InteractionStateLayer
  252. higherOpacity={priority && ['primary', 'danger'].includes(priority)}
  253. />
  254. )}
  255. <ButtonLabel size={size} borderless={borderless}>
  256. {icon && (
  257. <Icon size={size} hasChildren={hasChildren}>
  258. {icon}
  259. </Icon>
  260. )}
  261. {children}
  262. </ButtonLabel>
  263. </StyledButton>
  264. );
  265. // Doing this instead of using `Tooltip`'s `disabled` prop so that we can minimize snapshot nesting
  266. if (title) {
  267. return (
  268. <Tooltip skipWrapper {...tooltipProps} title={title}>
  269. {button}
  270. </Tooltip>
  271. );
  272. }
  273. return button;
  274. }
  275. const Button = reactForwardRef<ButtonElement, ButtonProps>((props, ref) => (
  276. <BaseButton forwardRef={ref} {...props} />
  277. ));
  278. Button.displayName = 'Button';
  279. interface StyledButtonPropsWithAriaLabel extends ButtonPropsWithoutAriaLabel {
  280. theme: Theme;
  281. }
  282. interface StyledButtonPropsWithoutAriaLabel extends ButtonPropsWithAriaLabel {
  283. theme: Theme;
  284. }
  285. type StyledButtonProps =
  286. | StyledButtonPropsWithAriaLabel
  287. | StyledButtonPropsWithoutAriaLabel;
  288. const getBoxShadow = ({
  289. priority,
  290. borderless,
  291. translucentBorder,
  292. disabled,
  293. size,
  294. theme,
  295. }: StyledButtonProps) => {
  296. if (disabled || borderless || priority === 'link') {
  297. return 'box-shadow: none';
  298. }
  299. const themeName = disabled ? 'disabled' : priority || 'default';
  300. const {borderTranslucent} = theme.button[themeName];
  301. const translucentBorderString = translucentBorder
  302. ? `0 0 0 1px ${borderTranslucent},`
  303. : '';
  304. const dropShadow = size === 'xs' ? theme.dropShadowLight : theme.dropShadowMedium;
  305. return `
  306. box-shadow: ${translucentBorderString} ${dropShadow};
  307. &:active {
  308. box-shadow: ${translucentBorderString} inset ${dropShadow};
  309. }
  310. `;
  311. };
  312. const getColors = ({
  313. size,
  314. priority,
  315. disabled,
  316. borderless,
  317. translucentBorder,
  318. theme,
  319. }: StyledButtonProps) => {
  320. const themeName = disabled ? 'disabled' : priority || 'default';
  321. const {color, colorActive, background, border, borderActive, focusBorder, focusShadow} =
  322. theme.button[themeName];
  323. const getFocusState = () => {
  324. switch (priority) {
  325. case 'primary':
  326. case 'danger':
  327. return `
  328. border-color: ${focusBorder};
  329. box-shadow: ${focusBorder} 0 0 0 1px, ${focusShadow} 0 0 0 4px;`;
  330. default:
  331. if (translucentBorder) {
  332. return `
  333. border-color: ${focusBorder};
  334. box-shadow: ${focusBorder} 0 0 0 2px;`;
  335. }
  336. return `
  337. border-color: ${focusBorder};
  338. box-shadow: ${focusBorder} 0 0 0 1px;`;
  339. }
  340. };
  341. const getBackgroundColor = () => {
  342. switch (priority) {
  343. case 'primary':
  344. case 'danger':
  345. return `background-color: ${background};`;
  346. default:
  347. if (borderless) {
  348. return `background-color: transparent;`;
  349. }
  350. return `background-color: ${background};`;
  351. }
  352. };
  353. return css`
  354. color: ${color};
  355. ${getBackgroundColor()}
  356. border: 1px solid ${borderless || priority === 'link' ? 'transparent' : border};
  357. ${translucentBorder && `border-width: 0;`}
  358. &:hover {
  359. color: ${color};
  360. }
  361. ${size !== 'zero' &&
  362. `
  363. &:hover,
  364. &:active,
  365. &[aria-expanded="true"] {
  366. color: ${colorActive || color};
  367. border-color: ${borderless || priority === 'link' ? 'transparent' : borderActive};
  368. }
  369. &.focus-visible {
  370. color: ${colorActive || color};
  371. border-color: ${borderActive};
  372. }
  373. `}
  374. &.focus-visible {
  375. ${getFocusState()}
  376. z-index: 1;
  377. }
  378. `;
  379. };
  380. const getSizeStyles = ({size = 'md', translucentBorder, theme}: StyledButtonProps) => {
  381. const buttonSize = size === 'zero' ? 'md' : size;
  382. const formStyles = theme.form[buttonSize];
  383. const buttonPadding = theme.buttonPadding[buttonSize];
  384. // If using translucent borders, rewrite size styles to
  385. // prevent layout shifts
  386. const borderStyles = !translucentBorder
  387. ? {}
  388. : {
  389. height: formStyles.height - 2,
  390. minHeight: formStyles.minHeight - 2,
  391. paddingTop: buttonPadding.paddingTop - 1,
  392. paddingBottom: buttonPadding.paddingBottom - 1,
  393. margin: 1,
  394. };
  395. return {...formStyles, ...buttonPadding, ...borderStyles};
  396. };
  397. const StyledButton = styled(
  398. reactForwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(
  399. (
  400. {
  401. forwardRef,
  402. size: _size,
  403. title: _title,
  404. external,
  405. to,
  406. href,
  407. disabled,
  408. ...props
  409. }: ButtonProps,
  410. forwardRefAlt
  411. ) => {
  412. // XXX: There may be two forwarded refs here, one potentially passed from a
  413. // wrapped Tooltip, another from callers of Button.
  414. const ref = mergeRefs([forwardRef, forwardRefAlt]);
  415. // Get component to use based on existence of `to` or `href` properties
  416. // Can be react-router `Link`, `a`, or `button`
  417. if (to) {
  418. return <Link {...props} ref={ref} to={to} disabled={disabled} />;
  419. }
  420. if (href && external) {
  421. return <ExternalLink {...props} ref={ref} href={href} disabled={disabled} />;
  422. }
  423. if (href) {
  424. return <a {...props} ref={ref} href={href} />;
  425. }
  426. // The default `type` of a native button element is `submit` when inside
  427. // of a form. This is typically not what we want, and if we do want it we
  428. // should explicitly set type submit.
  429. props.type ??= 'button';
  430. return <button {...props} ref={ref} disabled={disabled} />;
  431. }
  432. ),
  433. {
  434. shouldForwardProp: prop =>
  435. prop === 'forwardRef' ||
  436. prop === 'external' ||
  437. (typeof prop === 'string' && isPropValid(prop)),
  438. }
  439. )<ButtonProps>`
  440. position: relative;
  441. display: inline-block;
  442. border-radius: ${p => p.theme.borderRadius};
  443. text-transform: none;
  444. font-weight: 600;
  445. ${getColors};
  446. ${getSizeStyles};
  447. ${getBoxShadow};
  448. cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
  449. opacity: ${p => (p.busy || p.disabled) && '0.65'};
  450. transition: background 0.1s, border 0.1s, box-shadow 0.1s;
  451. ${p =>
  452. p.priority === 'link' &&
  453. `font-size: inherit; font-weight: inherit; padding: 0; height: auto; min-height: auto;`}
  454. ${p => p.size === 'zero' && `height: auto; min-height: auto; padding: ${space(0.25)};`}
  455. &:focus {
  456. outline: none;
  457. }
  458. `;
  459. const buttonLabelPropKeys = ['size', 'borderless'];
  460. type ButtonLabelProps = Pick<ButtonProps, 'size' | 'borderless'>;
  461. const ButtonLabel = styled('span', {
  462. shouldForwardProp: prop =>
  463. typeof prop === 'string' && isPropValid(prop) && !buttonLabelPropKeys.includes(prop),
  464. })<ButtonLabelProps>`
  465. height: 100%;
  466. display: flex;
  467. align-items: center;
  468. justify-content: center;
  469. white-space: nowrap;
  470. `;
  471. type ChildrenIconProps = {
  472. hasChildren?: boolean;
  473. size?: ButtonProps['size'];
  474. };
  475. const getIconMargin = ({size, hasChildren}: ChildrenIconProps) => {
  476. // If button is only an icon, then it shouldn't have margin
  477. if (!hasChildren) {
  478. return '0';
  479. }
  480. switch (size) {
  481. case 'xs':
  482. case 'zero':
  483. return space(0.75);
  484. default:
  485. return space(1);
  486. }
  487. };
  488. interface IconProps extends ChildrenIconProps, Omit<StyledButtonProps, 'theme'> {}
  489. const Icon = styled('span')<IconProps>`
  490. display: flex;
  491. align-items: center;
  492. margin-right: ${getIconMargin};
  493. flex-shrink: 0;
  494. `;
  495. const LinkButton = Button as React.ComponentType<LinkButtonProps>;
  496. export {
  497. Button,
  498. ButtonProps,
  499. LinkButton,
  500. LinkButtonProps,
  501. // Also export these styled components so we can use them as selectors
  502. StyledButton,
  503. ButtonLabel,
  504. };