refactor(viewport): centralize browser metrics
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { observeBrowserViewport } from "../viewport_observer";
|
||||
|
||||
class MockVisualViewport extends EventTarget {
|
||||
height = 760;
|
||||
offsetLeft = 0;
|
||||
offsetTop = 0;
|
||||
scale = 1;
|
||||
width = 390;
|
||||
}
|
||||
|
||||
class MockVirtualKeyboard extends EventTarget {
|
||||
overlaysContent = false;
|
||||
boundingRect = { height: 0 };
|
||||
}
|
||||
|
||||
describe("viewport observer", () => {
|
||||
let visualViewport: MockVisualViewport;
|
||||
let virtualKeyboard: MockVirtualKeyboard;
|
||||
|
||||
beforeEach(() => {
|
||||
visualViewport = new MockVisualViewport();
|
||||
virtualKeyboard = new MockVirtualKeyboard();
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
value: visualViewport,
|
||||
});
|
||||
Object.defineProperty(navigator, "virtualKeyboard", {
|
||||
configurable: true,
|
||||
value: virtualKeyboard,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("shares one browser listener set across multiple consumers", () => {
|
||||
const addWindowListener = vi.spyOn(window, "addEventListener");
|
||||
const removeWindowListener = vi.spyOn(window, "removeEventListener");
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
|
||||
const stopFirst = observeBrowserViewport(first);
|
||||
const stopSecond = observeBrowserViewport(second);
|
||||
|
||||
expect(
|
||||
addWindowListener.mock.calls.filter(
|
||||
([event]) => String(event) === "resize",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(first).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason: "initial" }),
|
||||
);
|
||||
expect(second).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason: "initial" }),
|
||||
);
|
||||
|
||||
visualViewport.height = 460;
|
||||
visualViewport.dispatchEvent(new Event("resize"));
|
||||
expect(first).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
reason: "visualViewport.resize",
|
||||
metrics: expect.objectContaining({ visualHeight: 460 }),
|
||||
}),
|
||||
);
|
||||
expect(second).toHaveBeenCalledTimes(2);
|
||||
|
||||
stopFirst();
|
||||
visualViewport.dispatchEvent(new Event("scroll"));
|
||||
expect(first).toHaveBeenCalledTimes(2);
|
||||
expect(second).toHaveBeenCalledTimes(3);
|
||||
|
||||
stopSecond();
|
||||
expect(
|
||||
removeWindowListener.mock.calls.filter(
|
||||
([event]) => String(event) === "resize",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
export type ViewportObservationReason =
|
||||
| "initial"
|
||||
| "window.resize"
|
||||
| "window.scroll"
|
||||
| "orientationchange"
|
||||
| "focusin"
|
||||
| "focusout"
|
||||
| "visibilitychange"
|
||||
| "visualViewport.resize"
|
||||
| "visualViewport.scroll"
|
||||
| "virtualKeyboard.geometrychange";
|
||||
|
||||
export interface BrowserViewportMetrics {
|
||||
layoutHeight: number;
|
||||
layoutWidth: number;
|
||||
documentClientHeight: number;
|
||||
scrollY: number;
|
||||
visualViewportSupported: boolean;
|
||||
visualHeight: number;
|
||||
visualWidth: number;
|
||||
visualOffsetTop: number;
|
||||
visualOffsetLeft: number;
|
||||
visualScale: number;
|
||||
virtualKeyboardSupported: boolean;
|
||||
virtualKeyboardOverlaysContent: boolean | null;
|
||||
virtualKeyboardRect: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ViewportObservation {
|
||||
reason: ViewportObservationReason;
|
||||
metrics: BrowserViewportMetrics;
|
||||
}
|
||||
|
||||
interface VirtualKeyboardLike extends EventTarget {
|
||||
readonly overlaysContent?: boolean;
|
||||
readonly boundingRect?: Pick<
|
||||
DOMRectReadOnly,
|
||||
"height" | "width" | "x" | "y"
|
||||
>;
|
||||
}
|
||||
|
||||
interface NavigatorWithVirtualKeyboard extends Navigator {
|
||||
readonly virtualKeyboard?: VirtualKeyboardLike;
|
||||
}
|
||||
|
||||
type ViewportObserver = (observation: ViewportObservation) => void;
|
||||
|
||||
const observers = new Set<ViewportObserver>();
|
||||
let removeBrowserListeners: (() => void) | null = null;
|
||||
|
||||
export function observeBrowserViewport(
|
||||
observer: ViewportObserver,
|
||||
options: { emitInitial?: boolean } = {},
|
||||
): () => void {
|
||||
if (typeof window === "undefined") return () => undefined;
|
||||
|
||||
observers.add(observer);
|
||||
ensureBrowserListeners();
|
||||
if (options.emitInitial !== false) {
|
||||
observer({
|
||||
reason: "initial",
|
||||
metrics: readBrowserViewportMetrics(),
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
observers.delete(observer);
|
||||
if (observers.size === 0) {
|
||||
removeBrowserListeners?.();
|
||||
removeBrowserListeners = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function readBrowserViewportMetrics(): BrowserViewportMetrics {
|
||||
const visualViewport = window.visualViewport;
|
||||
const virtualKeyboard = getVirtualKeyboard();
|
||||
const rect = virtualKeyboard?.boundingRect;
|
||||
|
||||
return {
|
||||
layoutHeight: window.innerHeight,
|
||||
layoutWidth: window.innerWidth,
|
||||
documentClientHeight: document.documentElement.clientHeight,
|
||||
scrollY: window.scrollY,
|
||||
visualViewportSupported: visualViewport != null,
|
||||
visualHeight: visualViewport?.height ?? window.innerHeight,
|
||||
visualWidth: visualViewport?.width ?? window.innerWidth,
|
||||
visualOffsetTop: visualViewport?.offsetTop ?? 0,
|
||||
visualOffsetLeft: visualViewport?.offsetLeft ?? 0,
|
||||
visualScale: visualViewport?.scale ?? 1,
|
||||
virtualKeyboardSupported: virtualKeyboard != null,
|
||||
virtualKeyboardOverlaysContent:
|
||||
virtualKeyboard?.overlaysContent ?? null,
|
||||
virtualKeyboardRect: rect
|
||||
? {
|
||||
x: rect.x ?? 0,
|
||||
y: rect.y ?? 0,
|
||||
width: rect.width ?? 0,
|
||||
height: rect.height ?? 0,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isViewportGeometryObservation(
|
||||
reason: ViewportObservationReason,
|
||||
): boolean {
|
||||
return (
|
||||
reason === "initial" ||
|
||||
reason === "window.resize" ||
|
||||
reason === "orientationchange" ||
|
||||
reason === "visualViewport.resize" ||
|
||||
reason === "visualViewport.scroll" ||
|
||||
reason === "virtualKeyboard.geometrychange"
|
||||
);
|
||||
}
|
||||
|
||||
function ensureBrowserListeners(): void {
|
||||
if (removeBrowserListeners) return;
|
||||
|
||||
const removers: Array<() => void> = [];
|
||||
const listen = (
|
||||
target: EventTarget | null | undefined,
|
||||
eventName: string,
|
||||
reason: ViewportObservationReason,
|
||||
) => {
|
||||
if (!target) return;
|
||||
const handler = () => notifyObservers(reason);
|
||||
target.addEventListener(eventName, handler);
|
||||
removers.push(() => target.removeEventListener(eventName, handler));
|
||||
};
|
||||
|
||||
listen(window, "resize", "window.resize");
|
||||
listen(window, "scroll", "window.scroll");
|
||||
listen(window, "orientationchange", "orientationchange");
|
||||
listen(document, "focusin", "focusin");
|
||||
listen(document, "focusout", "focusout");
|
||||
listen(document, "visibilitychange", "visibilitychange");
|
||||
listen(window.visualViewport, "resize", "visualViewport.resize");
|
||||
listen(window.visualViewport, "scroll", "visualViewport.scroll");
|
||||
listen(
|
||||
getVirtualKeyboard(),
|
||||
"geometrychange",
|
||||
"virtualKeyboard.geometrychange",
|
||||
);
|
||||
|
||||
removeBrowserListeners = () => {
|
||||
removers.forEach((remove) => remove());
|
||||
};
|
||||
}
|
||||
|
||||
function notifyObservers(reason: ViewportObservationReason): void {
|
||||
const observation = {
|
||||
reason,
|
||||
metrics: readBrowserViewportMetrics(),
|
||||
};
|
||||
observers.forEach((observer) => observer(observation));
|
||||
}
|
||||
|
||||
function getVirtualKeyboard(): VirtualKeyboardLike | null {
|
||||
return (
|
||||
(navigator as NavigatorWithVirtualKeyboard).virtualKeyboard ?? null
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user