fix(mobile): hide bottom nav when on-screen keyboard is up (BUG-2070) (#859)

PLAN-1694 Phase 1 specced "hide the bar when the on-screen keyboard is
up" but it shipped unimplemented — the fixed BottomNav stayed pinned
above the keyboard, eating vertical space during text entry.

Detect the keyboard from its own geometry: track visualViewport.height
against the tallest height seen (the keyboard-closed baseline) and flag
keyboardVisible when it shrinks >150px. This works on iOS Safari and
Android Chrome, unlike `innerHeight - visualViewport.height`, which stays
~0 on browsers that shrink innerHeight in lockstep with the visual
viewport. Baseline grows as the URL bar collapses on scroll and
re-captures on orientationchange so rotation/chrome don't false-trigger;
gated on isTouch.

BottomNav gates the <nav> on !keyboardVisible (docked sheets stay mounted
so QuickCapture survives raising the keyboard itself), and drops the
has-bottom-nav content-reflow padding with it to avoid a dead gap.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
This commit is contained in:
xarmian
2026-07-07 21:29:01 -04:00
committed by GitHub
parent 96c07c7981
commit 14bbd4a390
2 changed files with 45 additions and 3 deletions
+12 -3
View File
@@ -71,11 +71,12 @@
captureOpen = next;
}
// Drive the .main-content reflow only while shown (mobile). app.css owns
// the media-gated padding rule.
// Drive the .main-content reflow only while the bar is actually shown
// mobile AND no keyboard up. app.css owns the media-gated padding rule;
// dropping the class while the keyboard hides the bar avoids a dead gap.
$effect(() => {
if (typeof document === 'undefined') return;
document.body.classList.toggle('has-bottom-nav', uiStore.isMobile);
document.body.classList.toggle('has-bottom-nav', uiStore.isMobile && !uiStore.keyboardVisible);
});
onDestroy(() => {
if (typeof document !== 'undefined') document.body.classList.remove('has-bottom-nav');
@@ -83,6 +84,13 @@
</script>
{#if uiStore.isMobile && wsPrefix}
<!--
Hide the bar while the on-screen keyboard is up so it doesn't sit
stranded above the keyboard (PLAN-1694 Phase 1). The docked sheets
below stay mounted regardless — QuickCaptureSheet's title input
raises the keyboard itself, and it must survive that.
-->
{#if !uiStore.keyboardVisible}
<nav class="bottom-nav" aria-label="Primary">
<button
class="bn-item"
@@ -147,6 +155,7 @@
<span class="bn-label">You</span>
</button>
</nav>
{/if}
<WorkspaceSheet open={workspaceOpen} onclose={() => (workspaceOpen = false)} />
<YouSheet open={youOpen} onclose={() => (youOpen = false)} />
+33
View File
@@ -5,6 +5,14 @@ let topbarOpen = $state(browser ? localStorage.getItem('pad-topbar') !== 'closed
let searchOpen = $state(false);
let isMobile = $state(browser ? window.innerWidth <= 768 : false);
let isTouch = $state(browser ? 'ontouchstart' in window : false);
// True while the on-screen keyboard is up. Detected from the geometry of the
// keyboard itself — visualViewport.height shrinking below the tallest height
// we've seen (the keyboard-closed baseline). This works on iOS Safari AND
// Android Chrome; a naive `innerHeight - visualViewport.height` does NOT,
// because those browsers shrink window.innerHeight in lockstep with the visual
// viewport, leaving the delta ~0. Consumers (e.g. BottomNav) hide fixed bottom
// chrome so it doesn't sit stranded above the keyboard. PLAN-1694.
let keyboardVisible = $state(false);
let detailPanelOpen = $state(browser ? localStorage.getItem('pad-detail-panel') !== 'closed' && window.innerWidth > 768 : false);
let createWorkspaceOpen = $state(false);
let quickAddRequested = $state(false);
@@ -30,6 +38,30 @@ if (browser) {
}
}
});
// Track on-screen keyboard visibility from the visual viewport. Only touch
// devices raise a soft keyboard, so gate on isTouch — a narrow desktop
// window never has one and must not hide the nav.
if (isTouch && window.visualViewport) {
const vv = window.visualViewport;
// The tallest viewport height we've seen == keyboard closed. It grows as
// the URL bar collapses on scroll and resets on rotation, so the keyboard
// shrink is always measured against the true full-height reference.
let baseline = vv.height;
const KEYBOARD_MIN_PX = 150; // smaller shrinks are browser chrome, not a keyboard
const measure = () => {
if (vv.height > baseline) baseline = vv.height;
keyboardVisible = baseline - vv.height > KEYBOARD_MIN_PX;
};
vv.addEventListener('resize', measure);
vv.addEventListener('scroll', measure);
// Re-capture the baseline after an orientation change so a shorter
// landscape viewport isn't mistaken for an open keyboard.
window.addEventListener('orientationchange', () => {
baseline = 0;
setTimeout(measure, 300);
});
}
}
export const uiStore = {
@@ -38,6 +70,7 @@ export const uiStore = {
get searchOpen() { return searchOpen; },
get isMobile() { return isMobile; },
get isTouch() { return isTouch; },
get keyboardVisible() { return keyboardVisible; },
get detailPanelOpen() { return detailPanelOpen; },
get createWorkspaceOpen() { return createWorkspaceOpen; },