FN-5707: fix Android executor footer overlap after keyboard dismiss
Adjust mobile keyboard bar flags so Android keeps the executor footer stacked above the nav bar while iOS retains collapse behavior. - extract mobile bar keyboard flag computation into a dedicated utility - keep nav keyboard pinning cross-platform while limiting footer keyboard-collapse to iOS - add focused unit tests for Android/iOS/modal/desktop keyboard flag scenarios - update ExecutorStatusBar keyboard-open test assertion and dashboard guide keyboard behavior notes Files changed: docs/dashboard-guide.md | 2 +- packages/dashboard/app/App.tsx | 23 ++++--- .../__tests__/ExecutorStatusBar.test.tsx | 6 +- .../utils/__tests__/mobileBarKeyboardFlags.test.ts | 73 ++++++++++++++++++++++ .../dashboard/app/utils/mobileBarKeyboardFlags.ts | 36 +++++++++++ 5 files changed, 129 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-5707 Fusion-Task-Lineage: ed01fcb0-d814-4595-9479-5baf97326ae7
This commit is contained in:
@@ -1027,7 +1027,7 @@ Don't create parallel button/form variants — add states (`:hover`, `:focus-vis
|
||||
|
||||
Breakpoints: 768px (primary mobile), 1024px (tablet `min-width: 769px and max-width: 1024px`), 640px (compact), 480px (xs). Mobile overrides go in `@media (max-width: 768px)` blocks at the bottom of `styles.css` after base styles.
|
||||
|
||||
**Bottom spacing:** `--mobile-nav-height` (44px) + `env(safe-area-inset-bottom, 0px)` + `--standalone-bottom-gap` (0/8px PWA). All bottom-positioned mobile elements compose those. When the soft keyboard opens, the mobile nav bar and executor footer stay pinned to page bottom and are covered by the keyboard (they do not shift upward).
|
||||
**Bottom spacing:** `--mobile-nav-height` (44px) + `env(safe-area-inset-bottom, 0px)` + `--standalone-bottom-gap` (0/8px PWA). All bottom-positioned mobile elements compose those. When the soft keyboard opens, the mobile nav bar stays pinned to page bottom cross-platform; the executor footer keyboard-collapse pin is iOS-only. On Android (`interactive-widget=resizes-content`), the footer keeps its stacked position above the nav bar to avoid overlap after keyboard dismiss.
|
||||
|
||||
**Touch targets:** Standing button-freeze directive supersedes per-button touch-target guidance. For non-button elements, primary controls (nav bar, FAB, tab action rows, modal CTAs, list-row tap targets, form controls) must be ≥36px on mobile. Secondary controls inside a card/list-row where the row itself is the tap target stay compact (24–28px or small chips).
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ import { useFavorites } from "./hooks/useFavorites";
|
||||
import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
|
||||
import { useMobileKeyboard } from "./hooks/useMobileKeyboard";
|
||||
import { isIOS, useMobileScrollLock } from "./hooks/useMobileScrollLock";
|
||||
import { computeMobileBarKeyboardFlags } from "./utils/mobileBarKeyboardFlags";
|
||||
import { useSetupReadiness } from "./hooks/useSetupReadiness";
|
||||
import { useUpdateCheck } from "./hooks/useUpdateCheck";
|
||||
import { useViewState, type TaskView } from "./hooks/useViewState";
|
||||
@@ -472,13 +473,19 @@ function AppInner() {
|
||||
// as the focus target moving and dismisses the keyboard immediately.
|
||||
// iOS doesn't shrink the layout viewport, so the iOS path keeps the
|
||||
// hide-nav-on-keyboard behavior intact.
|
||||
const mobileKeyboardOpen = isMobile && keyboardOpen && !modalManager.anyModalOpen && isIOS();
|
||||
// Nav-bar-only: pin the bar to the page bottom whenever the keyboard is up on
|
||||
// mobile, regardless of modal state or platform. The bar's `bottom` defaults
|
||||
// to `var(--icb-bottom-offset)`, which equals the keyboard height on iOS and
|
||||
// would otherwise float the bar above the keyboard. We want the keyboard to
|
||||
// simply cover the bar instead.
|
||||
const mobileNavKeyboardOpen = isMobile && keyboardOpen;
|
||||
//
|
||||
// FN-5707: keep nav pinning cross-platform, but only apply the footer
|
||||
// keyboard-collapse class on iOS. On Android, collapsing the footer to
|
||||
// `bottom: 0` overlaps it with the nav bar because the layout viewport
|
||||
// already shrinks and the stacked footer position is already correct.
|
||||
const { footerHidden, navKeyboardOpen, footerKeyboardOpen } = computeMobileBarKeyboardFlags({
|
||||
isMobile,
|
||||
keyboardOpen,
|
||||
anyModalOpen: modalManager.anyModalOpen,
|
||||
isIOS: isIOS(),
|
||||
});
|
||||
const mobileKeyboardOpen = footerHidden;
|
||||
const mobileNavKeyboardOpen = navKeyboardOpen;
|
||||
// App-level scroll lock for inline editing (TaskCard inline edit, etc.):
|
||||
// when the keyboard is up outside of any modal, pin the body so iOS can't
|
||||
// shift the document or visualViewport, and so the dashboard snaps back
|
||||
@@ -1909,7 +1916,7 @@ function AppInner() {
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
currentProjectPath={currentProject.path}
|
||||
onOpenProjectDirectory={handleOpenProjectDirectory}
|
||||
keyboardOpen={mobileNavKeyboardOpen}
|
||||
keyboardOpen={footerKeyboardOpen}
|
||||
hideWhenKeyboardOpen={mobileKeyboardOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -506,9 +506,11 @@ describe("ExecutorStatusBar", () => {
|
||||
expect(screen.getByRole("status")).toHaveClass("executor-status-bar--keyboard-open");
|
||||
});
|
||||
|
||||
it("does not apply keyboard-open class when keyboardOpen is false", () => {
|
||||
it("does not apply keyboard-open class and remains rendered when keyboardOpen is false", () => {
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} keyboardOpen={false} />);
|
||||
expect(screen.getByRole("status")).not.toHaveClass("executor-status-bar--keyboard-open");
|
||||
const status = screen.getByRole("status");
|
||||
expect(status).toBeInTheDocument();
|
||||
expect(status).not.toHaveClass("executor-status-bar--keyboard-open");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeMobileBarKeyboardFlags } from "../mobileBarKeyboardFlags";
|
||||
|
||||
describe("computeMobileBarKeyboardFlags", () => {
|
||||
it("keeps footer rendered and uncollapsed on Android when keyboard is open", () => {
|
||||
const flags = computeMobileBarKeyboardFlags({
|
||||
isMobile: true,
|
||||
keyboardOpen: true,
|
||||
anyModalOpen: false,
|
||||
isIOS: false,
|
||||
});
|
||||
|
||||
expect(flags.footerKeyboardOpen).toBe(false);
|
||||
expect(flags.footerHidden).toBe(false);
|
||||
expect(flags.navKeyboardOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("hides and collapses footer on iOS when keyboard is open and no modal is open", () => {
|
||||
const flags = computeMobileBarKeyboardFlags({
|
||||
isMobile: true,
|
||||
keyboardOpen: true,
|
||||
anyModalOpen: false,
|
||||
isIOS: true,
|
||||
});
|
||||
|
||||
expect(flags.footerHidden).toBe(true);
|
||||
expect(flags.navKeyboardOpen).toBe(true);
|
||||
expect(flags.footerKeyboardOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps footer visible when iOS keyboard is open over a modal", () => {
|
||||
const flags = computeMobileBarKeyboardFlags({
|
||||
isMobile: true,
|
||||
keyboardOpen: true,
|
||||
anyModalOpen: true,
|
||||
isIOS: true,
|
||||
});
|
||||
|
||||
expect(flags.footerHidden).toBe(false);
|
||||
expect(flags.footerKeyboardOpen).toBe(true);
|
||||
expect(flags.navKeyboardOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("returns all false when keyboard is closed", () => {
|
||||
const flags = computeMobileBarKeyboardFlags({
|
||||
isMobile: true,
|
||||
keyboardOpen: false,
|
||||
anyModalOpen: false,
|
||||
isIOS: true,
|
||||
});
|
||||
|
||||
expect(flags).toEqual({
|
||||
footerHidden: false,
|
||||
navKeyboardOpen: false,
|
||||
footerKeyboardOpen: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns all false when not mobile", () => {
|
||||
const flags = computeMobileBarKeyboardFlags({
|
||||
isMobile: false,
|
||||
keyboardOpen: true,
|
||||
anyModalOpen: false,
|
||||
isIOS: true,
|
||||
});
|
||||
|
||||
expect(flags).toEqual({
|
||||
footerHidden: false,
|
||||
navKeyboardOpen: false,
|
||||
footerKeyboardOpen: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
36
packages/dashboard/app/utils/mobileBarKeyboardFlags.ts
Normal file
36
packages/dashboard/app/utils/mobileBarKeyboardFlags.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface MobileBarKeyboardFlagsInput {
|
||||
isMobile: boolean;
|
||||
keyboardOpen: boolean;
|
||||
anyModalOpen: boolean;
|
||||
isIOS: boolean;
|
||||
}
|
||||
|
||||
export interface MobileBarKeyboardFlags {
|
||||
footerHidden: boolean;
|
||||
navKeyboardOpen: boolean;
|
||||
footerKeyboardOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* FN-5707: Android uses `interactive-widget=resizes-content`, so the layout
|
||||
* viewport shrinks with the keyboard and the footer's normal stacked bottom
|
||||
* position remains correct above the mobile nav. Only iOS should apply the
|
||||
* footer keyboard-collapse class (`bottom: 0`) used to let the keyboard cover
|
||||
* bars when visualViewport shifts independently.
|
||||
*/
|
||||
export function computeMobileBarKeyboardFlags({
|
||||
isMobile,
|
||||
keyboardOpen,
|
||||
anyModalOpen,
|
||||
isIOS,
|
||||
}: MobileBarKeyboardFlagsInput): MobileBarKeyboardFlags {
|
||||
const footerHidden = isMobile && keyboardOpen && !anyModalOpen && isIOS;
|
||||
const navKeyboardOpen = isMobile && keyboardOpen;
|
||||
const footerKeyboardOpen = navKeyboardOpen && isIOS;
|
||||
|
||||
return {
|
||||
footerHidden,
|
||||
navKeyboardOpen,
|
||||
footerKeyboardOpen,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user