From a0b35cf3353dd43934eb3730a633325d4c6d4640 Mon Sep 17 00:00:00 2001 From: Trevor-Compaytence Date: Wed, 14 Oct 2026 10:53:36 -0600 Subject: [PATCH 1/4] fix(web): iPhone keyboard no longer hides the composer or scrolls the header away iOS Safari ignores interactive-widget=resizes-content, so opening the keyboard only shrinks the visual viewport and pans the page. The header scrolled off and the composer sat behind the keyboard. On phones the app shell now follows visualViewport height and offset, so the header stays put and the composer sits on the keyboard while the timeline shrinks. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/AppSidebarLayout.tsx | 7 +- apps/web/src/components/ChatView.tsx | 1 + apps/web/src/components/chat/ChatComposer.tsx | 4 +- apps/web/src/hooks/useMediaQuery.ts | 7 + .../src/hooks/useVisualViewportSync.test.ts | 162 ++++++++++++++++++ apps/web/src/hooks/useVisualViewportSync.ts | 101 +++++++++++ apps/web/src/index.css | 65 +++++++ 7 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/hooks/useVisualViewportSync.test.ts create mode 100644 apps/web/src/hooks/useVisualViewportSync.ts diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index fd56ee8ca5ac..70905dd6d510 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -33,6 +33,8 @@ import { } from "../panelAnimations"; import LegacyThreadSidebar from "./LegacySidebar"; import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; +import { useIsPhoneViewport } from "../hooks/useMediaQuery"; +import { useVisualViewportSync } from "../hooks/useVisualViewportSync"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome"; @@ -236,6 +238,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { // sidebar is active. // Seeds server-side visited tracking from this browser's localStorage the useThreadVisitedMigration(); + useVisualViewportSync(useIsPhoneViewport()); const pathname = useLocation({ select: (location) => location.pathname }); const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname); const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed; @@ -314,7 +317,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { return (
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 80ef5ecd054b..73d4b2a23f47 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1142,7 +1142,7 @@ import { } from "@t3tools/client-runtime/providerSkills"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useDelayedStatus } from "../../hooks/useDelayedStatus"; -import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { useIsPhoneViewport } from "../../hooks/useMediaQuery"; import { usePanelAnimationSettings } from "../../panelAnimations"; import { useAtomCommand } from "../../state/use-atom-command"; import { readEnvironmentScope } from "../../state/session"; @@ -2471,7 +2471,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); - const isMobileViewport = useMediaQuery("max-sm"); + const isMobileViewport = useIsPhoneViewport(); const { isComposerFocused, setIsComposerFocused, diff --git a/apps/web/src/hooks/useMediaQuery.ts b/apps/web/src/hooks/useMediaQuery.ts index e7e007b0b7c6..9e9378f82e9b 100644 --- a/apps/web/src/hooks/useMediaQuery.ts +++ b/apps/web/src/hooks/useMediaQuery.ts @@ -85,3 +85,10 @@ export function useMediaQuery(query: BreakpointQuery | MediaQueryInput | (string export function useIsMobile(): boolean { return useMediaQuery("max-md"); } + +/** Keep phone viewport handling active when a touch phone rotates to landscape. */ +export function useIsPhoneViewport(): boolean { + return useMediaQuery( + "(max-width: 639px), (pointer: coarse) and (max-width: 1023px) and (max-height: 639px)", + ); +} diff --git a/apps/web/src/hooks/useVisualViewportSync.test.ts b/apps/web/src/hooks/useVisualViewportSync.test.ts new file mode 100644 index 000000000000..5e7d58704b04 --- /dev/null +++ b/apps/web/src/hooks/useVisualViewportSync.test.ts @@ -0,0 +1,162 @@ +import { act, createElement } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { measureVisualViewport, useVisualViewportSync } from "./useVisualViewportSync"; + +describe("measureVisualViewport", () => { + it("reports no keyboard when the visible area fills the layout viewport", () => { + expect(measureVisualViewport(844, { height: 844, offsetTop: 0, scale: 1 })).toEqual({ + height: 844, + offsetTop: 0, + keyboardInset: 0, + keyboardOpen: false, + }); + }); + + it("measures an iOS keyboard that overlays the page without panning", () => { + expect(measureVisualViewport(844, { height: 508, offsetTop: 0, scale: 1 })).toEqual({ + height: 508, + offsetTop: 0, + keyboardInset: 336, + keyboardOpen: true, + }); + }); + + it("keeps the shell on the visible area after iOS pans to reveal the field", () => { + expect(measureVisualViewport(844, { height: 508, offsetTop: 336, scale: 1 })).toEqual({ + height: 508, + offsetTop: 336, + keyboardInset: 0, + keyboardOpen: true, + }); + }); + + it("treats a Chromium resizes-content keyboard as a plain smaller viewport", () => { + expect(measureVisualViewport(444, { height: 444, offsetTop: 0, scale: 1 })).toMatchObject({ + height: 444, + keyboardInset: 0, + }); + }); + + it("ignores toolbar collapse that is too small to be a keyboard", () => { + expect(measureVisualViewport(844, { height: 770, offsetTop: 0, scale: 1 })?.keyboardOpen).toBe( + false, + ); + }); + + it("stands down while the user is pinch-zoomed", () => { + expect(measureVisualViewport(844, { height: 300, offsetTop: 120, scale: 2 })).toBeNull(); + }); +}); + +describe("useVisualViewportSync lifecycle", () => { + let renderer: ReactTestRenderer | null; + let viewport: EventTarget & { height: number; offsetTop: number; scale: number }; + let browser: EventTarget & { + innerHeight: number; + innerWidth: number; + visualViewport: typeof viewport; + }; + let root: { style: CSSStyleDeclaration; dataset: DOMStringMap }; + + function Probe({ enabled }: { enabled: boolean }) { + useVisualViewportSync(enabled); + return null; + } + + async function render(enabled = true) { + await act(() => { + if (renderer) renderer.update(createElement(Probe, { enabled })); + else renderer = create(createElement(Probe, { enabled })); + }); + } + + function resize(height: number, offsetTop = 0, layoutHeight = 844) { + Object.assign(viewport, { height, offsetTop }); + browser.innerHeight = layoutHeight; + viewport.dispatchEvent(new Event("resize")); + browser.dispatchEvent(new Event("resize")); + } + + beforeEach(() => { + renderer = null; + viewport = Object.assign(new EventTarget(), { height: 844, offsetTop: 0, scale: 1 }); + browser = Object.assign(new EventTarget(), { + innerHeight: 844, + innerWidth: 390, + visualViewport: viewport, + }); + const properties = new Map(); + root = { + style: { + setProperty: (key: string, value: string) => properties.set(key, value), + removeProperty: (key: string) => properties.delete(key), + getPropertyValue: (key: string) => properties.get(key) ?? "", + } as unknown as CSSStyleDeclaration, + dataset: {}, + }; + vi.stubGlobal("window", browser); + vi.stubGlobal("document", { documentElement: root }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + }); + + afterEach(async () => { + await act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + }); + + it.each(["overlay", "resize"])("survives repeated %s keyboard cycles", async (mode) => { + await render(); + for (let cycle = 0; cycle < 5; cycle++) { + resize(444, mode === "overlay" ? 200 : 0, mode === "resize" ? 444 : 844); + expect(root.dataset.keyboardOpen).toBe(mode === "overlay" ? "true" : undefined); + expect(root.style.getPropertyValue("--app-viewport-height")).toBe("444px"); + resize(844); + expect(root.dataset.keyboardOpen).toBeUndefined(); + expect(root.style.getPropertyValue("--app-viewport-offset-top")).toBe("0px"); + } + }); + + it("follows rotation with a Chromium keyboard already open", async () => { + await render(); + resize(444, 0, 444); + browser.innerWidth = 844; + resize(190, 0, 190); + expect(root.style.getPropertyValue("--app-viewport-height")).toBe("190px"); + expect(root.style.getPropertyValue("--app-viewport-offset-top")).toBe("0px"); + resize(390, 0, 390); + expect(root.style.getPropertyValue("--app-viewport-height")).toBe("390px"); + expect(root.dataset.keyboardOpen).toBeUndefined(); + }); + + it("updates keyboard state even when the published geometry does not change", async () => { + browser.innerHeight = 600; + Object.assign(viewport, { height: 500, offsetTop: 200 }); + await render(); + expect(root.dataset.keyboardOpen).toBeUndefined(); + resize(500, 200, 650); + expect(root.dataset.keyboardOpen).toBe("true"); + }); + + it("releases the document lock for zoom, route unmount, and desktop", async () => { + await render(); + viewport.scale = 2; + resize(300, 120); + expect(root.dataset.viewportSync).toBeUndefined(); + expect(root.style.getPropertyValue("--app-viewport-height")).toBe(""); + viewport.scale = 1; + resize(508, 336); + expect(root.dataset.viewportSync).toBe("true"); + await render(false); + expect(root.dataset).toEqual({}); + resize(444); + expect(root.dataset).toEqual({}); + await render(); + await act(() => renderer?.unmount()); + renderer = null; + resize(844); + expect(root.dataset).toEqual({}); + expect(root.style.getPropertyValue("--app-viewport-height")).toBe(""); + }); +}); diff --git a/apps/web/src/hooks/useVisualViewportSync.ts b/apps/web/src/hooks/useVisualViewportSync.ts new file mode 100644 index 000000000000..15000343cf2a --- /dev/null +++ b/apps/web/src/hooks/useVisualViewportSync.ts @@ -0,0 +1,101 @@ +import { useEffect } from "react"; + +/** Below this much lost height we treat the change as browser chrome, not a keyboard. */ +export const KEYBOARD_OPEN_THRESHOLD_PX = 120; + +export interface VisualViewportMetrics { + /** Height of the area the user can actually see, keyboard excluded. */ + height: number; + /** How far the browser has panned the visible area down the layout viewport (iOS does this on focus). */ + offsetTop: number; + /** Layout height hidden under the on-screen keyboard. */ + keyboardInset: number; + keyboardOpen: boolean; +} + +interface VisualViewportLike { + height: number; + offsetTop: number; + scale: number; +} + +/** + * Measures the visible area against the layout viewport. Returns null while the user is + * pinch-zoomed, where the visual viewport says nothing about the keyboard. + * + * iOS Safari ignores `interactive-widget=resizes-content`, so its keyboard shrinks only the + * visual viewport and pans it (`offsetTop`) to reveal the focused field. Chromium honours the + * meta tag and shrinks the layout viewport too, which yields a zero inset here. + */ +export function measureVisualViewport( + layoutHeight: number, + viewport: VisualViewportLike, +): VisualViewportMetrics | null { + if (viewport.scale > 1.01) return null; + const height = Math.round(viewport.height); + const offsetTop = Math.max(0, Math.round(viewport.offsetTop)); + const lostHeight = Math.max(0, Math.round(layoutHeight) - height); + return { + height, + offsetTop, + keyboardInset: Math.max(0, lostHeight - offsetTop), + keyboardOpen: lostHeight > KEYBOARD_OPEN_THRESHOLD_PX, + }; +} + +const HEIGHT_VAR = "--app-viewport-height"; +const OFFSET_VAR = "--app-viewport-offset-top"; +const INSET_VAR = "--keyboard-inset"; + +function clearViewportSync(root: HTMLElement) { + root.style.removeProperty(HEIGHT_VAR); + root.style.removeProperty(OFFSET_VAR); + root.style.removeProperty(INSET_VAR); + delete root.dataset.keyboardOpen; + delete root.dataset.viewportSync; +} + +/** + * Pins the app shell to the visible area on phones: the header stays at the top, the composer + * sits directly on the keyboard, and the page itself never scrolls. + * Writes `--app-viewport-height` and `--app-viewport-offset-top` for the shell, `--keyboard-inset` + * for anything that needs it, and `data-viewport-sync` / `data-keyboard-open` on ``. + */ +export function useVisualViewportSync(enabled: boolean) { + useEffect(() => { + if (!enabled || typeof window === "undefined") return; + const viewport = window.visualViewport; + if (!viewport) return; + const root = document.documentElement; + let last = ""; + + const apply = () => { + const metrics = measureVisualViewport(window.innerHeight, viewport); + if (!metrics) { + last = ""; + clearViewportSync(root); + return; + } + const key = `${metrics.height}:${metrics.offsetTop}:${metrics.keyboardInset}:${metrics.keyboardOpen}`; + if (key === last) return; + last = key; + root.dataset.viewportSync = "true"; + root.style.setProperty(HEIGHT_VAR, `${metrics.height}px`); + root.style.setProperty(OFFSET_VAR, `${metrics.offsetTop}px`); + root.style.setProperty(INSET_VAR, `${metrics.keyboardInset}px`); + if (metrics.keyboardOpen) root.dataset.keyboardOpen = "true"; + else delete root.dataset.keyboardOpen; + }; + + apply(); + viewport.addEventListener("resize", apply); + viewport.addEventListener("scroll", apply); + window.addEventListener("resize", apply); + return () => { + viewport.removeEventListener("resize", apply); + viewport.removeEventListener("scroll", apply); + window.removeEventListener("resize", apply); + clearViewportSync(root); + }; + }, [enabled]); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 90f204d201fe..3dd1722c3532 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -705,6 +705,14 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil body { @apply text-foreground relative; } + /* While the phone shell is pinned to the visual viewport (useVisualViewportSync), focusing the + composer must not scroll or rubber-band the document behind it. Pages outside the shell, + such as pairing, keep normal document scrolling. */ + html[data-viewport-sync], + html[data-viewport-sync] body { + overflow: hidden; + overscroll-behavior: none; + } } @layer components { @@ -2367,3 +2375,60 @@ code { } } } + +/* Route pages size their inset with h-dvh/h-svh, which iOS Safari does not shrink for the + keyboard. On phones they follow the visible area instead (useVisualViewportSync). Unlayered so + it outranks those utilities without !important. */ +html[data-viewport-sync] [data-slot="sidebar-wrapper"] { + position: fixed; + inset-inline: 0; + top: var(--app-viewport-offset-top); + contain: layout; +} + +html[data-viewport-sync] [data-slot="sidebar-inset"] { + height: var(--app-viewport-height); +} + +/* body and #root normally clip the app. Standalone phone routes such as pairing + need a scrollport when their form is taller than the screen. */ +@media (width < 40rem), (pointer: coarse) and (width < 64rem) and (height < 40rem) { + html:not([data-viewport-sync]) #root { + overflow-y: auto; + } +} + +/* The thread drawer is portaled outside the shell's containing block. */ +html[data-viewport-sync] + [data-slot="sheet-viewport"]:has([data-sidebar="sidebar"][data-mobile="true"]) { + top: var(--app-viewport-offset-top); + bottom: auto; + height: var(--app-viewport-height); + contain: layout; +} + +/* Apply even when Chromium resizes the layout, or a route mounts with its keyboard + already open. A tiny landscape viewport must also override the editor's minimum. */ +html[data-viewport-sync] .composer-tiptap { + /* Reserve the header, composer controls and bottom gap in short landscape viewports. */ + --phone-editor-max-height: min( + 13rem, + calc(var(--app-viewport-height) * 0.4), + max(1rem, calc(var(--app-viewport-height) - 8rem)) + ); + max-height: var(--phone-editor-max-height); + min-height: min(4.875rem, var(--phone-editor-max-height)); +} + +/* Focus also covers Chromium's resized layout, where viewport geometry cannot + distinguish an open keyboard from a small window, including after rotation. */ +html[data-viewport-sync][data-keyboard-open] [data-composer-bottom-spacer], +html[data-viewport-sync]:has(.composer-tiptap:focus) [data-composer-bottom-spacer] { + height: 0.5rem; +} + +@media (pointer: coarse) { + html[data-viewport-sync] .composer-tiptap { + font-size: var(--font-size-prompt-touch); + } +} From b1eb2db97d356f820511241e500138212d14c14f Mon Sep 17 00:00:00 2001 From: Trevor-Compaytence Date: Wed, 14 Oct 2026 11:12:33 -0600 Subject: [PATCH 2/4] fix(web): keep the pinned phone shell below the top safe area Limit viewport sync to portrait touch phones so narrow desktop windows and landscape phones keep their existing layout, and only drop the composer's bottom safe-area gap when the keyboard is actually open. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/AppSidebarLayout.tsx | 2 +- apps/web/src/components/chat/ChatComposer.tsx | 4 +- apps/web/src/hooks/useMediaQuery.ts | 6 +-- .../src/hooks/useVisualViewportSync.test.ts | 9 ++++ apps/web/src/index.css | 48 ++++++------------- 5 files changed, 29 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 70905dd6d510..75cda4778548 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -319,7 +319,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { { expect(root.dataset.keyboardOpen).toBeUndefined(); }); + it("follows a Safari pan that only fires a visual viewport scroll", async () => { + await render(); + resize(508); + viewport.offsetTop = 336; + viewport.dispatchEvent(new Event("scroll")); + expect(root.style.getPropertyValue("--app-viewport-offset-top")).toBe("336px"); + expect(root.style.getPropertyValue("--keyboard-inset")).toBe("0px"); + }); + it("updates keyboard state even when the published geometry does not change", async () => { browser.innerHeight = 600; Object.assign(viewport, { height: 500, offsetTop: 200 }); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3dd1722c3532..655c5f980570 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2376,26 +2376,24 @@ code { } } -/* Route pages size their inset with h-dvh/h-svh, which iOS Safari does not shrink for the - keyboard. On phones they follow the visible area instead (useVisualViewportSync). Unlayered so - it outranks those utilities without !important. */ +/* While useVisualViewportSync is active, the shell is pinned to the visible area below the top + safe area, so the keyboard never pans the header away or covers the composer. Route pages size + their inset with h-dvh/h-svh, which iOS Safari does not shrink for the keyboard. Unlayered so + these rules outrank those utilities without !important. */ +html[data-viewport-sync] { + --app-shell-height: calc(var(--app-viewport-height) - env(safe-area-inset-top)); +} + html[data-viewport-sync] [data-slot="sidebar-wrapper"] { position: fixed; inset-inline: 0; - top: var(--app-viewport-offset-top); + top: calc(var(--app-viewport-offset-top) + env(safe-area-inset-top)); + /* Makes the shell the containing block of the header's fixed sidebar and panel toggles. */ contain: layout; } html[data-viewport-sync] [data-slot="sidebar-inset"] { - height: var(--app-viewport-height); -} - -/* body and #root normally clip the app. Standalone phone routes such as pairing - need a scrollport when their form is taller than the screen. */ -@media (width < 40rem), (pointer: coarse) and (width < 64rem) and (height < 40rem) { - html:not([data-viewport-sync]) #root { - overflow-y: auto; - } + height: var(--app-shell-height); } /* The thread drawer is portaled outside the shell's containing block. */ @@ -2407,28 +2405,12 @@ html[data-viewport-sync] contain: layout; } -/* Apply even when Chromium resizes the layout, or a route mounts with its keyboard - already open. A tiny landscape viewport must also override the editor's minimum. */ +/* Long prompts scroll inside the editor instead of pushing the header off a short screen. */ html[data-viewport-sync] .composer-tiptap { - /* Reserve the header, composer controls and bottom gap in short landscape viewports. */ - --phone-editor-max-height: min( - 13rem, - calc(var(--app-viewport-height) * 0.4), - max(1rem, calc(var(--app-viewport-height) - 8rem)) - ); - max-height: var(--phone-editor-max-height); - min-height: min(4.875rem, var(--phone-editor-max-height)); + max-height: min(13rem, calc(var(--app-shell-height) * 0.4)); } -/* Focus also covers Chromium's resized layout, where viewport geometry cannot - distinguish an open keyboard from a small window, including after rotation. */ -html[data-viewport-sync][data-keyboard-open] [data-composer-bottom-spacer], -html[data-viewport-sync]:has(.composer-tiptap:focus) [data-composer-bottom-spacer] { +/* The keyboard covers the home indicator, so the safe-area gap below the composer is not needed. */ +html[data-viewport-sync][data-keyboard-open] [data-composer-bottom-spacer] { height: 0.5rem; } - -@media (pointer: coarse) { - html[data-viewport-sync] .composer-tiptap { - font-size: var(--font-size-prompt-touch); - } -} From c30c5ae7780588bb72e8ab23b76d1b014803f4f5 Mon Sep 17 00:00:00 2001 From: Trevor-Compaytence Date: Wed, 14 Oct 2026 11:18:07 -0600 Subject: [PATCH 3/4] fix(web): align the drawer's sidebar toggle with the pinned shell Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/sidebar/SidebarChrome.tsx | 2 +- apps/web/src/index.css | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index ae05529374c3..1cc60d53dfac 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -57,7 +57,7 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ > {backdropVariant ? : null} {/* Match the fixed control's inset and border compensation inside the sheet's safe area. */} -
+
Date: Thu, 15 Oct 2026 10:46:13 -0600 Subject: [PATCH 4/4] fix(web): leave small phones held sideways out of the viewport sync A coarse-pointer phone under 640px wide in landscape still matched the phone query, so it got the pinned shell and the document scroll lock. The check now also requires a portrait screen. It reads screen.orientation rather than an (orientation: portrait) query, because with interactive-widget=resizes-content an Android keyboard can make the viewport wider than tall and would switch the sync off mid-typing. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/hooks/useMediaQuery.test.ts | 104 +++++++++++++++++++++++ apps/web/src/hooks/useMediaQuery.ts | 31 ++++++- 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/hooks/useMediaQuery.test.ts diff --git a/apps/web/src/hooks/useMediaQuery.test.ts b/apps/web/src/hooks/useMediaQuery.test.ts new file mode 100644 index 000000000000..7e54a12ecab6 --- /dev/null +++ b/apps/web/src/hooks/useMediaQuery.test.ts @@ -0,0 +1,104 @@ +import { act, createElement } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useIsPhoneViewport } from "./useMediaQuery"; + +describe("useIsPhoneViewport", () => { + let renderer: ReactTestRenderer | null; + let viewport: { width: number; height: number; coarse: boolean }; + let orientation: (EventTarget & { type: string }) | undefined; + let queries: Array; + + function evaluate(media: string): boolean { + if (media === "(orientation: portrait)") return viewport.height >= viewport.width; + return viewport.width <= 639 && viewport.coarse; + } + + function Probe() { + return createElement("output", { value: String(useIsPhoneViewport()) }); + } + + function phone(): boolean { + return renderer?.root.findByType("output").props.value === "true"; + } + + async function render() { + await act(() => { + renderer = create(createElement(Probe)); + }); + } + + async function rotate(width: number, height: number, type?: string) { + await act(() => { + Object.assign(viewport, { width, height }); + for (const query of queries) { + query.matches = evaluate(query.media); + query.dispatchEvent(new Event("change")); + } + if (orientation && type) { + orientation.type = type; + orientation.dispatchEvent(new Event("change")); + } + }); + } + + beforeEach(() => { + renderer = null; + viewport = { width: 390, height: 844, coarse: true }; + orientation = Object.assign(new EventTarget(), { type: "portrait-primary" }); + queries = []; + vi.stubGlobal("window", { + get screen() { + return { orientation }; + }, + matchMedia: (media: string) => { + const query = Object.assign(new EventTarget(), { media, matches: evaluate(media) }); + queries.push(query); + return query; + }, + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + }); + + afterEach(async () => { + await act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + }); + + it("matches a portrait touch phone", async () => { + await render(); + expect(phone()).toBe(true); + }); + + it("does not match a narrow window with a mouse", async () => { + viewport.coarse = false; + await render(); + expect(phone()).toBe(false); + }); + + it("turns off for a small phone on its side and back on when it is upright", async () => { + viewport = { width: 360, height: 600, coarse: true }; + await render(); + await rotate(600, 360, "landscape-primary"); + expect(phone()).toBe(false); + await rotate(360, 600, "portrait-primary"); + expect(phone()).toBe(true); + }); + + it("stays on when a resizes-content keyboard makes the viewport wider than tall", async () => { + viewport = { width: 412, height: 915, coarse: true }; + await render(); + await rotate(412, 380); + expect(phone()).toBe(true); + }); + + it("falls back to the orientation query without screen.orientation", async () => { + orientation = undefined; + viewport = { width: 360, height: 600, coarse: true }; + await render(); + expect(phone()).toBe(true); + await rotate(600, 360); + expect(phone()).toBe(false); + }); +}); diff --git a/apps/web/src/hooks/useMediaQuery.ts b/apps/web/src/hooks/useMediaQuery.ts index cfca0975e33c..47a92b36b056 100644 --- a/apps/web/src/hooks/useMediaQuery.ts +++ b/apps/web/src/hooks/useMediaQuery.ts @@ -86,7 +86,34 @@ export function useIsMobile(): boolean { return useMediaQuery("max-md"); } -/** A portrait touch phone, as opposed to a narrow desktop window. */ +function subscribeToScreenOrientation(onChange: () => void): () => void { + const orientation = window.screen?.orientation; + if (orientation) { + orientation.addEventListener("change", onChange); + return () => orientation.removeEventListener("change", onChange); + } + const mql = window.matchMedia("(orientation: portrait)"); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); +} + +/** The device's orientation, not the viewport's: under interactive-widget=resizes-content an + Android keyboard shrinks the viewport until it is wider than tall, which an + `(orientation: portrait)` query would read as landscape mid-typing. Safari before 16.4 has no + screen.orientation, and iOS never resizes the layout viewport, so the query is safe there. */ +function readScreenIsPortrait(): boolean { + const type = window.screen?.orientation?.type; + if (type) return type.startsWith("portrait"); + return window.matchMedia("(orientation: portrait)").matches; +} + +/** A portrait touch phone, as opposed to a narrow desktop window or a small phone on its side. */ export function useIsPhoneViewport(): boolean { - return useMediaQuery("(max-width: 639px) and (pointer: coarse)"); + const isPhoneSized = useMediaQuery("(max-width: 639px) and (pointer: coarse)"); + const isPortrait = useSyncExternalStore( + subscribeToScreenOrientation, + readScreenIsPortrait, + getServerSnapshot, + ); + return isPhoneSized && isPortrait; }