button.tsx 13 KB

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