diff --git a/src/app/globals.css b/src/app/globals.css index 272d158..c2b9939 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -253,31 +253,14 @@ body.dropdown-open .app-shell { margin 300ms ease-in-out; } -body.question-keyboard-open .app-shell .question-snap-list { - padding-top: 0; - padding-bottom: 0; -} - -body.question-keyboard-open .app-shell .question-snap-item[aria-current="step"] { - top: 0; - bottom: 0; -} - -body.question-keyboard-open - .app-shell - .question-snap-item[aria-current="step"] - .question-snap-content { - /* Preserve the normal centered layout and only lift it by measured height. */ - margin-top: auto; - margin-bottom: auto; - transform: translateY(calc(var(--question-keyboard-shift, 0px) * -1)); -} - -body.question-keyboard-open - .app-shell - .question-snap-item[aria-current="step"] - .question-snap-spacer { - display: none; +.question-snap-content { + transform: translate3d( + 0, + calc(var(--question-lift-y, 0px) * -1), + 0 + ); + transition: transform 340ms cubic-bezier(0.22, 1, 0.36, 1); + will-change: transform; } .page-background-none, diff --git a/src/components/Componentes/question-sheet.tsx b/src/components/Componentes/question-sheet.tsx index a54b651..c37fc47 100644 --- a/src/components/Componentes/question-sheet.tsx +++ b/src/components/Componentes/question-sheet.tsx @@ -8,6 +8,7 @@ import { Button } from "./button"; import { ExplanationUiFont } from "./explanation-ui-font"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; +import { registerCompactQuestionSheet } from "./question-viewport-coordinator"; import { useSheetScrollLock } from "./use-sheet-scroll-lock"; const EXIT_ANIMATION_MS = 300; @@ -38,6 +39,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { const [isClosing, setIsClosing] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const listRef = useRef(null); + const sheetRef = useRef(null); const closeSheet = useCallback(() => { setIsClosing(true); window.setTimeout(() => { @@ -77,6 +79,22 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { })(); const isCompact = options.length <= 5; + useEffect(() => { + if (!isOpen || isClosing || !isCompact) return; + + let unregister: (() => void) | undefined; + const frame = window.requestAnimationFrame(() => { + if (sheetRef.current) { + unregister = registerCompactQuestionSheet(sheetRef.current); + } + }); + + return () => { + window.cancelAnimationFrame(frame); + unregister?.(); + }; + }, [isCompact, isClosing, isOpen]); + const filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()), ); @@ -200,6 +218,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { }} >
({ @@ -16,7 +17,7 @@ describe("QuestionSnapList keyboard interaction", () => { afterEach(() => { cleanup(); document.body.classList.remove("question-keyboard-open"); - document.documentElement.style.removeProperty("--question-keyboard-shift"); + document.documentElement.style.removeProperty("--question-lift-y"); }); it("does not treat pointer contact as input focus", () => { @@ -42,10 +43,27 @@ describe("QuestionSnapList keyboard interaction", () => { ); const firstInput = screen.getByRole("textbox", { name: "Question one" }); - const secondInput = screen.getByRole("textbox", { name: "Question two" }); + const secondInput = document.querySelector( + 'input[aria-label="Question two"]', + ); + const content = firstInput.closest(".question-snap-content"); + vi.spyOn(content as HTMLElement, "getBoundingClientRect").mockReturnValue({ + top: 300, + bottom: 600, + left: 0, + right: 300, + width: 300, + height: 300, + x: 0, + y: 300, + toJSON: () => ({}), + }); - fireEvent.focusIn(firstInput); - expect(document.body).toHaveClass("question-keyboard-open"); + firstInput.focus(); + updateQuestionKeyboardHeight(300); + await waitFor(() => { + expect(document.body).toHaveClass("question-keyboard-open"); + }); fireEvent.wheel(screen.getByRole("region", { name: "Questions" }), { deltaY: 100, @@ -57,7 +75,7 @@ describe("QuestionSnapList keyboard interaction", () => { expect(secondInput).not.toHaveFocus(); expect( document.documentElement.style.getPropertyValue( - "--question-keyboard-shift", + "--question-lift-y", ), ).toBe("0px"); }); @@ -75,14 +93,72 @@ describe("QuestionSnapList keyboard interaction", () => { , ); - fireEvent.focusIn(screen.getByRole("textbox", { name: "Question input" })); - expect(document.body).toHaveClass("question-keyboard-open"); + const questionInput = screen.getByRole("textbox", { + name: "Question input", + }); + const content = questionInput.closest( + ".question-snap-content", + ); + vi.spyOn(content as HTMLElement, "getBoundingClientRect").mockReturnValue({ + top: 300, + bottom: 600, + left: 0, + right: 300, + width: 300, + height: 300, + x: 0, + y: 300, + toJSON: () => ({}), + }); + + questionInput.focus(); + updateQuestionKeyboardHeight(300); + await waitFor(() => { + expect(document.body).toHaveClass("question-keyboard-open"); + }); + + screen.getByRole("textbox", { name: "Dialog search" }).focus(); + + await waitFor(() => { + expect(document.body).not.toHaveClass("question-keyboard-open"); + }); + }); + + it("raises again when Flutter reopens the keyboard without another focusin", async () => { + render( + + + , + ); + + const input = screen.getByRole("textbox", { name: "Persistent input" }); + const content = input.closest(".question-snap-content"); + vi.spyOn(content as HTMLElement, "getBoundingClientRect").mockReturnValue({ + top: 300, + bottom: 600, + left: 0, + right: 300, + width: 300, + height: 300, + x: 0, + y: 300, + toJSON: () => ({}), + }); - fireEvent.focusOut(screen.getByRole("textbox", { name: "Question input" })); - fireEvent.focusIn(screen.getByRole("textbox", { name: "Dialog search" })); + input.focus(); + updateQuestionKeyboardHeight(300); + await waitFor(() => expect(document.body).toHaveClass("question-keyboard-open")); + updateQuestionKeyboardHeight(0); await waitFor(() => { expect(document.body).not.toHaveClass("question-keyboard-open"); + expect(input).toHaveFocus(); + }); + + updateQuestionKeyboardHeight(300); + await waitFor(() => { + expect(document.body).toHaveClass("question-keyboard-open"); + expect(input).toHaveFocus(); }); }); }); diff --git a/src/components/Componentes/question-snap-list.tsx b/src/components/Componentes/question-snap-list.tsx index a37cf3f..88f6d05 100644 --- a/src/components/Componentes/question-snap-list.tsx +++ b/src/components/Componentes/question-snap-list.tsx @@ -11,8 +11,8 @@ import { } from "react"; import { resetQuestionKeyboardState, - useQuestionInputFocusSync, -} from "./use-sheet-scroll-lock"; + useQuestionViewportCoordinator, +} from "./question-viewport-coordinator"; const WHEEL_GESTURE_IDLE_MS = 320; const TOUCH_MIN_DISTANCE = 8; @@ -65,7 +65,7 @@ export function QuestionSnapList({ onActiveIndexChange, alignTop, }: QuestionSnapListProps) { - useQuestionInputFocusSync(); + useQuestionViewportCoordinator(); const questions = Children.toArray(children); const wheelLockedRef = useRef(false); const wheelUnlockTimeoutRef = useRef(null); diff --git a/src/components/Componentes/question-viewport-coordinator.ts b/src/components/Componentes/question-viewport-coordinator.ts new file mode 100644 index 0000000..c006dc6 --- /dev/null +++ b/src/components/Componentes/question-viewport-coordinator.ts @@ -0,0 +1,254 @@ +"use client"; + +import { useEffect } from "react"; +import { viewPaddingsBridge } from "@/lib/view-paddings"; + +const KEYBOARD_THRESHOLD = 80; +const QUESTION_GAP = 16; +const LIFT_VARIABLE = "--question-lift-y"; + +let activeQuestionInput: HTMLElement | null = null; +let keyboardVisible = false; +let keyboardHeight = 0; +let lastKeyboardHeight = 0; +let compactSheetTop: number | null = null; +let currentLift = 0; +let geometryFrame: number | null = null; +let closedViewportHeight = 0; + +const NON_KEYBOARD_INPUT_TYPES = new Set([ + "button", + "checkbox", + "color", + "file", + "hidden", + "image", + "radio", + "range", + "reset", + "submit", +]); + +export type QuestionLiftGeometry = { + baseTop: number; + baseBottom: number; + visibleTop: number; + visibleBottom: number; + gap?: number; +}; + +export function computeQuestionLift({ + baseTop, + baseBottom, + visibleTop, + visibleBottom, + gap = QUESTION_GAP, +}: QuestionLiftGeometry) { + const contentHeight = baseBottom - baseTop; + const availableTop = visibleTop + gap; + const availableBottom = visibleBottom - gap; + const availableCenter = (availableTop + availableBottom) / 2; + const contentCenter = baseTop + contentHeight / 2; + const centerShift = Math.max(0, contentCenter - availableCenter); + const overlapShift = Math.max(0, baseBottom - availableBottom); + const maxShift = Math.max(0, baseTop - availableTop); + + return Math.min(Math.max(centerShift, overlapShift), maxShift); +} + +function isKeyboardInputTarget(target: EventTarget | null): target is HTMLElement { + if (target instanceof HTMLTextAreaElement) { + return !target.disabled && !target.readOnly; + } + if (target instanceof HTMLInputElement) { + const type = (target.getAttribute("type") ?? "text").toLowerCase(); + return ( + !NON_KEYBOARD_INPUT_TYPES.has(type) && + !target.disabled && + !target.readOnly + ); + } + return target instanceof HTMLElement && target.isContentEditable; +} + +export function isActiveQuestionKeyboardInput( + target: EventTarget | null, +): target is HTMLElement { + return ( + isKeyboardInputTarget(target) && + target.closest('.question-snap-item[aria-current="step"]') !== null + ); +} + +function setLift(nextLift: number) { + currentLift = Math.max(0, nextLift); + document.documentElement.style.setProperty( + LIFT_VARIABLE, + `${currentLift}px`, + ); + document.body.classList.toggle( + "question-keyboard-open", + currentLift > 0 && keyboardVisible && activeQuestionInput !== null, + ); +} + +function getKeyboardTop() { + if (!keyboardVisible || !activeQuestionInput) return null; + + const flutterTop = window.innerHeight - keyboardHeight; + const viewport = window.visualViewport; + const viewportBottom = viewport + ? viewport.offsetTop + viewport.height + : window.innerHeight; + + return Math.min(flutterTop, viewportBottom); +} + +function updateLift() { + const content = document.querySelector( + '.question-snap-item[aria-current="step"] .question-snap-content', + ); + if (!content) { + setLift(0); + return; + } + + const keyboardTop = getKeyboardTop(); + const visibleBottomCandidates = [keyboardTop, compactSheetTop].filter( + (value): value is number => value !== null, + ); + if (visibleBottomCandidates.length === 0) { + setLift(0); + return; + } + + const rect = content.getBoundingClientRect(); + const snapList = content.closest(".question-snap-list"); + const baseTop = rect.top + currentLift; + const baseBottom = rect.bottom + currentLift; + const safeTop = Number.parseFloat( + getComputedStyle(document.documentElement).getPropertyValue("--safe-top"), + ); + + setLift( + computeQuestionLift({ + baseTop, + baseBottom, + visibleTop: Math.max( + Number.isFinite(safeTop) ? safeTop : 0, + snapList?.getBoundingClientRect().top ?? 0, + ), + visibleBottom: Math.min(...visibleBottomCandidates), + }), + ); +} + +function scheduleLiftUpdate() { + if (geometryFrame !== null) window.cancelAnimationFrame(geometryFrame); + geometryFrame = window.requestAnimationFrame(() => { + geometryFrame = null; + updateLift(); + }); +} + +export function updateQuestionKeyboardHeight(height: number) { + keyboardHeight = Math.max(0, height); + keyboardVisible = keyboardHeight > KEYBOARD_THRESHOLD; + if (keyboardVisible) { + lastKeyboardHeight = keyboardHeight; + if (isActiveQuestionKeyboardInput(document.activeElement)) { + activeQuestionInput = document.activeElement; + } + } + scheduleLiftUpdate(); +} + +export function registerCompactQuestionSheet(element: HTMLElement) { + compactSheetTop = element.getBoundingClientRect().top; + scheduleLiftUpdate(); + return () => { + compactSheetTop = null; + scheduleLiftUpdate(); + }; +} + +export function resetQuestionKeyboardState() { + if (geometryFrame !== null) { + window.cancelAnimationFrame(geometryFrame); + geometryFrame = null; + } + if (isActiveQuestionKeyboardInput(document.activeElement)) { + document.activeElement.blur(); + } + activeQuestionInput = null; + keyboardVisible = false; + keyboardHeight = 0; + compactSheetTop = null; + setLift(0); +} + +export function useQuestionViewportCoordinator() { + useEffect(() => { + closedViewportHeight = window.visualViewport?.height ?? window.innerHeight; + + const handleFocusIn = (event: FocusEvent) => { + if (!isActiveQuestionKeyboardInput(event.target)) return; + activeQuestionInput = event.target; + + // Start with the last known keyboard geometry so lifting begins in the + // same frame as focus; Flutter then replaces it with the exact height. + if (!keyboardVisible && lastKeyboardHeight > KEYBOARD_THRESHOLD) { + keyboardHeight = lastKeyboardHeight; + keyboardVisible = true; + } + scheduleLiftUpdate(); + }; + + const handleFocusOut = () => { + window.setTimeout(() => { + activeQuestionInput = isActiveQuestionKeyboardInput( + document.activeElement, + ) + ? document.activeElement + : null; + if (!activeQuestionInput) scheduleLiftUpdate(); + }, 0); + }; + + const unsubscribeConfig = viewPaddingsBridge.subscribeConfig((config) => { + updateQuestionKeyboardHeight(config.keyboardHeight); + }); + + const handleViewportResize = () => { + const viewportHeight = window.visualViewport?.height ?? window.innerHeight; + const reduction = closedViewportHeight - viewportHeight; + + if (reduction > KEYBOARD_THRESHOLD) { + if (isActiveQuestionKeyboardInput(document.activeElement)) { + activeQuestionInput = document.activeElement; + keyboardVisible = true; + keyboardHeight = Math.max(keyboardHeight, reduction); + lastKeyboardHeight = keyboardHeight; + scheduleLiftUpdate(); + } + } else if (keyboardHeight <= KEYBOARD_THRESHOLD) { + keyboardVisible = false; + keyboardHeight = 0; + closedViewportHeight = viewportHeight; + scheduleLiftUpdate(); + } + }; + + document.addEventListener("focusin", handleFocusIn); + document.addEventListener("focusout", handleFocusOut); + window.visualViewport?.addEventListener("resize", handleViewportResize); + + return () => { + document.removeEventListener("focusin", handleFocusIn); + document.removeEventListener("focusout", handleFocusOut); + window.visualViewport?.removeEventListener("resize", handleViewportResize); + unsubscribeConfig(); + resetQuestionKeyboardState(); + }; + }, []); +} diff --git a/src/components/Componentes/use-sheet-scroll-lock.ts b/src/components/Componentes/use-sheet-scroll-lock.ts index 5e6ed22..d9b1204 100644 --- a/src/components/Componentes/use-sheet-scroll-lock.ts +++ b/src/components/Componentes/use-sheet-scroll-lock.ts @@ -1,18 +1,14 @@ "use client"; import { useEffect, useRef } from "react"; -import { viewPaddingsBridge } from "@/lib/view-paddings"; let activeSheetCount = 0; -let hasFocusedKeyboardInput = false; -let keyboardHeight = 0; let bodyHadDropdownClass = false; let initialBodyOverflow = ""; let initialHtmlOverflow = ""; let initialAppShellOverflow = ""; let initialAppShellTouchAction = ""; let lockedAppShell: HTMLElement | null = null; -let keyboardShiftFrame: number | null = null; const SHEET_HISTORY_KEY = "__habibQuestionSheet"; type SheetScrollLockOptions = { @@ -93,154 +89,3 @@ export function useSheetScrollLock( }; }, [isOpen]); } - -const NON_KEYBOARD_INPUT_TYPES = new Set([ - "button", - "checkbox", - "color", - "file", - "hidden", - "image", - "radio", - "range", - "reset", - "submit", -]); - -function isKeyboardInputTarget(target: EventTarget | null): boolean { - if (target instanceof HTMLTextAreaElement) { - return !target.disabled && !target.readOnly; - } - if (target instanceof HTMLInputElement) { - const type = (target.getAttribute("type") ?? "text").toLowerCase(); - return ( - !NON_KEYBOARD_INPUT_TYPES.has(type) && - !target.disabled && - !target.readOnly - ); - } - if (target instanceof HTMLElement) { - return target.isContentEditable; - } - return false; -} - -function syncQuestionInputOpenClass() { - if (hasFocusedKeyboardInput) { - document.body.classList.add("question-keyboard-open"); - } else { - document.body.classList.remove("question-keyboard-open"); - } -} - -function syncKeyboardShift(isOpen: boolean) { - const root = document.documentElement; - if (!isOpen) { - root.style.setProperty("--question-keyboard-shift", "0px"); - return; - } - - const activeContent = document.querySelector( - '.question-snap-item[aria-current="step"] .question-snap-content', - ); - const snapList = document.querySelector(".question-snap-list"); - if (!activeContent || !snapList) return; - - const contentTop = activeContent.getBoundingClientRect().top; - const targetTop = snapList.getBoundingClientRect().top + 8; - const requiredShift = Math.max(0, contentTop - targetTop); - root.style.setProperty("--question-keyboard-shift", `${requiredShift}px`); -} - -function isActiveQuestionKeyboardInput( - target: EventTarget | null, -): target is HTMLElement { - return ( - isKeyboardInputTarget(target) && - target instanceof HTMLElement && - target.closest('.question-snap-item[aria-current="step"]') !== null - ); -} - -export function resetQuestionKeyboardState() { - if (keyboardShiftFrame !== null) { - window.cancelAnimationFrame(keyboardShiftFrame); - keyboardShiftFrame = null; - } - - if (isActiveQuestionKeyboardInput(document.activeElement)) { - document.activeElement.blur(); - } - - hasFocusedKeyboardInput = false; - keyboardHeight = 0; - syncQuestionInputOpenClass(); - syncKeyboardShift(false); -} - -/** - * Moves the active question up (same animation the dropdown sheet uses) while - * a keyboard input is focused, so the mobile keyboard cannot cover it. - */ -export function useQuestionInputFocusSync() { - useEffect(() => { - const handleFocusIn = (event: FocusEvent) => { - if (!isActiveQuestionKeyboardInput(event.target)) return; - hasFocusedKeyboardInput = true; - syncQuestionInputOpenClass(); - if (keyboardShiftFrame !== null) { - window.cancelAnimationFrame(keyboardShiftFrame); - } - keyboardShiftFrame = window.requestAnimationFrame(() => { - keyboardShiftFrame = null; - syncKeyboardShift(true); - }); - }; - const handleFocusOut = (event: FocusEvent) => { - if (!isKeyboardInputTarget(event.target)) return; - window.setTimeout(() => { - hasFocusedKeyboardInput = isActiveQuestionKeyboardInput( - document.activeElement, - ); - syncQuestionInputOpenClass(); - if (!hasFocusedKeyboardInput) syncKeyboardShift(false); - }, 0); - }; - - const unsubscribeConfig = viewPaddingsBridge.subscribeConfig((config) => { - keyboardHeight = config.keyboardHeight; - if (keyboardHeight === 0) { - // Android Back can hide Flutter's keyboard without dispatching blur. - hasFocusedKeyboardInput = false; - } - syncQuestionInputOpenClass(); - if (keyboardHeight === 0) syncKeyboardShift(false); - }); - - const initialViewportHeight = window.visualViewport?.height ?? 0; - const handleViewportResize = () => { - const viewportHeight = window.visualViewport?.height ?? 0; - if ( - initialViewportHeight > 0 && - viewportHeight >= initialViewportHeight - 40 - ) { - hasFocusedKeyboardInput = false; - syncQuestionInputOpenClass(); - } - }; - - document.addEventListener("focusin", handleFocusIn); - document.addEventListener("focusout", handleFocusOut); - window.visualViewport?.addEventListener("resize", handleViewportResize); - return () => { - document.removeEventListener("focusin", handleFocusIn); - document.removeEventListener("focusout", handleFocusOut); - window.visualViewport?.removeEventListener( - "resize", - handleViewportResize, - ); - unsubscribeConfig(); - resetQuestionKeyboardState(); - }; - }, []); -}