Uecko_ERP/packages/rdx-ui/src/hooks/use-device-info.ts

156 lines
4.8 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
export const DEFAULT_PHONE_MAX = 768;
export const DEFAULT_TABLET_MAX = 1024;
type DeviceType = "phone" | "tablet" | "desktop";
type DeviceInfo = {
isMobile: boolean;
deviceType: DeviceType;
// Señales útiles para decisiones finas de UX:
widthNarrow: boolean; // <= phoneMax
widthTablet: boolean; // > phoneMax && <= tabletMax
pointerCoarse: boolean; // puntero táctil grueso
hoverNone: boolean; // no hay hover
maxTouchPoints: number;
// Info de plataforma cuando está disponible
platform?: string;
model?: string;
uaMobileHint?: boolean; // de UA/Client Hints si se puede
};
type Options = {
phoneMax?: number; // por defecto DEFAULT_PHONE_MAX
tabletMax?: number; // por defecto DEFAULT_TABLET_MAX
// Para SSR/Next: primera suposición que evitará saltos visuales
serverGuess?: Partial<Pick<DeviceInfo, "deviceType" | "isMobile">>;
};
/** Hook base para media queries */
function useMediaQuery(query: string, fallback = false) {
const [matches, setMatches] = useState(fallback);
useEffect(() => {
if (typeof window === "undefined") return; // SSR
const mql = window.matchMedia(query);
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches);
setMatches(mql.matches);
mql.addEventListener?.("change", onChange);
return () => mql.removeEventListener?.("change", onChange);
}, [query]);
return matches;
}
/** Clasificador a partir de señales */
function classifyDevice(
widthNarrow: boolean,
widthTablet: boolean,
pointerCoarse: boolean,
hoverNone: boolean
): DeviceType {
// Heurística práctica:
if (pointerCoarse && (widthNarrow || hoverNone)) return "phone";
if (pointerCoarse && widthTablet) return "tablet";
return "desktop";
}
export function useDeviceInfo(opts: Options = {}): DeviceInfo {
const phoneMax = opts.phoneMax ?? DEFAULT_PHONE_MAX;
const tabletMax = opts.tabletMax ?? DEFAULT_TABLET_MAX;
// Inicialización segura para SSR para evitar hydration mismatch.
// Si hay serverGuess, úsalo; si no, asume desktop hasta montar.
const [initial, setInitial] = useState<DeviceInfo | null>(() => {
if (typeof window === "undefined") {
const deviceType = opts.serverGuess?.deviceType ?? "desktop";
const isMobile = opts.serverGuess?.isMobile ?? deviceType !== "desktop";
return {
isMobile,
deviceType,
widthNarrow: deviceType === "phone",
widthTablet: deviceType === "tablet",
pointerCoarse: deviceType !== "desktop",
hoverNone: deviceType !== "desktop",
maxTouchPoints: 0,
};
}
return null; // en cliente, calcularemos real en useEffect
});
const widthNarrow = useMediaQuery(`(max-width: ${phoneMax}px)`, initial?.widthNarrow ?? false);
const widthTablet = useMediaQuery(
`(min-width: ${phoneMax + 1}px) and (max-width: ${tabletMax}px)`,
initial?.widthTablet ?? false
);
const pointerCoarse = useMediaQuery("(pointer: coarse)", initial?.pointerCoarse ?? false);
const hoverNone = useMediaQuery("(hover: none)", initial?.hoverNone ?? false);
const [maxTouchPoints, setMaxTouchPoints] = useState(initial?.maxTouchPoints ?? 0);
const [uaHints, setUaHints] = useState<{ platform?: string; model?: string; mobile?: boolean }>(
{}
);
useEffect(() => {
if (typeof window === "undefined") return;
// maxTouchPoints es útil para detectar iPadOS que se disfraza de Mac
setMaxTouchPoints(navigator.maxTouchPoints || 0);
// Client Hints (Chromium). No bloquea si no existen.
(async () => {
const nav = navigator as any;
if (nav.userAgentData?.getHighEntropyValues) {
try {
const { platform, mobile, model } = await nav.userAgentData.getHighEntropyValues([
"platform",
"mobile",
"model",
]);
setUaHints({ platform, mobile, model });
} catch {
// ignorar excepciones
}
}
})();
}, []);
const deviceType = useMemo(() => {
// Si es Mac pero con touchPoints > 1 => probablemente iPadOS en modo desktop
const forceTouch =
typeof window !== "undefined" &&
/Macintosh/i.test(navigator.userAgent || "") &&
maxTouchPoints > 1;
return classifyDevice(
widthNarrow,
widthTablet,
pointerCoarse || forceTouch,
hoverNone || forceTouch
);
}, [widthNarrow, widthTablet, pointerCoarse, hoverNone, maxTouchPoints]);
const isMobile = deviceType !== "desktop";
// Si teníamos estado inicial en SSR, al primer render en cliente lo reemplazamos por el real
useEffect(() => {
if (initial) setInitial(null);
}, [initial]);
return {
isMobile,
deviceType,
widthNarrow,
widthTablet,
pointerCoarse,
hoverNone,
maxTouchPoints,
platform: uaHints.platform,
model: uaHints.model,
uaMobileHint: uaHints.mobile,
};
}