lib/app-layout.ts
Rama main · réplica de lectura del repositorio original.
export const APP_LAYOUT_POLICY = {
desktopMinWidth: 1024,
portraitDesktopMinWidth: 1200,
phoneMaxShortSide: 600,
} as const;
export type AppLayoutInput = {
width: number;
height: number;
narrow?: boolean;
touch: boolean;
screenWidth: number;
screenHeight: number;
orientation: "portrait" | "landscape" | null;
};
type AppLayoutPolicy = typeof APP_LAYOUT_POLICY;
/** Self-contained so the same decision can run before React hydrates. */
export function resolveMobileLayout(input: AppLayoutInput, policy: AppLayoutPolicy): boolean {
if (input.narrow ?? (input.width < policy.desktopMinWidth)) return true;
if (!input.touch) return false;
const shortSide = Math.min(input.screenWidth, input.screenHeight);
if (shortSide > 0 && shortSide < policy.phoneMaxShortSide) return true;
const portrait = input.orientation === null
? (input.screenWidth > 0 && input.screenHeight > 0
? input.screenHeight >= input.screenWidth
: input.height >= input.width)
: input.orientation === "portrait";
return portrait && input.width < policy.portraitDesktopMinWidth;
}
/** Screen orientation stays stable when the software keyboard shrinks the viewport. */
export function readAppLayoutInput(policy: AppLayoutPolicy): AppLayoutInput {
const screen = window.screen;
const type = screen?.orientation?.type;
const legacy = (window as Window & { orientation?: number }).orientation;
const orientation = type
? (type.startsWith("portrait") ? "portrait" : "landscape")
: typeof legacy === "number"
? (Math.abs(legacy) === 90 ? "landscape" : "portrait")
: null;
return {
width: window.innerWidth,
height: window.innerHeight,
narrow: window.matchMedia?.(`(max-width: ${policy.desktopMinWidth - 1}px)`).matches,
touch: (navigator.maxTouchPoints ?? 0) > 0 || window.matchMedia?.("(any-pointer: coarse)").matches === true,
screenWidth: screen?.width ?? 0,
screenHeight: screen?.height ?? 0,
orientation,
};
}
export const MOBILE_LAYOUT_QUERY = `(max-width: ${APP_LAYOUT_POLICY.desktopMinWidth - 1}px)`;
export const COMPACT_DESKTOP_QUERY = `(max-width: ${APP_LAYOUT_POLICY.portraitDesktopMinWidth - 1}px)`;
export function isMobileLayout(): boolean {
return resolveMobileLayout(readAppLayoutInput(APP_LAYOUT_POLICY), APP_LAYOUT_POLICY);
}
const subscribers = new Set<() => void>();
let stopListening: (() => void) | undefined;
function publish() {
document.documentElement.dataset.appLayout = isMobileLayout() ? "mobile" : "desktop";
subscribers.forEach((notify) => notify());
}
/** One observer keeps CSS and all React consumers in sync during rotation and resizing. */
export function subscribeAppLayout(notify: () => void) {
subscribers.add(notify);
if (!stopListening) {
const narrow = window.matchMedia?.(MOBILE_LAYOUT_QUERY);
const coarse = window.matchMedia?.("(any-pointer: coarse)");
const orientation = window.screen?.orientation;
window.addEventListener("resize", publish);
window.addEventListener("orientationchange", publish);
orientation?.addEventListener?.("change", publish);
narrow?.addEventListener?.("change", publish);
coarse?.addEventListener?.("change", publish);
stopListening = () => {
window.removeEventListener("resize", publish);
window.removeEventListener("orientationchange", publish);
orientation?.removeEventListener?.("change", publish);
narrow?.removeEventListener?.("change", publish);
coarse?.removeEventListener?.("change", publish);
};
publish();
}
return () => {
subscribers.delete(notify);
if (subscribers.size === 0) {
stopListening?.();
stopListening = undefined;
}
};
}