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:
@@ -785,32 +785,14 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
enabled: isMobile && !!activeSession,
|
||||
});
|
||||
|
||||
// Suppresses keyboard-following styles for ~450ms after blur so the
|
||||
// composer snaps to full height immediately instead of crawling down with
|
||||
// iOS's keyboard-dismiss animation. See `handleInputBlur` below for where
|
||||
// this is set, and QuickChatFAB's `suppressVvShrinkRef` for the same trick
|
||||
// applied to the FAB panel.
|
||||
const [suppressKeyboardStyle, setSuppressKeyboardStyle] = useState(false);
|
||||
const suppressKeyboardStyleTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const threadKeyboardStyle: CSSProperties = keyboardOpen
|
||||
? suppressKeyboardStyle
|
||||
? // Suppress window: snap composer to the final post-keyboard height
|
||||
// immediately, but reserve space for the MobileNavBar that is about
|
||||
// to reappear once App-level keyboardOpen settles to false. Without
|
||||
// the reservation the composer briefly extends below the nav bar
|
||||
// position, then jumps up when the nav reappears.
|
||||
({
|
||||
"--keyboard-overlap":
|
||||
"calc(var(--mobile-nav-height, 44px) + env(safe-area-inset-bottom, 0px))",
|
||||
"--vv-offset-top": "0px",
|
||||
} as CSSProperties)
|
||||
: ({
|
||||
const threadKeyboardStyle: CSSProperties =
|
||||
keyboardOpen
|
||||
? ({
|
||||
"--keyboard-overlap": `${keyboardOverlap}px`,
|
||||
"--vv-offset-top": `${viewportOffsetTop}px`,
|
||||
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
||||
} as CSSProperties)
|
||||
: {};
|
||||
: {};
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
const normalizedFilter = skillFilter.trim().toLowerCase();
|
||||
@@ -1334,22 +1316,6 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-grow the composer ahead of iOS's keyboard-dismiss animation so it
|
||||
// snaps to full height immediately instead of crawling down with the
|
||||
// keyboard. The suppress window covers the iOS slide-out duration
|
||||
// (~250ms with margin) during which visualViewport keeps reporting
|
||||
// mid-dismiss heights that would otherwise drag the panel back down.
|
||||
if (isMobile) {
|
||||
setSuppressKeyboardStyle(true);
|
||||
if (suppressKeyboardStyleTimeoutRef.current !== null) {
|
||||
window.clearTimeout(suppressKeyboardStyleTimeoutRef.current);
|
||||
}
|
||||
suppressKeyboardStyleTimeoutRef.current = window.setTimeout(() => {
|
||||
setSuppressKeyboardStyle(false);
|
||||
suppressKeyboardStyleTimeoutRef.current = null;
|
||||
}, 450);
|
||||
}
|
||||
|
||||
if (hideSkillMenuTimeoutRef.current !== null) {
|
||||
window.clearTimeout(hideSkillMenuTimeoutRef.current);
|
||||
}
|
||||
@@ -1363,16 +1329,9 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
fileMention.dismissMention();
|
||||
hideSkillMenuTimeoutRef.current = null;
|
||||
}, 120);
|
||||
}, [fileMention, focusComposerInput, isMobile]);
|
||||
}, [fileMention, focusComposerInput]);
|
||||
|
||||
const handleInputFocus = useCallback(() => {
|
||||
// If the user re-focuses inside the post-blur suppress window, lift it
|
||||
// immediately so the keyboard-aware sizing kicks back in.
|
||||
if (suppressKeyboardStyleTimeoutRef.current !== null) {
|
||||
window.clearTimeout(suppressKeyboardStyleTimeoutRef.current);
|
||||
suppressKeyboardStyleTimeoutRef.current = null;
|
||||
}
|
||||
setSuppressKeyboardStyle(false);
|
||||
if (hideSkillMenuTimeoutRef.current !== null) {
|
||||
window.clearTimeout(hideSkillMenuTimeoutRef.current);
|
||||
hideSkillMenuTimeoutRef.current = null;
|
||||
|
||||
@@ -122,6 +122,10 @@ describe("useMobileKeyboard", () => {
|
||||
vvHeight: 600,
|
||||
});
|
||||
|
||||
const input = document.createElement("textarea");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
const { result } = renderHook(() => useMobileKeyboard());
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -148,6 +152,8 @@ describe("useMobileKeyboard", () => {
|
||||
expect(result.current.keyboardOverlap).toBe(100);
|
||||
expect(result.current.viewportHeight).toBe(700);
|
||||
});
|
||||
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("unsubscribes listeners and resets state when disabled", async () => {
|
||||
@@ -156,6 +162,10 @@ describe("useMobileKeyboard", () => {
|
||||
vvHeight: 600,
|
||||
});
|
||||
|
||||
const input = document.createElement("textarea");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useMobileKeyboard({ enabled }),
|
||||
{ initialProps: { enabled: true } },
|
||||
@@ -178,6 +188,8 @@ describe("useMobileKeyboard", () => {
|
||||
|
||||
expect(mockVV.removeEventListener).toHaveBeenCalledWith("resize", resizeListener);
|
||||
expect(mockVV.removeEventListener).toHaveBeenCalledWith("scroll", scrollListener);
|
||||
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("uses iOS Safari fallback when innerHeight shrinks with visualViewport", async () => {
|
||||
@@ -186,6 +198,10 @@ describe("useMobileKeyboard", () => {
|
||||
vvHeight: 844,
|
||||
});
|
||||
|
||||
const input = document.createElement("textarea");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
const { result } = renderHook(() => useMobileKeyboard());
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -212,6 +228,8 @@ describe("useMobileKeyboard", () => {
|
||||
expect(result.current.keyboardOverlap).toBe(324);
|
||||
expect(result.current.viewportHeight).toBe(520);
|
||||
});
|
||||
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("reports moderate iOS fallback overlap below 80px", async () => {
|
||||
@@ -220,6 +238,10 @@ describe("useMobileKeyboard", () => {
|
||||
vvHeight: 844,
|
||||
});
|
||||
|
||||
const input = document.createElement("textarea");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
const { result } = renderHook(() => useMobileKeyboard());
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -245,6 +267,8 @@ describe("useMobileKeyboard", () => {
|
||||
expect(result.current.keyboardOverlap).toBe(40);
|
||||
expect(result.current.viewportHeight).toBe(804);
|
||||
});
|
||||
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("uses focused-input fallback for small viewport gaps", async () => {
|
||||
@@ -336,6 +360,50 @@ describe("useMobileKeyboard", () => {
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("reports keyboardOpen=false the instant focus leaves an input even while visualViewport still reports keyboard-up size", async () => {
|
||||
// Regression for the ChatView "composer crawls down with the keyboard"
|
||||
// bug: on iOS the visualViewport keeps reporting the small mid-dismiss
|
||||
// size for hundreds of ms after the user blurs an input. App-level
|
||||
// layout (mobile nav bar, project-content padding) must flip back to
|
||||
// no-keyboard mode immediately on blur, not when vv finally settles.
|
||||
const { listeners, mockVV } = setupMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
});
|
||||
|
||||
const input = document.createElement("textarea");
|
||||
document.body.appendChild(input);
|
||||
|
||||
const { result } = renderHook(() => useMobileKeyboard());
|
||||
|
||||
// Bring up the keyboard: focus the input, then shrink the viewport.
|
||||
input.focus();
|
||||
Object.defineProperty(window, "innerHeight", { value: 520, writable: true, configurable: true });
|
||||
Object.defineProperty(mockVV, "height", { value: 520, writable: true, configurable: true });
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.keyboardOpen).toBe(true);
|
||||
});
|
||||
|
||||
// Blur, but leave visualViewport still reporting the small mid-dismiss
|
||||
// size — the dismissal animation takes hundreds of ms on iOS.
|
||||
input.blur();
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.keyboardOpen).toBe(false);
|
||||
});
|
||||
|
||||
input.remove();
|
||||
});
|
||||
|
||||
// FN-3290 regression: focusout must reset keyboard state when input blurs
|
||||
describe("FN-3290: focusout resets keyboard state", () => {
|
||||
it("resets keyboardOpen to false on focusout when viewport returns to baseline", async () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user