Files
kandev/apps/web/hooks/use-responsive-breakpoint.ts
T

115 lines
4.3 KiB
TypeScript

import * as React from "react";
const MOBILE_BREAKPOINT = 640;
const COMPACT_DESKTOP_BREAKPOINT = 768;
const DESKTOP_BREAKPOINT = 1024;
export type Breakpoint = "mobile" | "tablet" | "compactDesktop" | "desktop";
export type ResponsiveBreakpoint = {
breakpoint: Breakpoint;
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
isCompactDesktop: boolean;
isFullDesktop: boolean;
isFinePointer: boolean;
usesDesktopWorkbench: boolean;
};
function getBreakpoint(width: number, isFinePointer: boolean): Breakpoint {
if (width < MOBILE_BREAKPOINT) {
return "mobile";
}
// Fine-pointer devices below 768px stay on the tablet layout; that range is
// too narrow to host the full workbench even with a mouse.
if (width >= COMPACT_DESKTOP_BREAKPOINT && width < DESKTOP_BREAKPOINT && isFinePointer) {
return "compactDesktop";
}
if (width < DESKTOP_BREAKPOINT) {
return "tablet";
}
return "desktop";
}
function buildResponsiveBreakpoint(width: number, isFinePointer: boolean): ResponsiveBreakpoint {
const breakpoint = getBreakpoint(width, isFinePointer);
const usesDesktopWorkbench = breakpoint === "compactDesktop" || breakpoint === "desktop";
return {
breakpoint,
isMobile: breakpoint === "mobile",
isTablet: breakpoint === "tablet",
isDesktop: usesDesktopWorkbench,
isCompactDesktop: breakpoint === "compactDesktop",
isFullDesktop: breakpoint === "desktop",
isFinePointer,
usesDesktopWorkbench,
};
}
function getPointerMode(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return true;
}
return window.matchMedia("(pointer: fine)").matches;
}
function getCurrentResponsiveBreakpoint(): ResponsiveBreakpoint {
if (typeof window === "undefined") {
return buildResponsiveBreakpoint(DESKTOP_BREAKPOINT, true);
}
return buildResponsiveBreakpoint(window.innerWidth, getPointerMode());
}
const SERVER_SNAPSHOT = buildResponsiveBreakpoint(DESKTOP_BREAKPOINT, true);
// Cached snapshot keeps getSnapshot referentially stable across re-renders —
// useSyncExternalStore loops if getSnapshot returns a fresh object every
// call. The cache is freshened only when the underlying viewport actually
// changes (verified by deep-equal against the live readout), so listeners
// firing without an effective change don't trigger spurious renders.
let cachedClientSnapshot: ResponsiveBreakpoint | null = null;
function breakpointsEqual(a: ResponsiveBreakpoint, b: ResponsiveBreakpoint): boolean {
// breakpoint + isFinePointer fully determine every other field (see
// buildResponsiveBreakpoint), so equality on those two is sufficient.
return a.breakpoint === b.breakpoint && a.isFinePointer === b.isFinePointer;
}
function getClientSnapshot(): ResponsiveBreakpoint {
const fresh = getCurrentResponsiveBreakpoint();
if (cachedClientSnapshot !== null && breakpointsEqual(cachedClientSnapshot, fresh)) {
return cachedClientSnapshot;
}
cachedClientSnapshot = fresh;
return fresh;
}
function subscribeBreakpoint(callback: () => void): () => void {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return () => {};
}
const mediaQueries = [
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
`(min-width: ${MOBILE_BREAKPOINT}px) and (max-width: ${COMPACT_DESKTOP_BREAKPOINT - 1}px)`,
`(min-width: ${COMPACT_DESKTOP_BREAKPOINT}px) and (max-width: ${DESKTOP_BREAKPOINT - 1}px)`,
`(min-width: ${DESKTOP_BREAKPOINT}px)`,
"(pointer: fine)",
];
const mediaQueryLists = mediaQueries.map((query) => window.matchMedia(query));
mediaQueryLists.forEach((mql) => mql.addEventListener("change", callback));
return () => {
mediaQueryLists.forEach((mql) => mql.removeEventListener("change", callback));
};
}
// useSyncExternalStore is React 19's canonical pattern for hooks that
// expose browser-only state. It reads the real viewport on the first
// client render — no useEffect lag, no desktop→tablet flash, no race
// where heavyweight desktop-only components mount on narrow viewports
// before the layout switches. SSR keeps the desktop default via the
// third arg, matching the prior behavior for hydration.
export function useResponsiveBreakpoint(): ResponsiveBreakpoint {
return React.useSyncExternalStore(subscribeBreakpoint, getClientSnapshot, () => SERVER_SNAPSHOT);
}