alert.tsx 6.7 KB

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