alert.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import {useRef, useState} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {useHover} from '@react-aria/interactions';
  5. import classNames from 'classnames';
  6. import {IconCheckmark, IconChevron, IconInfo, IconNot, IconWarning} from 'sentry/icons';
  7. import space from 'sentry/styles/space';
  8. import {defined} from 'sentry/utils';
  9. import {Theme} from 'sentry/utils/theme';
  10. export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
  11. expand?: React.ReactNode;
  12. icon?: React.ReactNode;
  13. opaque?: boolean;
  14. showIcon?: boolean;
  15. system?: boolean;
  16. trailingItems?: React.ReactNode;
  17. type?: keyof Theme['alert'];
  18. }
  19. const DEFAULT_TYPE = 'info';
  20. function Alert({
  21. type = DEFAULT_TYPE,
  22. showIcon = false,
  23. icon,
  24. opaque,
  25. system,
  26. expand,
  27. trailingItems,
  28. className,
  29. children,
  30. ...props
  31. }: AlertProps) {
  32. const [isExpanded, setIsExpanded] = useState(false);
  33. const showExpand = defined(expand);
  34. const showTrailingItems = defined(trailingItems);
  35. // Show the hover state (with darker borders) only when hovering over the
  36. // IconWrapper or MessageContainer.
  37. const {hoverProps, isHovered} = useHover({
  38. isDisabled: !showExpand,
  39. });
  40. const {hoverProps: expandHoverProps, isHovered: expandIsHovered} = useHover({
  41. isDisabled: !showExpand,
  42. });
  43. function getIcon() {
  44. switch (type) {
  45. case 'warning':
  46. return <IconWarning />;
  47. case 'success':
  48. return <IconCheckmark />;
  49. case 'error':
  50. return <IconNot />;
  51. case 'info':
  52. default:
  53. return <IconInfo />;
  54. }
  55. }
  56. const expandRef = useRef<HTMLDivElement>(null);
  57. function handleClick(e: React.MouseEvent<HTMLDivElement>) {
  58. if (
  59. // Only close the alert when the click event originated from outside the expanded
  60. // content.
  61. e.target === expandRef.current ||
  62. expandRef.current?.contains(e.target as HTMLDivElement)
  63. ) {
  64. return;
  65. }
  66. showExpand && setIsExpanded(!isExpanded);
  67. }
  68. return (
  69. <Wrap
  70. type={type}
  71. system={system}
  72. opaque={opaque}
  73. expand={expand}
  74. trailingItems={trailingItems}
  75. showIcon={showIcon}
  76. onClick={handleClick}
  77. hovered={isHovered && !expandIsHovered}
  78. className={classNames(type ? `ref-${type}` : '', className)}
  79. {...hoverProps}
  80. {...props}
  81. >
  82. {showIcon && <IconWrapper onClick={handleClick}>{icon ?? getIcon()}</IconWrapper>}
  83. <Message>{children}</Message>
  84. {showTrailingItems && (
  85. <TrailingItems showIcon={showIcon} onClick={e => e.stopPropagation()}>
  86. {trailingItems}
  87. </TrailingItems>
  88. )}
  89. {showExpand && (
  90. <ExpandIconWrap>
  91. <IconChevron direction={isExpanded ? 'up' : 'down'} />
  92. </ExpandIconWrap>
  93. )}
  94. {isExpanded && (
  95. <ExpandContainer
  96. ref={expandRef}
  97. showIcon={showIcon}
  98. showTrailingItems={showTrailingItems}
  99. {...expandHoverProps}
  100. >
  101. {Array.isArray(expand) ? expand.map(item => item) : expand}
  102. </ExpandContainer>
  103. )}
  104. </Wrap>
  105. );
  106. }
  107. const alertStyles = ({
  108. type = DEFAULT_TYPE,
  109. system,
  110. opaque,
  111. expand,
  112. showIcon,
  113. trailingItems,
  114. hovered,
  115. theme,
  116. }: AlertProps & {theme: Theme; hovered?: boolean}) => {
  117. const alertColors = theme.alert[type];
  118. const showExpand = defined(expand);
  119. const showTrailingItems = defined(trailingItems);
  120. return css`
  121. display: grid;
  122. grid-template-columns:
  123. ${showIcon && `minmax(0, max-content)`}
  124. minmax(0, 1fr)
  125. ${showTrailingItems && 'max-content'}
  126. ${showExpand && 'max-content'};
  127. gap: ${space(1)};
  128. margin: 0 0 ${space(2)};
  129. font-size: ${theme.fontSizeMedium};
  130. border-radius: ${theme.borderRadius};
  131. border: 1px solid ${alertColors.border};
  132. background: ${opaque
  133. ? `linear-gradient(
  134. ${alertColors.backgroundLight},
  135. ${alertColors.backgroundLight}),
  136. linear-gradient(${theme.background}, ${theme.background}
  137. )`
  138. : `${alertColors.backgroundLight}`};
  139. a:not([role='button']) {
  140. color: ${theme.textColor};
  141. text-decoration-color: ${theme.translucentBorder};
  142. text-decoration-style: solid;
  143. text-decoration-line: underline;
  144. text-decoration-thickness: 0.08em;
  145. text-underline-offset: 0.06em;
  146. }
  147. a:not([role='button']):hover {
  148. text-decoration-color: ${theme.subText};
  149. text-decoration-style: solid;
  150. }
  151. pre {
  152. background: ${alertColors.backgroundLight};
  153. margin: ${space(0.5)} 0 0;
  154. }
  155. ${IconWrapper}, ${ExpandIconWrap} {
  156. color: ${alertColors.iconColor};
  157. }
  158. ${hovered &&
  159. `
  160. border-color: ${alertColors.borderHover};
  161. ${IconWrapper}, ${IconChevron} {
  162. color: ${alertColors.iconHoverColor};
  163. }
  164. `}
  165. ${showExpand &&
  166. `cursor: pointer;
  167. ${TrailingItems} {
  168. cursor: auto;
  169. }
  170. `}
  171. ${system &&
  172. `
  173. border-width: 0 0 1px 0;
  174. border-radius: 0;
  175. `}
  176. `;
  177. };
  178. const Wrap = styled('div')<AlertProps & {hovered: boolean}>`
  179. ${alertStyles}
  180. padding: ${space(1.5)} ${space(2)};
  181. `;
  182. const IconWrapper = styled('div')`
  183. display: flex;
  184. align-items: center;
  185. height: calc(${p => p.theme.fontSizeMedium} * ${p => p.theme.text.lineHeightBody});
  186. `;
  187. const Message = styled('span')`
  188. position: relative;
  189. line-height: ${p => p.theme.text.lineHeightBody};
  190. `;
  191. const TrailingItems = styled('div')<{showIcon: boolean}>`
  192. height: calc(${p => p.theme.fontSizeMedium} * ${p => p.theme.text.lineHeightBody});
  193. display: grid;
  194. grid-auto-flow: column;
  195. grid-template-rows: 100%;
  196. align-items: center;
  197. gap: ${space(1)};
  198. @media (max-width: ${p => p.theme.breakpoints.small}) {
  199. /* In mobile, TrailingItems should wrap to a second row and be vertically aligned
  200. with Message. When there is a leading icon, Message is in the second grid column.
  201. Otherwise it's in the first grid column. */
  202. grid-row: 2;
  203. grid-column: ${p => (p.showIcon ? 2 : 1)} / -1;
  204. justify-items: start;
  205. margin: ${space(0.5)} 0;
  206. }
  207. `;
  208. const ExpandIconWrap = styled(IconWrapper)`
  209. margin-left: ${space(0.5)};
  210. `;
  211. const ExpandContainer = styled('div')<{showIcon: boolean; showTrailingItems: boolean}>`
  212. grid-row: 2;
  213. /* ExpandContainer should be vertically aligned with Message. When there is a leading icon,
  214. Message is in the second grid column. Otherwise it's in the first column. */
  215. grid-column: ${p => (p.showIcon ? 2 : 1)} / -1;
  216. cursor: auto;
  217. @media (max-width: ${p => p.theme.breakpoints.small}) {
  218. grid-row: ${p => (p.showTrailingItems ? 3 : 2)};
  219. }
  220. `;
  221. export {alertStyles};
  222. export default Alert;