GitFOSS
.ts
TypeScript
(application/typescript)
// 3rd-party
import type { ReactIsland } from "@ethicdevs/react-monolith";
import React, { useCallback, useEffect, useRef, useState } from "react";
import styled, { css, keyframes } from "styled-components";
// app

interface InstantRouterIndicatorProps {}

export const ClientSideRouterEventPrefix = `app_router_`;
export const ClientSideRouterEvents = {
  LOADING: `${ClientSideRouterEventPrefix}loading`,
  LOADED: `${ClientSideRouterEventPrefix}loaded`,
  LOAD_ERROR: `${ClientSideRouterEventPrefix}load_error`,
  NAVIGATING: `${ClientSideRouterEventPrefix}navigating`,
  NAVIGATED: `${ClientSideRouterEventPrefix}navigated`,
  NAVIGATION_ERROR: `${ClientSideRouterEventPrefix}navigation_error`,
};

const InstantRouterIndicator: ReactIsland<InstantRouterIndicatorProps> = () => {
  const [isInstantRouteIndicatorVisible, setInstantRouteIndicatorVisible] =
    useState<boolean>(false);
  const [isInstantRouteLoading, setInstantRouteLoading] =
    useState<boolean>(false);
  const [instantRouteLoadPercent, setInstantRouteLoadPercent] =
    useState<number>(0);

  const incrementProgressIntervalIdRef = useRef<null | NodeJS.Timer>(null);

  const startIncrementProgress = useCallback(() => {
    setTimeout(() => {
      setInstantRouteLoadPercent(0);
    }, 0);

    setInstantRouteIndicatorVisible(true);
    setInstantRouteLoading(true);

    incrementProgressIntervalIdRef.current = setInterval(() => {
      if (isInstantRouteLoading) {
        setInstantRouteLoadPercent((prev) => prev + 10);
      }
    }, 500);
  }, [
    isInstantRouteLoading,
    setInstantRouteLoading,
    setInstantRouteLoadPercent,
    setInstantRouteIndicatorVisible,
  ]);

  const stopIncrementProgress = useCallback(() => {
    if (incrementProgressIntervalIdRef.current != null) {
      clearInterval(incrementProgressIntervalIdRef.current);
      incrementProgressIntervalIdRef.current = null;
    }

    setInstantRouteIndicatorVisible(false);
    setInstantRouteLoading(false);
    setInstantRouteLoadPercent(0);
  }, [
    setInstantRouteLoadPercent,
    setInstantRouteLoading,
    setInstantRouteIndicatorVisible,
  ]);

  const onClientSideRouterLoadStart = useCallback(() => {
    // console.log("onClientSideRouterLoadStart:", ev.detail);
    setInstantRouteLoading(true);
  }, [setInstantRouteLoading]);

  const onClientSideRouterLoadComplete = useCallback(() => {
    // console.log("onClientSideRouterLoadComplete:", ev.detail);
    setInstantRouteLoading(false);
  }, [setInstantRouteLoading]);

  const onClientSideRouterLoadError = useCallback(() => {
    // console.log("onClientSideRouterLoadError:", ev.detail);
    setInstantRouteLoading(false);
  }, [setInstantRouteLoading]);

  const onClientSideRouterNavigationError = useCallback(() => {
    // console.log("onClientSideRouterNavigationError:", ev.detail);
    setInstantRouteLoading(false);
  }, [setInstantRouteLoading]);

  // An effect that force 1 re-render once mounted
  useEffect(() => {
    if (typeof document !== "undefined") {
      document.addEventListener(
        ClientSideRouterEvents.LOADING,
        onClientSideRouterLoadStart
      );
      document.addEventListener(
        ClientSideRouterEvents.NAVIGATED,
        onClientSideRouterLoadComplete
      );
      document.addEventListener(
        ClientSideRouterEvents.LOAD_ERROR,
        onClientSideRouterLoadError
      );
      document.addEventListener(
        ClientSideRouterEvents.NAVIGATION_ERROR,
        onClientSideRouterNavigationError
      );

      return () => {
        document.removeEventListener(
          ClientSideRouterEvents.LOADING,
          onClientSideRouterLoadStart
        );
        document.removeEventListener(
          ClientSideRouterEvents.NAVIGATED,
          onClientSideRouterLoadComplete
        );
        document.removeEventListener(
          ClientSideRouterEvents.LOAD_ERROR,
          onClientSideRouterLoadError
        );
        document.removeEventListener(
          ClientSideRouterEvents.NAVIGATION_ERROR,
          onClientSideRouterNavigationError
        );
      };
    }

    return () => undefined;
  }, []);

  useEffect(() => {
    if (
      isInstantRouteLoading &&
      incrementProgressIntervalIdRef.current == null
    ) {
      startIncrementProgress();
    } else if (
      isInstantRouteLoading === false &&
      incrementProgressIntervalIdRef.current != null
    ) {
      stopIncrementProgress();
    }
  }, [isInstantRouteLoading, startIncrementProgress, stopIncrementProgress]);

  if (isInstantRouteIndicatorVisible === false) {
    return null;
  }

  return (
    <StyledInstantRouteProgressIndicator>
      <StyledInstantRouteProgressBar
        progressPercent={instantRouteLoadPercent}
      />
    </StyledInstantRouteProgressIndicator>
  );
};

const blinkAnimation = keyframes`
  /**
   * At the start of the animation the dot
   * has an opacity of .67
   */
  0% {
    opacity: .67;
  }
  /**
   * At 20% the line is fully visible and
   * then fades out slowly
   */
  20% {
    opacity: 1;
  }
  /**
   * Until it reaches an opacity of .67 and
   * the animation can start again
   */
  100% {
    opacity: .67;
  }
`;

const StyledInstantRouteProgressIndicator = styled.div`
  display: flex;

  height: 4px;
  width: 100vw;

  position: fixed;
  top: 0;
  right: 0;
  left: 0;

  z-index: 25000;

  background-color: transparent;
`;

const StyledInstantRouteProgressBar = styled.div<{ progressPercent?: number }>`
  display: flex;

  height: 4px;
  width: 100%;

  position: absolute;
  top: 0;
  left: 0;

  background: #29d;
  z-index: 26000;

  animation: ${blinkAnimation} 500ms ease-in-out infinite;

  transition: all 200ms ease 0s;

  ${({ progressPercent = 0 }) => css`
    transform: translate3d(
      -${100 - Math.max(0, Math.min(progressPercent, 100))}%,
      0px,
      0px
    );
  `};
`;

InstantRouterIndicator.displayName = "InstantRouterIndicator";
export default InstantRouterIndicator;