All files / network-indicator/src NetworkIndicator.tsx

100% Statements 26/26
100% Branches 28/28
100% Functions 4/4
100% Lines 26/26

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219                                                                                                                                                      1x     1x     1x   1x                                 1x 1x                                                                                                             147x 147x         147x   147x 35x     147x 63x 63x 63x 28x     35x 35x           147x 7x       147x 9x     138x 138x 61x     77x                                            
import {
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";
import { useEventCallback } from "@usefy/use-event-callback";
import { useNetworkState } from "@usefy/use-network-state";
import { useTimeout } from "@usefy/use-timeout";
 
/** Which screen edge the fixed banner is pinned to. */
export type NetworkIndicatorPosition = "top" | "bottom";
 
/**
 * The connectivity state handed to the {@link NetworkIndicatorProps.render}
 * escape hatch.
 */
export interface NetworkIndicatorState {
  /** Current connectivity, from `navigator.onLine` (`true` on the server). */
  online: boolean;
  /**
   * `true` during the brief "back online" confirmation window that follows an
   * observed offline → online transition. Never `true` on first mount.
   */
  reconnected: boolean;
}
 
/** Props for {@link NetworkIndicator}. */
export interface NetworkIndicatorProps {
  /**
   * Which screen edge the fixed banner is pinned to.
   * @default "top"
   */
  position?: NetworkIndicatorPosition;
  /**
   * Content of the offline banner.
   * @default "You're offline. Some features may not work."
   */
  offlineMessage?: ReactNode;
  /**
   * Content of the "back online" confirmation.
   * @default "Back online"
   */
  onlineMessage?: ReactNode;
  /**
   * How long (ms) the "back online" confirmation stays visible before
   * auto-dismissing. `0` or a negative value disables the confirmation
   * entirely — reconnecting goes straight back to rendering nothing.
   * Changing the value while the confirmation is visible restarts the
   * dismiss timer from zero with the new duration.
   * @default 3000
   */
  onlineDuration?: number;
  /**
   * Escape hatch: fully own the UI while keeping the state machine. When
   * provided, the default banner is never rendered and `render` is called on
   * every render (including steady-state online) with the current
   * {@link NetworkIndicatorState}; return `null` to render nothing.
   */
  render?: (state: NetworkIndicatorState) => ReactNode;
  /**
   * Called after each online/offline transition (never on mount) with the new
   * connectivity. Dispatched from a post-commit effect, so it is StrictMode-
   * and concurrent-rendering-safe.
   */
  onStatusChange?: (online: boolean) => void;
  /** Class name applied to the default banner element. */
  className?: string;
  /** Inline styles merged over the default banner styles (yours win). */
  style?: CSSProperties;
}
 
/** Default offline banner content. */
export const DEFAULT_OFFLINE_MESSAGE =
  "You're offline. Some features may not work.";
 
/** Default "back online" confirmation content. */
export const DEFAULT_ONLINE_MESSAGE = "Back online";
 
/** Default duration (ms) of the "back online" confirmation. */
export const DEFAULT_ONLINE_DURATION = 3000;
 
const BASE_STYLE: CSSProperties = {
  position: "fixed",
  left: 0,
  right: 0,
  zIndex: 9999,
  padding: "10px 16px",
  textAlign: "center",
  fontFamily:
    "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif",
  fontSize: 14,
  fontWeight: 500,
  lineHeight: 1.4,
  color: "#ffffff",
  // Non-interactive by default: never steal clicks from the app underneath.
  pointerEvents: "none",
};
 
const OFFLINE_BACKGROUND = "#b91c1c";
const RECONNECTED_BACKGROUND = "#15803d";
 
/**
 * A drop-in online/offline status banner.
 *
 * Renders nothing while online; shows a fixed offline banner when connectivity
 * is lost; swaps to a brief auto-dismissing "back online" confirmation when it
 * returns. The confirmation never flashes on first mount — it only ever follows
 * an observed offline → online transition. Mounting while already offline shows
 * the offline banner immediately.
 *
 * Built on `@usefy/use-network-state` (SSR-safe, tear-free connectivity) with
 * `@usefy/use-timeout` handling the auto-dismiss (cleaned up on unmount).
 *
 * Accessibility: the banner is non-interactive and `aria-atomic`. The offline
 * banner is a `role="alert"` live region (assertive; reliably announced even
 * when inserted already populated); the reconnected confirmation is a polite
 * `role="status"` (screen readers may skip announcing it when the element is
 * inserted pre-populated — it is a nicety, not critical information).
 *
 * @example
 * ```tsx
 * import { NetworkIndicator } from "@usefy/network-indicator";
 *
 * function App() {
 *   return (
 *     <>
 *       <YourApp />
 *       <NetworkIndicator position="top" onlineDuration={3000} />
 *     </>
 *   );
 * }
 * ```
 *
 * @example
 * ```tsx
 * // Fully custom UI via the render escape hatch
 * <NetworkIndicator
 *   render={({ online, reconnected }) => {
 *     if (online && !reconnected) return null;
 *     return <MyToast tone={online ? "success" : "danger"} />;
 *   }}
 * />
 * ```
 */
export function NetworkIndicator({
  position = "top",
  offlineMessage = DEFAULT_OFFLINE_MESSAGE,
  onlineMessage = DEFAULT_ONLINE_MESSAGE,
  onlineDuration = DEFAULT_ONLINE_DURATION,
  render,
  onStatusChange,
  className,
  style,
}: NetworkIndicatorProps): ReactNode {
  const { online } = useNetworkState();
  const [reconnected, setReconnected] = useState(false);
 
  // Previous committed connectivity. `undefined` until the first commit so the
  // mount effect (and StrictMode's double invocation of it) can never register
  // a transition — no callback and no "back online" flash on first mount.
  const prevOnlineRef = useRef<boolean | undefined>(undefined);
 
  const emitStatusChange = useEventCallback((next: boolean) => {
    onStatusChange?.(next);
  });
 
  useEffect(() => {
    const prev = prevOnlineRef.current;
    prevOnlineRef.current = online;
    if (prev === undefined || prev === online) {
      return;
    }
    // A real transition: notify post-commit (never from a setState updater).
    emitStatusChange(online);
    setReconnected(online && onlineDuration > 0);
  }, [online, onlineDuration, emitStatusChange]);
 
  // Auto-dismiss the confirmation. `null` disables (and clears) the timer, and
  // useTimeout cleans up on unmount. If `onlineDuration` drops to <= 0 while
  // the confirmation is visible, the clamped 0ms timeout dismisses it at once.
  useTimeout(
    () => setReconnected(false),
    reconnected ? Math.max(0, onlineDuration) : null
  );
 
  if (render) {
    return <>{render({ online, reconnected })}</>;
  }
 
  const offline = !online;
  if (!offline && !reconnected) {
    return null;
  }
 
  return (
    <div
      // Offline uses role="alert" — the canonical assertive live region, and the
      // one role screen readers reliably announce even when the element is
      // inserted already populated. The reconnected confirmation is a polite
      // role="status". The implicit aria-live of each role applies.
      role={offline ? "alert" : "status"}
      aria-atomic="true"
      data-status={offline ? "offline" : "reconnected"}
      data-position={position}
      className={className}
      style={{
        ...BASE_STYLE,
        ...(position === "bottom" ? { bottom: 0 } : { top: 0 }),
        backgroundColor: offline ? OFFLINE_BACKGROUND : RECONNECTED_BACKGROUND,
        ...style,
      }}
    >
      {offline ? offlineMessage : onlineMessage}
    </div>
  );
}