fix(dashboard): treat keyboard as closed on blur instead of waiting for vv settle

useMobileKeyboard now requires a focused input for keyboardOpen=true (both
the chrome-overlap and iOS-gap paths). The moment an input blurs, the hook
reports keyboardOpen=false instead of waiting hundreds of ms for iOS's
visualViewport dismissal animation to settle.

This makes App-level mobileKeyboardOpen flip false instantly on blur, so
MobileNavBar reappears and project-content regains nav-bar padding in the
same frame. The ChatView composer (and TodoModal/PlanningModeModal) snap
to their post-keyboard layout in one move instead of crawling down with
iOS's keyboard slide.

Replaces the per-component 450ms suppress hack in ChatView (also dropped
in this commit) which couldn't reach the parent layout's nav padding
state and produced "below tab bar then snap up" jitter.

Adds a regression test and updates four existing tests that assumed
"vv shrinks → keyboard up" without focus (focus is now required).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-03 19:34:28 -07:00
parent 4bb3af220e
commit 330541049d
4 changed files with 85 additions and 51 deletions

View File

@@ -64,17 +64,24 @@ function getKeyboardMetrics(): KeyboardMetrics {
updateBaselineViewportHeight(vv.height);
}
// Android/Chrome style overlap.
// Android/Chrome style overlap. Only treat as open while an input is
// actually focused — without this, the (often slow) visualViewport
// dismissal animation keeps reporting overlap > 0 for hundreds of ms
// after the user has tapped Done, which leaves App-level layout (mobile
// nav bar visibility, project-content padding) stuck in keyboard-up
// mode and makes downstream components (ChatView) jump on settle.
const chromeOverlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
if (chromeOverlap > 0) {
if (chromeOverlap > 0 && focused) {
return { overlap: chromeOverlap, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop };
}
// iOS fallback (window.innerHeight shrinks with keyboard).
// iOS fallback (window.innerHeight shrinks with keyboard). Same focused
// requirement as above — the dismissal animation otherwise leaves the
// gap > the open-threshold for the duration of the slide.
const baselineHeight = getBaselineViewportHeight();
const gap = Math.max(0, baselineHeight - vv.offsetTop - vv.height);
if (gap >= IOS_FALLBACK_MIN_GAP_PX) {
if (gap >= IOS_FALLBACK_MIN_GAP_PX && focused) {
return { overlap: gap, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop };
}