pageOverlay.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import {Component, createRef} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {motion} from 'framer-motion';
  5. import Text from 'sentry/components/text';
  6. import space from 'sentry/styles/space';
  7. import testableTransition from 'sentry/utils/testableTransition';
  8. /**
  9. * The default wrapper for the detail text.
  10. *
  11. * This can be overridden using the `customWrapper` prop for when the overlay
  12. * needs some special sizing due to background illustration constraints.
  13. */
  14. const DefaultWrapper = styled('div')`
  15. width: 500px;
  16. `;
  17. const subItemAnimation = {
  18. initial: {
  19. opacity: 0,
  20. x: 60,
  21. },
  22. animate: {
  23. opacity: 1,
  24. x: 0,
  25. transition: testableTransition({
  26. type: 'spring',
  27. duration: 0.4,
  28. }),
  29. },
  30. };
  31. const Header = styled(motion.h2)`
  32. display: flex;
  33. align-items: center;
  34. font-weight: normal;
  35. margin-bottom: ${space(1)};
  36. `;
  37. Header.defaultProps = {
  38. variants: subItemAnimation,
  39. transition: testableTransition(),
  40. };
  41. const Body = styled(motion.div)`
  42. margin-bottom: ${space(2)};
  43. `;
  44. Body.defaultProps = {
  45. variants: subItemAnimation,
  46. transition: testableTransition(),
  47. };
  48. type ContentOpts = {
  49. Body: typeof Body;
  50. Header: typeof Header;
  51. };
  52. type PositioningStrategyOpts = {
  53. /**
  54. * The anchor reference component in the backgrounds rect.
  55. */
  56. anchorRect: DOMRect;
  57. /**
  58. * The main container component rect.
  59. */
  60. mainRect: DOMRect;
  61. /**
  62. * The wrapper being positioned Rect.
  63. */
  64. wrapperRect: DOMRect;
  65. };
  66. type Props = {
  67. /**
  68. * When a background with an anchorRef is provided, you can customize the
  69. * positioning strategy for the wrapper by passing in a custom function here
  70. * that resolves the X and Y position.
  71. */
  72. positioningStrategy: (opts: PositioningStrategyOpts) => {x: number; y: number};
  73. text: (opts: ContentOpts) => React.ReactNode;
  74. animateDelay?: number;
  75. /**
  76. * Instead of rendering children with an animated gradient fly-in, render a
  77. * background component.
  78. *
  79. * Optionally the background may accept an `anchorRef` prop, which when available will anchor
  80. *
  81. * Instead of rendering children, render a background and disable the
  82. * gradient effect.
  83. */
  84. background?:
  85. | React.ComponentType
  86. | React.ComponentType<{anchorRef: React.Ref<SVGForeignObjectElement>}>;
  87. /**
  88. * If special sizing of the details block is required you can use a custom
  89. * wrapper passed in here.
  90. *
  91. * This must forward its ref if you are using a background that provides an
  92. * anchor
  93. */
  94. customWrapper?: React.ComponentType;
  95. };
  96. type DefaultProps = Pick<Props, 'positioningStrategy'>;
  97. /**
  98. * When a background with a anchor is used and no positioningStrategy is
  99. * provided, by default we'll align the top left of the container to the anchor
  100. */
  101. const defaultPositioning = ({mainRect, anchorRect}: PositioningStrategyOpts) => ({
  102. x: anchorRect.x - mainRect.x,
  103. y: anchorRect.y - mainRect.y,
  104. });
  105. /**
  106. * Wrapper component that will render the wrapped content with an animated
  107. * overlay.
  108. *
  109. * If children are given they will be placed behind the overlay and hidden from
  110. * pointer events.
  111. *
  112. * If a background is given, the background will be rendered _above_ any
  113. * children (and will receive framer-motion variant changes for animations).
  114. * The background may also provide a `anchorRef` to aid in alignment of the
  115. * wrapper to a safe space in the background to aid in alignment of the wrapper
  116. * to a safe space in the background.
  117. */
  118. class PageOverlay extends Component<Props> {
  119. static defaultProps: DefaultProps = {
  120. positioningStrategy: defaultPositioning,
  121. };
  122. componentDidMount() {
  123. if (this.contentRef.current === null || this.anchorRef.current === null) {
  124. return;
  125. }
  126. this.anchorWrapper();
  127. // Observe changes to the upsell container to reanchor if available
  128. if (window.ResizeObserver) {
  129. this.bgResizeObserver = new ResizeObserver(this.anchorWrapper);
  130. this.bgResizeObserver.observe(this.contentRef.current);
  131. }
  132. }
  133. componentWillUnmount() {
  134. this.bgResizeObserver?.disconnect();
  135. }
  136. /**
  137. * Used to re-anchor the text wrapper to the anchor point in the background when
  138. * the size of the page changes.
  139. */
  140. bgResizeObserver: ResizeObserver | null = null;
  141. contentRef = createRef<HTMLDivElement>();
  142. wrapperRef = createRef<HTMLDivElement>();
  143. anchorRef = createRef<SVGForeignObjectElement>();
  144. /**
  145. * Align the wrapper component to the anchor by computing x/y values using
  146. * the passed function. By default if no function is specified it will align
  147. * to the top left of the anchor.
  148. */
  149. anchorWrapper = () => {
  150. if (
  151. this.contentRef.current === null ||
  152. this.wrapperRef.current === null ||
  153. this.anchorRef.current === null
  154. ) {
  155. return;
  156. }
  157. // Absolute position the container, this avoids the browser having to reflow
  158. // the component
  159. this.wrapperRef.current.style.position = 'absolute';
  160. this.wrapperRef.current.style.left = `0px`;
  161. this.wrapperRef.current.style.top = `0px`;
  162. const mainRect = this.contentRef.current.getBoundingClientRect();
  163. const anchorRect = this.anchorRef.current.getBoundingClientRect();
  164. const wrapperRect = this.wrapperRef.current.getBoundingClientRect();
  165. // Compute the position of the wrapper
  166. const {x, y} = this.props.positioningStrategy({mainRect, anchorRect, wrapperRect});
  167. const transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
  168. this.wrapperRef.current.style.transform = transform;
  169. };
  170. render() {
  171. const {
  172. text,
  173. children,
  174. animateDelay,
  175. background: BackgroundComponent,
  176. customWrapper,
  177. ...props
  178. } = this.props;
  179. const Wrapper = customWrapper ?? DefaultWrapper;
  180. const transition = testableTransition({
  181. delay: 1,
  182. duration: 1.2,
  183. ease: 'easeInOut',
  184. delayChildren: animateDelay ?? (BackgroundComponent ? 0.5 : 1.5),
  185. staggerChildren: 0.15,
  186. });
  187. return (
  188. <MaskedContent {...props}>
  189. {children}
  190. <ContentWrapper
  191. ref={this.contentRef}
  192. transition={transition}
  193. variants={{animate: {}}}
  194. >
  195. {BackgroundComponent && (
  196. <Background>
  197. <BackgroundComponent anchorRef={this.anchorRef} />
  198. </Background>
  199. )}
  200. <Wrapper ref={this.wrapperRef}>
  201. <Text>{text({Body, Header})}</Text>
  202. </Wrapper>
  203. </ContentWrapper>
  204. </MaskedContent>
  205. );
  206. }
  207. }
  208. const absoluteFull = css`
  209. position: absolute;
  210. top: 0;
  211. left: 0;
  212. right: 0;
  213. bottom: 0;
  214. `;
  215. const ContentWrapper = styled(motion.div)`
  216. ${absoluteFull}
  217. padding: 10%;
  218. z-index: 900;
  219. `;
  220. ContentWrapper.defaultProps = {
  221. initial: 'initial',
  222. animate: 'animate',
  223. };
  224. const Background = styled('div')`
  225. ${absoluteFull}
  226. z-index: -1;
  227. padding: 60px;
  228. display: flex;
  229. align-items: center;
  230. > * {
  231. width: 100%;
  232. min-height: 600px;
  233. height: 100%;
  234. }
  235. `;
  236. const MaskedContent = styled('div')`
  237. position: relative;
  238. overflow: hidden;
  239. flex-grow: 1;
  240. flex-basis: 0;
  241. `;
  242. export default PageOverlay;