button.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. import {forwardRef as reactForwardRef} from 'react';
  2. import isPropValid from '@emotion/is-prop-valid';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import ExternalLink from 'sentry/components/links/externalLink';
  6. import Link from 'sentry/components/links/link';
  7. import Tooltip from 'sentry/components/tooltip';
  8. import space from 'sentry/styles/space';
  9. import mergeRefs from 'sentry/utils/mergeRefs';
  10. import {Theme} from 'sentry/utils/theme';
  11. /**
  12. * The button can actually also be an anchor or React router Link (which seems
  13. * to be poorly typed as `any`). So this is a bit of a workaround to receive
  14. * the proper html attributes.
  15. */
  16. type ButtonElement = HTMLButtonElement & HTMLAnchorElement & any;
  17. type TooltipProps = React.ComponentProps<typeof Tooltip>;
  18. interface BaseButtonProps
  19. extends Omit<
  20. React.ButtonHTMLAttributes<ButtonElement>,
  21. 'ref' | 'label' | 'size' | 'title'
  22. > {
  23. align?: 'center' | 'left' | 'right';
  24. // This is only used with `<ButtonBar>`
  25. barId?: string;
  26. borderless?: boolean;
  27. busy?: boolean;
  28. disabled?: boolean;
  29. download?: HTMLAnchorElement['download'];
  30. external?: boolean;
  31. forwardRef?: React.Ref<ButtonElement>;
  32. href?: string;
  33. icon?: React.ReactNode;
  34. name?: string;
  35. onClick?: (e: React.MouseEvent) => void;
  36. priority?: 'default' | 'primary' | 'danger' | 'link' | 'success' | 'form';
  37. rel?: HTMLAnchorElement['rel'];
  38. size?: 'zero' | 'xsmall' | 'small';
  39. target?: HTMLAnchorElement['target'];
  40. title?: TooltipProps['title'];
  41. to?: string | object;
  42. tooltipProps?: Omit<TooltipProps, 'children' | 'title' | 'skipWrapper'>;
  43. translucentBorder?: boolean;
  44. }
  45. export interface ButtonPropsWithoutAriaLabel extends BaseButtonProps {
  46. children: React.ReactNode;
  47. }
  48. export interface ButtonPropsWithAriaLabel extends BaseButtonProps {
  49. 'aria-label': string;
  50. children?: never;
  51. }
  52. export type ButtonProps = ButtonPropsWithoutAriaLabel | ButtonPropsWithAriaLabel;
  53. type Url = ButtonProps['to'] | ButtonProps['href'];
  54. function BaseButton({
  55. size,
  56. to,
  57. busy,
  58. href,
  59. title,
  60. icon,
  61. children,
  62. 'aria-label': ariaLabel,
  63. borderless,
  64. translucentBorder,
  65. align = 'center',
  66. priority,
  67. disabled = false,
  68. tooltipProps,
  69. onClick,
  70. ...buttonProps
  71. }: ButtonProps) {
  72. // Intercept onClick and propagate
  73. function handleClick(e: React.MouseEvent) {
  74. // Don't allow clicks when disabled or busy
  75. if (disabled || busy) {
  76. e.preventDefault();
  77. e.stopPropagation();
  78. return;
  79. }
  80. if (typeof onClick !== 'function') {
  81. return;
  82. }
  83. onClick(e);
  84. }
  85. function getUrl<T extends Url>(prop: T): T | undefined {
  86. return disabled ? undefined : prop;
  87. }
  88. // Fallbacking aria-label to string children is not necessary as screen readers natively understand that scenario.
  89. // Leaving it here for a bunch of our tests that query by aria-label.
  90. const screenReaderLabel =
  91. ariaLabel || (typeof children === 'string' ? children : undefined);
  92. const hasChildren = Array.isArray(children)
  93. ? children.some(child => !!child)
  94. : !!children;
  95. // Buttons come in 4 flavors: <Link>, <ExternalLink>, <a>, and <button>.
  96. // Let's use props to determine which to serve up, so we don't have to think about it.
  97. // *Note* you must still handle tabindex manually.
  98. const button = (
  99. <StyledButton
  100. aria-label={screenReaderLabel}
  101. aria-disabled={disabled}
  102. disabled={disabled}
  103. to={getUrl(to)}
  104. href={getUrl(href)}
  105. size={size}
  106. priority={priority}
  107. borderless={borderless}
  108. translucentBorder={translucentBorder}
  109. {...buttonProps}
  110. onClick={handleClick}
  111. role="button"
  112. >
  113. <ButtonLabel align={align} size={size} borderless={borderless}>
  114. {icon && (
  115. <Icon size={size} hasChildren={hasChildren}>
  116. {icon}
  117. </Icon>
  118. )}
  119. {children}
  120. </ButtonLabel>
  121. </StyledButton>
  122. );
  123. // Doing this instead of using `Tooltip`'s `disabled` prop so that we can minimize snapshot nesting
  124. if (title) {
  125. return (
  126. <Tooltip skipWrapper {...tooltipProps} title={title}>
  127. {button}
  128. </Tooltip>
  129. );
  130. }
  131. return button;
  132. }
  133. const Button = reactForwardRef<ButtonElement, ButtonProps>((props, ref) => (
  134. <BaseButton forwardRef={ref} {...props} />
  135. ));
  136. Button.displayName = 'Button';
  137. export default Button;
  138. type StyledButtonProps = ButtonProps & {theme: Theme};
  139. const getBoxShadow = ({
  140. priority,
  141. borderless,
  142. translucentBorder,
  143. disabled,
  144. theme,
  145. }: StyledButtonProps) => {
  146. const themeName = disabled ? 'disabled' : priority || 'default';
  147. const {borderTranslucent} = theme.button[themeName];
  148. const translucentBorderString = translucentBorder
  149. ? `0 0 0 1px ${borderTranslucent},`
  150. : '';
  151. if (disabled || borderless || priority === 'link') {
  152. return 'box-shadow: none';
  153. }
  154. return `
  155. box-shadow: ${translucentBorderString} ${theme.dropShadowLight};
  156. &:active {
  157. box-shadow: ${translucentBorderString} inset ${theme.dropShadowLight};
  158. }
  159. `;
  160. };
  161. const getColors = ({
  162. size,
  163. priority,
  164. disabled,
  165. borderless,
  166. translucentBorder,
  167. theme,
  168. }: StyledButtonProps) => {
  169. const themeName = disabled ? 'disabled' : priority || 'default';
  170. const {
  171. color,
  172. colorActive,
  173. background,
  174. backgroundActive,
  175. border,
  176. borderActive,
  177. focusBorder,
  178. focusShadow,
  179. } = theme.button[themeName];
  180. const getFocusState = () => {
  181. switch (priority) {
  182. case 'primary':
  183. case 'success':
  184. case 'danger':
  185. return `
  186. border-color: ${focusBorder};
  187. box-shadow: ${focusBorder} 0 0 0 1px, ${focusShadow} 0 0 0 4px;`;
  188. default:
  189. if (translucentBorder) {
  190. return `
  191. border-color: ${focusBorder};
  192. box-shadow: ${focusBorder} 0 0 0 2px;`;
  193. }
  194. return `
  195. border-color: ${focusBorder};
  196. box-shadow: ${focusBorder} 0 0 0 1px;`;
  197. }
  198. };
  199. const getBackgroundColor = () => {
  200. switch (priority) {
  201. case 'primary':
  202. case 'success':
  203. case 'danger':
  204. return `background-color: ${background};`;
  205. default:
  206. if (borderless) {
  207. return `background-color: transparent;`;
  208. }
  209. return `background-color: ${background};`;
  210. }
  211. };
  212. return css`
  213. color: ${color};
  214. ${getBackgroundColor()}
  215. border: 1px solid ${borderless || priority === 'link' ? 'transparent' : border};
  216. ${translucentBorder && `border-width: 0;`}
  217. &:hover {
  218. color: ${color};
  219. }
  220. ${size !== 'zero' &&
  221. `
  222. &:hover,
  223. &:active,
  224. &.focus-visible,
  225. &[aria-expanded="true"] {
  226. color: ${colorActive || color};
  227. background: ${backgroundActive};
  228. border-color: ${borderless || priority === 'link' ? 'transparent' : borderActive};
  229. }`}
  230. &.focus-visible {
  231. ${getFocusState()}
  232. z-index: 1;
  233. }
  234. `;
  235. };
  236. const getSizeStyles = ({size, translucentBorder, theme}: StyledButtonProps) => {
  237. const buttonSize = size === 'small' || size === 'xsmall' ? size : 'default';
  238. const formStyles = theme.form[buttonSize];
  239. const buttonPadding = theme.buttonPadding[buttonSize];
  240. return {
  241. ...formStyles,
  242. ...buttonPadding,
  243. // If using translucent borders, rewrite size styles to
  244. // prevent layout shifts
  245. ...(translucentBorder && {
  246. height: formStyles.height - 2,
  247. minHeight: formStyles.minHeight - 2,
  248. paddingTop: buttonPadding.paddingTop - 1,
  249. paddingBottom: buttonPadding.paddingBottom - 1,
  250. margin: 1,
  251. }),
  252. };
  253. };
  254. const StyledButton = styled(
  255. reactForwardRef<any, ButtonProps>(
  256. (
  257. {forwardRef, size: _size, external, to, href, disabled, ...otherProps}: ButtonProps,
  258. forwardRefAlt
  259. ) => {
  260. // XXX: There may be two forwarded refs here, one potentially passed from a
  261. // wrapped Tooltip, another from callers of Button.
  262. const ref = mergeRefs([forwardRef, forwardRefAlt]);
  263. // only pass down title to child element if it is a string
  264. const {title, ...props} = otherProps;
  265. if (typeof title === 'string') {
  266. props[title] = title;
  267. }
  268. // Get component to use based on existence of `to` or `href` properties
  269. // Can be react-router `Link`, `a`, or `button`
  270. if (to) {
  271. return <Link ref={ref} to={to} disabled={disabled} {...props} />;
  272. }
  273. if (!href) {
  274. return <button ref={ref} disabled={disabled} {...props} />;
  275. }
  276. if (external && href) {
  277. return <ExternalLink ref={ref} href={href} disabled={disabled} {...props} />;
  278. }
  279. return <a ref={ref} {...props} href={href} />;
  280. }
  281. ),
  282. {
  283. shouldForwardProp: prop =>
  284. prop === 'forwardRef' ||
  285. prop === 'external' ||
  286. (typeof prop === 'string' && isPropValid(prop)),
  287. }
  288. )<ButtonProps>`
  289. display: inline-block;
  290. border-radius: ${p => p.theme.button.borderRadius};
  291. text-transform: none;
  292. font-weight: 600;
  293. ${getColors};
  294. ${getSizeStyles}
  295. ${getBoxShadow};
  296. cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
  297. opacity: ${p => (p.busy || p.disabled) && '0.65'};
  298. transition: background 0.1s, border 0.1s, box-shadow 0.1s;
  299. ${p => p.priority === 'link' && `font-size: inherit; font-weight: inherit; padding: 0;`}
  300. ${p => p.size === 'zero' && `height: auto; min-height: auto; padding: ${space(0.25)};`}
  301. &:focus {
  302. outline: none;
  303. }
  304. `;
  305. const buttonLabelPropKeys = ['size', 'borderless', 'align'];
  306. type ButtonLabelProps = Pick<ButtonProps, 'size' | 'borderless' | 'align'>;
  307. const ButtonLabel = styled('span', {
  308. shouldForwardProp: prop =>
  309. typeof prop === 'string' && isPropValid(prop) && !buttonLabelPropKeys.includes(prop),
  310. })<ButtonLabelProps>`
  311. height: 100%;
  312. display: flex;
  313. align-items: center;
  314. justify-content: ${p => p.align};
  315. white-space: nowrap;
  316. `;
  317. type IconProps = {
  318. hasChildren?: boolean;
  319. size?: ButtonProps['size'];
  320. };
  321. const getIconMargin = ({size, hasChildren}: IconProps) => {
  322. // If button is only an icon, then it shouldn't have margin
  323. if (!hasChildren) {
  324. return '0';
  325. }
  326. return size === 'xsmall' ? '6px' : '8px';
  327. };
  328. const Icon = styled('span')<IconProps & Omit<StyledButtonProps, 'theme'>>`
  329. display: flex;
  330. align-items: center;
  331. margin-right: ${getIconMargin};
  332. `;
  333. /**
  334. * Also export these styled components so we can use them as selectors
  335. */
  336. export {StyledButton, ButtonLabel, Icon};