button.tsx 9.6 KB

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