codevise/pageflow

View on GitHub
entry_types/scrolled/package/src/frontend/useScrollPositionLifecycle.js

Summary

Maintainability
A
1 hr
Test Coverage
import React, {useRef, useEffect, useContext, useMemo, createContext} from 'react';
import classNames from 'classnames';
import {useOnScreen} from './useOnScreen';
import {useDelayedBoolean} from './useDelayedBoolean';

import styles from './useScrollPositionLifecycle.module.css';

const StaticPreviewContext = createContext(false);

export function StaticPreview({children}) {
  return (
    <StaticPreviewContext.Provider value={true}>
      {children}
    </StaticPreviewContext.Provider>
  );
}

/**
 * Use inside a content element component to determine whether the
 * component is being rendered in a static preview, e.g. editor
 * thumbnails.
 *
 * @example
 * const isStaticPreview = useIsStaticPreview();
 */
export function useIsStaticPreview() {
  return useContext(StaticPreviewContext);
}

export function createScrollPositionLifecycleProvider(Context) {
  return function ScrollPositionLifecycleProvider({
    children, onActivate, entersWithFadeTransition
  }) {
    const ref = useRef();
    const isActiveProbeRef = useRef();

    const isStaticPreview = useContext(StaticPreviewContext);

    const shouldLoad = useOnScreen(ref, {rootMargin: '200% 0px 200% 0px'});
    const shouldPrepare = useOnScreen(ref, {rootMargin: '25% 0px 25% 0px'}) && !isStaticPreview;

    // Sections that enter with fade transition only become visible
    // once they reach the center of the viewport. We want to reflect
    // that in `isVisible`/`onVisible` to prevent background videos
    // from starting too soon. Since fade section might still exit
    // with a scroll transition, we want to keep `isVisible` true
    // until the section has completely left the viewport. We do not
    // care about when exactly a background video pauses.
    //
    // Note that with fade transitions sections actually stay visible
    // a bit longer while they are still fading out. This is handled
    // by `isVisibleWithDelay` below.
    const shouldBeVisible = useOnScreen(ref, {
      rootMargin: entersWithFadeTransition ?
                  '0px 0px -50% 0px' :
                  undefined
    }) && !isStaticPreview;

    const shouldBeActive = useOnScreen(isActiveProbeRef, {
      rootMargin: '-50% 0px -50% 0px',
      onIntersecting: onActivate
    }) && !isStaticPreview;

    // useDelayedBoolean causes an extra render once the delay has
    // elapsed. When entersWithFadeTransition is false,
    // isVisibleWithDelay is never used, though. Since hooks can not
    // be wrapped in conditionals, we ensure that the value passed to
    // useDelayedBoolean is always false if entersWithFadeTransition
    // is false. This prevents the extra render.
    const isVisibleWithDelay = useDelayedBoolean(
      shouldBeVisible && entersWithFadeTransition,
      {fromTrueToFalse: 1000}
    );

    const isVisible = entersWithFadeTransition ? isVisibleWithDelay : shouldBeVisible;

    // We want to make sure that `onActivate` is never called before
    // `onVisible`, no matter in which order the intersection
    // observers above fire.
    const isActive = isVisible && shouldBeActive

    const value = useMemo(() => ({
      shouldLoad, shouldPrepare, isVisible, isActive}
    ), [shouldLoad, shouldPrepare, isVisible, isActive]);

    return (
      <div ref={ref} className={classNames(styles.wrapper)}>
        <div ref={isActiveProbeRef} className={styles.isActiveProbe} />

        <Context.Provider value={value}>
          {children}
        </Context.Provider>
      </div>
    );
  }
}

export function createScrollPositionLifecycleHook(Context) {
  return function useScrollPositionLifecycle({onActivate, onDeactivate, onVisible, onInvisible} = {}) {
    const result = useContext(Context);

    const wasActive = useRef();
    const wasVisible = useRef();

    const {isActive, isVisible} = result || {};

    useEffect(() => {
      if (!wasVisible.current && isVisible && onVisible) {
        onVisible();
      }

      if (!wasActive.current && isActive && onActivate) {
        onActivate();
      }
      else if (wasActive.current && !isActive && onDeactivate) {
        onDeactivate();
      }

      if (wasVisible.current && !isVisible && onInvisible) {
        onInvisible();
      }

      wasActive.current = isActive;
      wasVisible.current = isVisible;
    });

    return result;
  };
}