115 lines
4.3 KiB
TypeScript
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);
|
|
}
|