Add useIsMobileViewport hook + Online-Board mobile time defaults per TZ 4.1.1-R4/R5

This commit is contained in:
2026-04-21 19:03:54 +03:00
parent bc0b10bd8e
commit 9aed10c281
4 changed files with 164 additions and 0 deletions
@@ -0,0 +1,54 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useIsMobileViewport } from "./useIsMobileViewport.js";
describe("useIsMobileViewport", () => {
let matchMediaMock: ReturnType<typeof vi.fn>;
let listeners: ((e: { matches: boolean }) => void)[];
let currentMatches = false;
beforeEach(() => {
listeners = [];
currentMatches = false;
matchMediaMock = vi.fn().mockImplementation((query: string) => ({
matches: currentMatches,
media: query,
addEventListener: (_type: string, listener: (e: { matches: boolean }) => void) => {
listeners.push(listener);
},
removeEventListener: () => {},
dispatchEvent: () => false,
}));
Object.defineProperty(window, "matchMedia", { value: matchMediaMock, configurable: true });
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns false when viewport is wider than mobile breakpoint", () => {
currentMatches = false;
const { result } = renderHook(() => useIsMobileViewport());
expect(result.current).toBe(false);
});
it("returns true when viewport is narrower than mobile breakpoint", () => {
currentMatches = true;
const { result } = renderHook(() => useIsMobileViewport());
expect(result.current).toBe(true);
});
it("updates when media-query match changes", () => {
currentMatches = false;
const { result } = renderHook(() => useIsMobileViewport());
expect(result.current).toBe(false);
act(() => {
for (const l of listeners) l({ matches: true });
});
expect(result.current).toBe(true);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
/** Mobile breakpoint — matches the project's `$media-breakpoint-mobile` token (640px). */
const MOBILE_BREAKPOINT_PX = 640;
/**
* Returns `true` when the viewport is ≤ the mobile breakpoint. Used to
* branch TZ-prescribed mobile-only behaviors (e.g. Online-Board's
* `Время рейса` default = -1h/+3h per §4.1.1-R5).
*
* SSR: returns `false` initially, then updates on first client render.
*/
export function useIsMobileViewport(): boolean {
const [isMobile, setIsMobile] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`).matches;
});
useEffect(() => {
if (typeof window === "undefined") return;
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`);
const listener = (e: MediaQueryListEvent | { matches: boolean }) => setIsMobile(e.matches);
setIsMobile(mql.matches);
mql.addEventListener("change", listener as (e: MediaQueryListEvent) => void);
return () => mql.removeEventListener("change", listener as (e: MediaQueryListEvent) => void);
}, []);
return isMobile;
}