FN-5673: keep mobile nav and footer pinned when keyboard opens
Prevent keyboard viewport shifts from displacing mobile bottom bars while preserving iOS hide safeguards. - Split executor footer keyboard behavior into separate hide and pinning controls - Add keyboard-open CSS overrides to keep mobile nav/footer bottom at 0 instead of offsetting upward - Wire App to pass distinct keyboard props and expand component/CSS contract tests for keyboard-open classes and rule ordering Files changed: packages/dashboard/app/App.tsx | 3 ++- packages/dashboard/app/__tests__/mobile-bottom-bars-keyboard-layout.test.ts | 30 ++++++++++++++++++++++ packages/dashboard/app/components/ExecutorStatusBar.css | 6 +++++ packages/dashboard/app/components/ExecutorStatusBar.tsx | 14 +++++----- packages/dashboard/app/components/MobileNavBar.css | 3 ++- packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx | 17 ++++++++++++ packages/dashboard/app/components/__tests__/MobileNavBar.test.tsx | 6 +++-- 7 files changed, 69 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-5673 Fusion-Task-Lineage: 7c2dc749-214a-48ab-af58-5638ea27ae47
This commit is contained in:
@@ -1909,7 +1909,8 @@ function AppInner() {
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
currentProjectPath={currentProject.path}
|
||||
onOpenProjectDirectory={handleOpenProjectDirectory}
|
||||
keyboardOpen={mobileKeyboardOpen}
|
||||
keyboardOpen={mobileNavKeyboardOpen}
|
||||
hideWhenKeyboardOpen={mobileKeyboardOpen}
|
||||
/>
|
||||
)}
|
||||
<MobileNavBar
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadAllAppCss } from "../test/cssFixture";
|
||||
|
||||
const css = loadAllAppCss();
|
||||
|
||||
describe("mobile bottom bars keyboard-open css contract", () => {
|
||||
it("mobile nav keyboard-open rule pins bottom to 0", () => {
|
||||
const match = css.match(/\.mobile-nav-bar\.mobile-nav-bar--keyboard-open,\s*\.mobile-nav-bar\.mobile-nav-bar--with-footer\.mobile-nav-bar--keyboard-open\s*\{([^}]*)\}/m);
|
||||
expect(match?.[1] ?? "").toContain("bottom: 0");
|
||||
});
|
||||
|
||||
it("mobile nav keyboard-open rule appears after with-footer rule", () => {
|
||||
const withFooterPos = css.indexOf(".mobile-nav-bar--with-footer");
|
||||
const keyboardPos = css.indexOf(".mobile-nav-bar.mobile-nav-bar--keyboard-open");
|
||||
expect(withFooterPos).toBeGreaterThanOrEqual(0);
|
||||
expect(keyboardPos).toBeGreaterThan(withFooterPos);
|
||||
});
|
||||
|
||||
it("executor status bar keyboard-open rule pins bottom to 0", () => {
|
||||
const match = css.match(/\.executor-status-bar\.executor-status-bar--keyboard-open\s*\{([^}]*)\}/m);
|
||||
expect(match?.[1] ?? "").toContain("bottom: 0");
|
||||
});
|
||||
|
||||
it("executor status bar keyboard-open rule appears after mobile base bottom rule", () => {
|
||||
const mobileBasePos = css.indexOf("bottom: calc(var(--icb-bottom-offset, 0px) + var(--mobile-nav-height)");
|
||||
const keyboardPos = css.indexOf(".executor-status-bar.executor-status-bar--keyboard-open");
|
||||
expect(mobileBasePos).toBeGreaterThanOrEqual(0);
|
||||
expect(keyboardPos).toBeGreaterThan(mobileBasePos);
|
||||
});
|
||||
});
|
||||
@@ -298,6 +298,12 @@
|
||||
bottom: calc(var(--icb-bottom-offset, 0px) + var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), 12px) + var(--standalone-bottom-gap));
|
||||
}
|
||||
|
||||
/* Keyboard-open override: ignore ICB offset so the keyboard can cover the
|
||||
footer instead of displacing it upward by keyboard height. */
|
||||
.executor-status-bar.executor-status-bar--keyboard-open {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.executor-status-bar__segment {
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
|
||||
@@ -32,10 +32,12 @@ interface ExecutorStatusBarProps {
|
||||
currentProjectPath?: string;
|
||||
/** Opens the workspace-aware file browser to the project workspace. */
|
||||
onOpenProjectDirectory?: () => void;
|
||||
/** When true on mobile, hide the bar so it doesn't slide over messages
|
||||
* during visualViewport pans (position:fixed is anchored to layout
|
||||
* viewport, which iOS leaves below the keyboard). */
|
||||
/** When true on mobile, force bottom pinning so ICB compensation does not
|
||||
* push the bar above the keyboard; keyboard may cover it instead. */
|
||||
keyboardOpen?: boolean;
|
||||
/** iOS-only hide guard to prevent footer drifting over content while
|
||||
* visualViewport settles during keyboard transitions. */
|
||||
hideWhenKeyboardOpen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,8 +87,8 @@ function getStateDisplay(state: ExecutorState): { label: string; color: string;
|
||||
* - Executor state badge (idle/running/paused)
|
||||
* - Last activity timestamp
|
||||
*/
|
||||
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen }: ExecutorStatusBarProps) {
|
||||
if (keyboardOpen) return null;
|
||||
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen, hideWhenKeyboardOpen }: ExecutorStatusBarProps) {
|
||||
if (hideWhenKeyboardOpen) return null;
|
||||
const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs, lastFetchTimeMs);
|
||||
const [isProjectPathVisible, setIsProjectPathVisible] = useState(false);
|
||||
|
||||
@@ -136,7 +138,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`executor-status-bar ${stats.executorState === "running" ? "executor-status-bar--running" : ""}`}
|
||||
className={`executor-status-bar ${stats.executorState === "running" ? "executor-status-bar--running" : ""}${keyboardOpen ? " executor-status-bar--keyboard-open" : ""}`}
|
||||
role="status"
|
||||
aria-label="Executor status"
|
||||
>
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
compensation that would otherwise push the bar up by the keyboard
|
||||
height (iOS shrinks vv.height; bottomOffset becomes ~keyboard height).
|
||||
Pin the nav to the page bottom so the keyboard simply covers it. */
|
||||
.mobile-nav-bar--keyboard-open {
|
||||
.mobile-nav-bar.mobile-nav-bar--keyboard-open,
|
||||
.mobile-nav-bar.mobile-nav-bar--with-footer.mobile-nav-bar--keyboard-open {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -495,6 +495,23 @@ describe("ExecutorStatusBar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobile keyboard behavior", () => {
|
||||
it("hides bar when hideWhenKeyboardOpen is true", () => {
|
||||
const { container } = render(<ExecutorStatusBar tasks={emptyTasks} hideWhenKeyboardOpen={true} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("applies keyboard-open class when keyboardOpen is true", () => {
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} keyboardOpen={true} />);
|
||||
expect(screen.getByRole("status")).toHaveClass("executor-status-bar--keyboard-open");
|
||||
});
|
||||
|
||||
it("does not apply keyboard-open class when keyboardOpen is false", () => {
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} keyboardOpen={false} />);
|
||||
expect(screen.getByRole("status")).not.toHaveClass("executor-status-bar--keyboard-open");
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout integration", () => {
|
||||
it("exposes stable executor-status-bar class for external layout hooks", () => {
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
@@ -635,14 +635,16 @@ describe("MobileNavBar", () => {
|
||||
expect(container.querySelector(".mobile-nav-bar")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nav bar when keyboardOpen is true on mobile", () => {
|
||||
it("renders nav bar with keyboard-open class when keyboardOpen is true on mobile", () => {
|
||||
const { container } = render(<MobileNavBar {...createDefaultProps()} keyboardOpen={true} />);
|
||||
expect(container.querySelector(".mobile-nav-bar")).not.toBeNull();
|
||||
expect(container.querySelector(".mobile-nav-bar--keyboard-open")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders nav bar when keyboardOpen is false on mobile", () => {
|
||||
it("renders nav bar without keyboard-open class when keyboardOpen is false on mobile", () => {
|
||||
const { container } = render(<MobileNavBar {...createDefaultProps()} keyboardOpen={false} />);
|
||||
expect(container.querySelector(".mobile-nav-bar")).not.toBeNull();
|
||||
expect(container.querySelector(".mobile-nav-bar--keyboard-open")).toBeNull();
|
||||
});
|
||||
|
||||
it("applies footer-visible class when footer is shown", () => {
|
||||
|
||||
Reference in New Issue
Block a user