fix(viewport): keep large phones in mobile mode so the bottom nav renders
FN-8557 excluded any touch device from mobile mode when its reported physical screen exceeded the 480px phone threshold, at any CSS width. Large Android phones report exactly that, so they resolved to tablet: MobileNavBar returns null off mode "mobile", while MobileNavBar.css still displayed at (max-width: 768px) — JS and CSS disagreeing about the same device. The board also fell back to the horizontally-scrollable desktop layout. No tablet uses a <=600px viewport as its primary layout, so width now overrides the physical-screen heuristic below that floor; the carve-out FN-8557 wanted (portrait tablets at the 768 boundary) keeps the 601-768 band. The floor checks all three width signals the classifier already trusts — media query, innerWidth, visualViewport — since each can lead on a different device or update late on rotation. Regression tests cover all three; each fails against the pre-fix classifier. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/mobile-nav-large-phones.md
Normal file
7
.changeset/mobile-nav-large-phones.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Restore the mobile bottom nav bar on large phones, which were being treated as tablets.
|
||||||
|
category: fix
|
||||||
|
dev: `isMobileViewport()` gained a phone width floor (`PHONE_MAX_CSS_WIDTH` = 600) that overrides the FN-8557 `isTabletClassTouchScreen()` exclusion. That check treats any touch device whose `window.screen` min edge exceeds 480px as tablet-class, which large Android phones report, so they lost mobile mode at any CSS width while `MobileNavBar.css` still displayed at `(max-width: 768px)`. The tablet carve-out now applies only in the 601-768px band.
|
||||||
@@ -5,6 +5,7 @@ import { getViewportMode, isFullScreenSheetViewport, isMobileViewport, MOBILE_ME
|
|||||||
const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)";
|
const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)";
|
||||||
const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)";
|
const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)";
|
||||||
const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)";
|
const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)";
|
||||||
|
const PHONE_WIDTH_MEDIA_QUERY = "(max-width: 600px)";
|
||||||
const originalScreenDescriptor = Object.getOwnPropertyDescriptor(window, "screen");
|
const originalScreenDescriptor = Object.getOwnPropertyDescriptor(window, "screen");
|
||||||
|
|
||||||
function stubScreen(width: number, height: number) {
|
function stubScreen(width: number, height: number) {
|
||||||
@@ -150,6 +151,97 @@ describe("useViewportMode", () => {
|
|||||||
expect(renderHook(() => useViewportMode()).result.current).toBe("tablet");
|
expect(renderHook(() => useViewportMode()).result.current).toBe("tablet");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ViewportMode 2026-07-24-18:55:
|
||||||
|
Regression cover for large Android phones losing the bottom nav bar. `window.screen`
|
||||||
|
is not a reliable phone/tablet discriminator — these devices report a screen min edge
|
||||||
|
above the 480px phone threshold, so the tablet-class exclusion stripped mobile mode at
|
||||||
|
any CSS width. The invariant is width-based and asserted across every width signal the
|
||||||
|
classifier reads (media query, innerWidth, visualViewport), not just the reported case,
|
||||||
|
because each of the three can lead on a different device or update late on rotation.
|
||||||
|
*/
|
||||||
|
describe("phone width floor overrides physical-screen tablet classification", () => {
|
||||||
|
const LARGE_PHONE_SCREEN: [number, number] = [1080, 2400];
|
||||||
|
|
||||||
|
function withTouch(run: () => void) {
|
||||||
|
const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
|
||||||
|
Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 5 });
|
||||||
|
try {
|
||||||
|
run();
|
||||||
|
} finally {
|
||||||
|
if (originalMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", originalMaxTouchPoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubPhoneWidthMedia(phoneWidthMatches: boolean) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"matchMedia",
|
||||||
|
vi.fn((query: string) => ({
|
||||||
|
matches:
|
||||||
|
query === MOBILE_MEDIA_QUERY || query === MOBILE_WIDTH_MEDIA_QUERY
|
||||||
|
? true
|
||||||
|
: query === PHONE_WIDTH_MEDIA_QUERY
|
||||||
|
? phoneWidthMatches
|
||||||
|
: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("stays mobile when the phone width media query matches", () => {
|
||||||
|
withTouch(() => {
|
||||||
|
stubScreen(...LARGE_PHONE_SCREEN);
|
||||||
|
stubPhoneWidthMedia(true);
|
||||||
|
|
||||||
|
expect(isMobileViewport()).toBe(true);
|
||||||
|
expect(getViewportMode()).toBe("mobile");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays mobile on a narrow innerWidth even when the media query has not updated", () => {
|
||||||
|
withTouch(() => {
|
||||||
|
stubScreen(...LARGE_PHONE_SCREEN);
|
||||||
|
stubPhoneWidthMedia(false);
|
||||||
|
vi.stubGlobal("innerWidth", 430);
|
||||||
|
|
||||||
|
expect(isMobileViewport()).toBe(true);
|
||||||
|
expect(getViewportMode()).toBe("mobile");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays mobile on a narrow visualViewport pane", () => {
|
||||||
|
withTouch(() => {
|
||||||
|
stubScreen(...LARGE_PHONE_SCREEN);
|
||||||
|
stubPhoneWidthMedia(false);
|
||||||
|
const originalVisualViewport = window.visualViewport;
|
||||||
|
Object.defineProperty(window, "visualViewport", {
|
||||||
|
configurable: true,
|
||||||
|
value: { width: 412, height: 915, addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(isMobileViewport()).toBe(true);
|
||||||
|
expect(getViewportMode()).toBe("mobile");
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(window, "visualViewport", { configurable: true, value: originalVisualViewport });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the 601-768px tablet band to the physical-screen classification", () => {
|
||||||
|
withTouch(() => {
|
||||||
|
stubScreen(768, 1024);
|
||||||
|
stubPhoneWidthMedia(false);
|
||||||
|
vi.stubGlobal("innerWidth", 700);
|
||||||
|
|
||||||
|
expect(isMobileViewport()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a touch tablet at the 768px boundary out of the phone presentation", () => {
|
it("keeps a touch tablet at the 768px boundary out of the phone presentation", () => {
|
||||||
const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
|
const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
|
||||||
stubScreen(768, 1024);
|
stubScreen(768, 1024);
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ export const MOBILE_MEDIA_QUERY = "(max-width: 768px), (max-height: 480px)";
|
|||||||
const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)";
|
const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)";
|
||||||
const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)";
|
const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ViewportMode 2026-07-24-18:55:
|
||||||
|
No tablet renders a viewport this narrow as its primary layout, so a CSS width at
|
||||||
|
or below this is a phone regardless of what `window.screen` reports. The physical-
|
||||||
|
screen heuristic below cannot be trusted on its own: large Android phones report a
|
||||||
|
screen min edge above the 480px phone threshold, which classified them tablet-class
|
||||||
|
and dropped the bottom nav bar entirely while `MobileNavBar.css` still displayed at
|
||||||
|
`(max-width: 768px)` — JS and CSS disagreeing about the same device. The tablet
|
||||||
|
carve-out only ever needed the 601-768px band (portrait tablets at the boundary).
|
||||||
|
*/
|
||||||
|
const PHONE_WIDTH_MEDIA_QUERY = "(max-width: 600px)";
|
||||||
|
const PHONE_MAX_CSS_WIDTH = 600;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:ModalGeometryPersistence 2026-07-15-19:30:
|
FNXC:ModalGeometryPersistence 2026-07-15-19:30:
|
||||||
Full-screen FloatingWindow sheets use only the CSS width breakpoint. This deliberately diverges from
|
Full-screen FloatingWindow sheets use only the CSS width breakpoint. This deliberately diverges from
|
||||||
@@ -64,6 +77,23 @@ function isTabletClassTouchScreen(): boolean {
|
|||||||
return hasTouchScreen() && hasKnownPhysicalScreenSize() && !isPhoneClassScreen();
|
return hasTouchScreen() && hasKnownPhysicalScreenSize() && !isPhoneClassScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the CSS viewport is narrow enough to be a phone on width alone.
|
||||||
|
*
|
||||||
|
* FNXC:ViewportMode 2026-07-24-18:55:
|
||||||
|
* Overrides the physical-screen tablet classification: no `window.screen` value
|
||||||
|
* can turn a <=600px CSS viewport into a tablet presentation. Checks every width
|
||||||
|
* signal the module already trusts (visual viewport, media query, innerWidth) so
|
||||||
|
* a delayed media-query update on rotation cannot strand a phone in tablet mode.
|
||||||
|
*/
|
||||||
|
function isPhoneClassWidth(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
const visualWidth = getTouchVisualViewportWidth();
|
||||||
|
if (visualWidth !== null && visualWidth <= PHONE_MAX_CSS_WIDTH) return true;
|
||||||
|
if (typeof window.matchMedia === "function" && window.matchMedia(PHONE_WIDTH_MEDIA_QUERY).matches) return true;
|
||||||
|
return window.innerWidth > 0 && window.innerWidth <= PHONE_MAX_CSS_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
export function isMobileViewport(): boolean {
|
export function isMobileViewport(): boolean {
|
||||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
||||||
const visualWidth = getTouchVisualViewportWidth();
|
const visualWidth = getTouchVisualViewportWidth();
|
||||||
@@ -82,7 +112,14 @@ export function isMobileViewport(): boolean {
|
|||||||
const hasNarrowWidth = window.innerWidth <= 768 ||
|
const hasNarrowWidth = window.innerWidth <= 768 ||
|
||||||
window.matchMedia(MOBILE_WIDTH_MEDIA_QUERY).matches ||
|
window.matchMedia(MOBILE_WIDTH_MEDIA_QUERY).matches ||
|
||||||
(visualWidth !== null && visualWidth <= 768);
|
(visualWidth !== null && visualWidth <= 768);
|
||||||
return (hasNarrowWidth && !isTabletClassTouchScreen()) ||
|
/*
|
||||||
|
FNXC:ViewportMode 2026-07-24-18:55:
|
||||||
|
The tablet exclusion applies only above the phone width floor. Without that
|
||||||
|
guard every touch device whose reported physical screen exceeds the 480px phone
|
||||||
|
threshold — which includes large Android phones — lost mobile mode at any width,
|
||||||
|
taking the bottom nav bar with it.
|
||||||
|
*/
|
||||||
|
return (hasNarrowWidth && (isPhoneClassWidth() || !isTabletClassTouchScreen())) ||
|
||||||
((window.matchMedia(MOBILE_HEIGHT_MEDIA_QUERY).matches || (visualHeight !== null && visualHeight <= 480)) && isPhoneClassScreen());
|
((window.matchMedia(MOBILE_HEIGHT_MEDIA_QUERY).matches || (visualHeight !== null && visualHeight <= 480)) && isPhoneClassScreen());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user