SPB Git

Repository Public

use-viewport.ts 3.8 KB 108 lines typescript

1'use client';2import { useEffect, useSyncExternalStore } from 'react';3import { create } from 'zustand';45/** null until mounted (static export renders without a viewport). */6export type Breakpoint = 'mobile' | 'tablet' | 'desktop';7function subscribe(cb: () => void): () => void {8  const a = window.matchMedia('(min-width: 768px)');9  const b = window.matchMedia('(min-width: 1024px)');10  a.addEventListener('change', cb);11  b.addEventListener('change', cb);12  return () => {13    a.removeEventListener('change', cb);14    b.removeEventListener('change', cb);15  };16}17const snapshot = (): Breakpoint => (window.matchMedia('(min-width: 1024px)').matches ? 'desktop' : window.matchMedia('(min-width: 768px)').matches ? 'tablet' : 'mobile');18export function useBreakpoint(): Breakpoint | null {19  return useSyncExternalStore(subscribe, snapshot, () => null);20}2122interface VvState {23  keyboardOpen: boolean;24  height: number;25}26export const useVv = create<VvState>(() => ({ keyboardOpen: false, height: 0 }));2728/**29 * Drives the layout height from window.visualViewport so that, on iOS, the bottom of the app30 * (the key toolbar) sits exactly on top of the software keyboard. Sets --vvh / --vvt on <html>.31 */32export function useVisualViewport(): void {33  useEffect(() => {34    const vv = window.visualViewport;35    const root = document.documentElement;36    let maxH = 0;37    let lastW = 0;38    let raf = 0;39    const apply = () => {40      raf = 0;41      const h = Math.round(vv?.height ?? window.innerHeight);42      const w = Math.round(vv?.width ?? window.innerWidth);43      if (w !== lastW) {44        lastW = w; // rotation: forget the previous orientation's full height45        maxH = 0;46      }47      maxH = Math.max(maxH, h, vv ? 0 : window.innerHeight);48      root.style.setProperty('--vvh', `${h}px`);49      root.style.setProperty('--vvt', `${Math.round(vv?.offsetTop ?? 0)}px`);50      const keyboardOpen = maxH - h > 140;51      const s = useVv.getState();52      if (s.keyboardOpen !== keyboardOpen || s.height !== h) useVv.setState({ keyboardOpen, height: h });53      // iOS scrolls the layout viewport to reveal the focused field; the shell is fixed, so undo it.54      if (keyboardOpen && (window.scrollY !== 0 || window.scrollX !== 0)) window.scrollTo(0, 0);55    };56    const queue = () => {57      if (!raf) raf = requestAnimationFrame(apply);58    };59    apply();60    vv?.addEventListener('resize', queue);61    vv?.addEventListener('scroll', queue);62    window.addEventListener('resize', queue);63    window.addEventListener('orientationchange', queue);64    return () => {65      vv?.removeEventListener('resize', queue);66      vv?.removeEventListener('scroll', queue);67      window.removeEventListener('resize', queue);68      window.removeEventListener('orientationchange', queue);69      if (raf) cancelAnimationFrame(raf);70    };71  }, []);72}7374/** Re-render every `ms` (relative times, live durations). Paused while the tab is hidden. */75export function useNow(ms = 10_000): number {76  const store = getTicker(ms);77  return useSyncExternalStore(store.subscribe, store.get, store.get);78}79const tickers = new Map<number, { subscribe: (cb: () => void) => () => void; get: () => number }>();80function getTicker(ms: number) {81  let t = tickers.get(ms);82  if (t) return t;83  let now = Date.now();84  const subs = new Set<() => void>();85  let timer: ReturnType<typeof setInterval> | null = null;86  const tick = () => {87    if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;88    now = Date.now();89    subs.forEach((s) => s());90  };91  t = {92    subscribe(cb) {93      subs.add(cb);94      timer ??= setInterval(tick, ms);95      return () => {96        subs.delete(cb);97        if (subs.size === 0 && timer) {98          clearInterval(timer);99          timer = null;100        }101      };102    },103    get: () => now,104  };105  tickers.set(ms, t);106  return t;107}108
Files main
Menu

Theme