Browse Source

feat(ui): Add PageOverlay component for whole page splashes (#25604)

Evan Purkhiser 3 years ago
parent
commit
4084a22369
1 changed files with 281 additions and 0 deletions
  1. 281 0
      static/app/components/pageOverlay.tsx

+ 281 - 0
static/app/components/pageOverlay.tsx

@@ -0,0 +1,281 @@
+import React from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+import {motion} from 'framer-motion';
+
+import Text from 'app/components/text';
+import space from 'app/styles/space';
+import testableTransition from 'app/utils/testableTransition';
+
+/**
+ * The default wrapper for the detail text.
+ *
+ * This can be overridden using the `customWrapper` prop for when the overlay
+ * needs some special sizing due to background illustration constraints.
+ */
+const DefaultWrapper = styled('div')`
+  width: 500px;
+`;
+
+const subItemAnimation = {
+  initial: {
+    opacity: 0,
+    x: 60,
+  },
+  animate: {
+    opacity: 1,
+    x: 0,
+    transition: testableTransition({
+      type: 'spring',
+      duration: 0.4,
+    }),
+  },
+};
+
+const Header = styled(motion.h2)`
+  display: flex;
+  align-items: center;
+  font-weight: normal;
+  margin-bottom: ${space(1)};
+`;
+
+Header.defaultProps = {
+  variants: subItemAnimation,
+  transition: testableTransition(),
+};
+
+const Body = styled(motion.div)`
+  margin-bottom: ${space(2)};
+`;
+
+Body.defaultProps = {
+  variants: subItemAnimation,
+  transition: testableTransition(),
+};
+
+type ContentOpts = {
+  Body: typeof Body;
+  Header: typeof Header;
+};
+
+type PositioningStrategyOpts = {
+  /**
+   * The main container component rect.
+   */
+  mainRect: DOMRect;
+  /**
+   * The anchor reference component in the backgrounds rect.
+   */
+  anchorRect: DOMRect;
+  /**
+   * The wrapper being positioned Rect.
+   */
+  wrapperRect: DOMRect;
+};
+
+type Props = {
+  text: (opts: ContentOpts) => React.ReactNode;
+  animateDelay?: number;
+  /**
+   * If special sizing of the details block is required you can use a custom
+   * wrapper passed in here.
+   *
+   * This must forward its ref if you are using a background that provides an
+   * anchor
+   */
+  customWrapper?: React.ComponentType;
+  /**
+   * Instead of rendering children with an animated gradient fly-in, render a
+   * background component.
+   *
+   * Optionally the background may accept an `anchorRef` prop, which when available will anchor
+   *
+   * Instead of rendering children, render a background and disable the
+   * gradient effect.
+   */
+  background?:
+    | React.ComponentType
+    | React.ComponentType<{anchorRef: React.Ref<SVGForeignObjectElement>}>;
+  /**
+   * When a background with an anchorRef is provided, you can customize the
+   * positioning strategy for the wrapper by passing in a custom function here
+   * that resolves the X and Y position.
+   */
+  positioningStrategy: (opts: PositioningStrategyOpts) => {x: number; y: number};
+};
+
+type DefaultProps = Pick<Props, 'positioningStrategy'>;
+
+/**
+ * When a background with a anchor is used and no positioningStrategy is
+ * provided, by default we'll align the top left of the container to the anchor
+ */
+const defaultPositioning = ({mainRect, anchorRect}: PositioningStrategyOpts) => ({
+  x: anchorRect.x - mainRect.x,
+  y: anchorRect.y - mainRect.y,
+});
+
+/**
+ * Wrapper component that will render the wrapped content with an animated
+ * overlay.
+ *
+ * If children are given they will be placed behind the overlay and hidden from
+ * pointer events.
+ *
+ * If a background is given, the background will be rendered _above_ any
+ * children (and will receive framer-motion variant changes for animations).
+ * The background may also provide a `anchorRef` to aid in alignment of the
+ * wrapper to a safe space in the background to aid in alignment of the wrapper
+ * to a safe space in the background.
+ */
+class PageOverlay extends React.Component<Props> {
+  static defaultProps: DefaultProps = {
+    positioningStrategy: defaultPositioning,
+  };
+
+  componentDidMount() {
+    if (this.contentRef.current === null || this.anchorRef.current === null) {
+      return;
+    }
+
+    this.anchorWrapper();
+
+    // Observe changes to the upsell container to reanchor if available
+    if (window.ResizeObserver) {
+      this.bgResizeObserver = new ResizeObserver(this.anchorWrapper);
+      this.bgResizeObserver.observe(this.contentRef.current);
+    }
+  }
+
+  componentWillUnmount() {
+    this.bgResizeObserver?.disconnect();
+  }
+
+  /**
+   * Used to re-anchor the text wrapper to the anchor point in the background when
+   * the size of the page changes.
+   */
+  bgResizeObserver: ResizeObserver | null = null;
+
+  contentRef = React.createRef<HTMLDivElement>();
+  wrapperRef = React.createRef<HTMLDivElement>();
+  anchorRef = React.createRef<SVGForeignObjectElement>();
+
+  /**
+   * Align the wrapper component to the anchor by computing x/y values using
+   * the passed function. By default if no function is specified it will align
+   * to the top left of the anchor.
+   */
+  anchorWrapper = () => {
+    if (
+      this.contentRef.current === null ||
+      this.wrapperRef.current === null ||
+      this.anchorRef.current === null
+    ) {
+      return;
+    }
+
+    // Absolute position the container, this avoids the browser having to reflow
+    // the component
+    this.wrapperRef.current.style.position = 'absolute';
+    this.wrapperRef.current.style.left = `0px`;
+    this.wrapperRef.current.style.top = `0px`;
+
+    const mainRect = this.contentRef.current.getBoundingClientRect();
+    const anchorRect = this.anchorRef.current.getBoundingClientRect();
+    const wrapperRect = this.wrapperRef.current.getBoundingClientRect();
+
+    // Compute the position of the wrapper
+    const {x, y} = this.props.positioningStrategy({mainRect, anchorRect, wrapperRect});
+
+    const transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
+    this.wrapperRef.current.style.transform = transform;
+  };
+
+  render() {
+    const {
+      text,
+      children,
+      animateDelay,
+      background: BackgroundComponent,
+      customWrapper,
+      ...props
+    } = this.props;
+    const Wrapper = customWrapper ?? DefaultWrapper;
+
+    const transition = testableTransition({
+      delay: 1,
+      duration: 1.2,
+      ease: 'easeInOut',
+      delayChildren: animateDelay ?? (BackgroundComponent ? 0.5 : 1.5),
+      staggerChildren: 0.15,
+    });
+
+    return (
+      <MaskedContent {...props}>
+        {children}
+        <ContentWrapper
+          ref={this.contentRef}
+          transition={transition}
+          variants={{animate: {}}}
+        >
+          {BackgroundComponent && (
+            <Background>
+              <BackgroundComponent anchorRef={this.anchorRef} />
+            </Background>
+          )}
+          <Wrapper ref={this.wrapperRef}>
+            <Text>{text({Body, Header})}</Text>
+          </Wrapper>
+        </ContentWrapper>
+      </MaskedContent>
+    );
+  }
+}
+
+const absoluteFull = css`
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+`;
+
+const ContentWrapper = styled(motion.div)`
+  ${absoluteFull}
+  padding: 10%;
+  z-index: 900;
+`;
+
+ContentWrapper.defaultProps = {
+  initial: 'initial',
+  animate: 'animate',
+};
+
+const Background = styled('div')`
+  ${absoluteFull}
+  z-index: -1;
+  padding: 60px;
+  display: flex;
+  align-items: center;
+
+  > * {
+    width: 100%;
+    min-height: 600px;
+    height: 100%;
+  }
+`;
+
+const MaskedContent = styled('div')`
+  position: relative;
+  overflow: hidden;
+  flex-grow: 1;
+  flex-basis: 0;
+
+  /* Specify bottom margin specifically to offset the margin of the footer, so
+   * the hidden content flows directly to the border of the footer
+   */
+  margin-bottom: -20px;
+`;
+
+export default PageOverlay;