alert.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import {useRef, useState} from 'react';
  2. import {css, Theme} 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 PanelProvider from 'sentry/utils/panelProvider';
  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. <PanelProvider>
  83. {showIcon && <IconWrapper onClick={handleClick}>{icon ?? getIcon()}</IconWrapper>}
  84. <Message>{children}</Message>
  85. {showTrailingItems && (
  86. <TrailingItems showIcon={showIcon} onClick={e => e.stopPropagation()}>
  87. {trailingItems}
  88. </TrailingItems>
  89. )}
  90. {showExpand && (
  91. <ExpandIconWrap>
  92. <IconChevron direction={isExpanded ? 'up' : 'down'} />
  93. </ExpandIconWrap>
  94. )}
  95. {isExpanded && (
  96. <ExpandContainer
  97. ref={expandRef}
  98. showIcon={showIcon}
  99. showTrailingItems={showTrailingItems}
  100. {...expandHoverProps}
  101. >
  102. {Array.isArray(expand) ? expand.map(item => item) : expand}
  103. </ExpandContainer>
  104. )}
  105. </PanelProvider>
  106. </Wrap>
  107. );
  108. }
  109. const alertStyles = ({
  110. type = DEFAULT_TYPE,
  111. system,
  112. opaque,
  113. expand,
  114. showIcon,
  115. trailingItems,
  116. hovered,
  117. theme,
  118. }: AlertProps & {theme: Theme; hovered?: boolean}) => {
  119. const alertColors = theme.alert[type];
  120. const showExpand = defined(expand);
  121. const showTrailingItems = defined(trailingItems);
  122. return css`
  123. display: grid;
  124. grid-template-columns:
  125. ${showIcon && `minmax(0, max-content)`}
  126. minmax(0, 1fr)
  127. ${showTrailingItems && 'max-content'}
  128. ${showExpand && 'max-content'};
  129. gap: ${space(1)};
  130. margin: 0 0 ${space(2)};
  131. font-size: ${theme.fontSizeMedium};
  132. border-radius: ${theme.borderRadius};
  133. border: 1px solid ${alertColors.border};
  134. background: ${opaque
  135. ? `linear-gradient(
  136. ${alertColors.backgroundLight},
  137. ${alertColors.backgroundLight}),
  138. linear-gradient(${theme.background}, ${theme.background}
  139. )`
  140. : `${alertColors.backgroundLight}`};
  141. a:not([role='button']) {
  142. color: ${theme.textColor};
  143. text-decoration-color: ${theme.translucentBorder};
  144. text-decoration-style: solid;
  145. text-decoration-line: underline;
  146. text-decoration-thickness: 0.08em;
  147. text-underline-offset: 0.06em;
  148. }
  149. a:not([role='button']):hover {
  150. text-decoration-color: ${theme.subText};
  151. text-decoration-style: solid;
  152. }
  153. pre {
  154. background: ${alertColors.backgroundLight};
  155. margin: ${space(0.5)} 0 0;
  156. }
  157. ${IconWrapper}, ${ExpandIconWrap} {
  158. color: ${alertColors.iconColor};
  159. }
  160. ${hovered &&
  161. `
  162. border-color: ${alertColors.borderHover};
  163. ${IconWrapper}, ${IconChevron} {
  164. color: ${alertColors.iconHoverColor};
  165. }
  166. `}
  167. ${showExpand &&
  168. `cursor: pointer;
  169. ${TrailingItems} {
  170. cursor: auto;
  171. }
  172. `}
  173. ${system &&
  174. `
  175. border-width: 0 0 1px 0;
  176. border-radius: 0;
  177. `}
  178. `;
  179. };
  180. const Wrap = styled('div')<AlertProps & {hovered: boolean}>`
  181. ${alertStyles}
  182. padding: ${space(1.5)} ${space(2)};
  183. `;
  184. const IconWrapper = styled('div')`
  185. display: flex;
  186. align-items: center;
  187. height: calc(${p => p.theme.fontSizeMedium} * ${p => p.theme.text.lineHeightBody});
  188. `;
  189. const Message = styled('span')`
  190. position: relative;
  191. line-height: ${p => p.theme.text.lineHeightBody};
  192. `;
  193. const TrailingItems = styled('div')<{showIcon: boolean}>`
  194. height: calc(${p => p.theme.fontSizeMedium} * ${p => p.theme.text.lineHeightBody});
  195. display: grid;
  196. grid-auto-flow: column;
  197. grid-template-rows: 100%;
  198. align-items: center;
  199. gap: ${space(1)};
  200. @media (max-width: ${p => p.theme.breakpoints.small}) {
  201. /* In mobile, TrailingItems should wrap to a second row and be vertically aligned
  202. with Message. When there is a leading icon, Message is in the second grid column.
  203. Otherwise it's in the first grid column. */
  204. grid-row: 2;
  205. grid-column: ${p => (p.showIcon ? 2 : 1)} / -1;
  206. justify-items: start;
  207. margin: ${space(0.5)} 0;
  208. }
  209. `;
  210. const ExpandIconWrap = styled(IconWrapper)`
  211. margin-left: ${space(0.5)};
  212. `;
  213. const ExpandContainer = styled('div')<{showIcon: boolean; showTrailingItems: boolean}>`
  214. grid-row: 2;
  215. /* ExpandContainer should be vertically aligned with Message. When there is a leading icon,
  216. Message is in the second grid column. Otherwise it's in the first column. */
  217. grid-column: ${p => (p.showIcon ? 2 : 1)} / -1;
  218. cursor: auto;
  219. @media (max-width: ${p => p.theme.breakpoints.small}) {
  220. grid-row: ${p => (p.showTrailingItems ? 3 : 2)};
  221. }
  222. `;
  223. export {Alert, alertStyles};
  224. export default Alert;