From 66cecd6639e0c28d4fc8d91581a2635f8b474291 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Mon, 17 Aug 2026 14:21:15 +0330 Subject: [PATCH] refactor: improve mobile keyboard interaction sync by introducing strict input validation and explicit state resets --- .../Componentes/question-snap-list.test.tsx | 88 +++++++++++++++++++ .../Componentes/question-snap-list.tsx | 71 ++------------- .../Componentes/use-sheet-scroll-lock.ts | 61 ++++++++----- 3 files changed, 134 insertions(+), 86 deletions(-) create mode 100644 src/components/Componentes/question-snap-list.test.tsx diff --git a/src/components/Componentes/question-snap-list.test.tsx b/src/components/Componentes/question-snap-list.test.tsx new file mode 100644 index 0000000..f7489d7 --- /dev/null +++ b/src/components/Componentes/question-snap-list.test.tsx @@ -0,0 +1,88 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { QuestionSnapList } from "./question-snap-list"; + +vi.mock("@/translations/provider", () => ({ + useI18n: () => ({ dictionary: {} }), +})); + +describe("QuestionSnapList keyboard interaction", () => { + afterEach(() => { + cleanup(); + document.body.classList.remove("question-keyboard-open"); + document.documentElement.style.removeProperty("--question-keyboard-shift"); + }); + + it("does not treat pointer contact as input focus", () => { + render( + + + , + ); + + fireEvent.pointerDown( + screen.getByRole("textbox", { name: "Question one" }), + ); + + expect(document.body).not.toHaveClass("question-keyboard-open"); + }); + + it("activates only on real focus and resets before changing questions", async () => { + render( + + + + , + ); + + const firstInput = screen.getByRole("textbox", { name: "Question one" }); + const secondInput = screen.getByRole("textbox", { name: "Question two" }); + + fireEvent.focusIn(firstInput); + expect(document.body).toHaveClass("question-keyboard-open"); + + fireEvent.wheel(screen.getByRole("region", { name: "Questions" }), { + deltaY: 100, + }); + + await waitFor(() => { + expect(document.body).not.toHaveClass("question-keyboard-open"); + expect(firstInput).not.toHaveFocus(); + expect(secondInput).not.toHaveFocus(); + expect( + document.documentElement.style.getPropertyValue( + "--question-keyboard-shift", + ), + ).toBe("0px"); + }); + }); + + it("does not keep question keyboard state for a dialog input", async () => { + render( + <> + + + +
+ +
+ , + ); + + fireEvent.focusIn(screen.getByRole("textbox", { name: "Question input" })); + expect(document.body).toHaveClass("question-keyboard-open"); + + fireEvent.focusOut(screen.getByRole("textbox", { name: "Question input" })); + fireEvent.focusIn(screen.getByRole("textbox", { name: "Dialog search" })); + + await waitFor(() => { + expect(document.body).not.toHaveClass("question-keyboard-open"); + }); + }); +}); diff --git a/src/components/Componentes/question-snap-list.tsx b/src/components/Componentes/question-snap-list.tsx index 8bc004c..a37cf3f 100644 --- a/src/components/Componentes/question-snap-list.tsx +++ b/src/components/Componentes/question-snap-list.tsx @@ -9,9 +9,10 @@ import { useRef, useState, } from "react"; -import { useI18n } from "@/translations/provider"; -import { useQuestionProgress } from "./question-progress-tracker"; -import { useQuestionInputFocusSync } from "./use-sheet-scroll-lock"; +import { + resetQuestionKeyboardState, + useQuestionInputFocusSync, +} from "./use-sheet-scroll-lock"; const WHEEL_GESTURE_IDLE_MS = 320; const TOUCH_MIN_DISTANCE = 8; @@ -21,11 +22,6 @@ const DRAG_FLICK_VELOCITY = 0.55; const SNAP_ANIMATION_MS = 340; const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; const RUBBER_BAND_RESISTANCE = 0.4; -const AUTO_FOCUS_SELECTOR = [ - "textarea:not([disabled]):not([data-no-auto-focus])", - 'input[type="text"]:not([disabled]):not([data-no-auto-focus])', - 'input[type="number"]:not([disabled]):not([data-no-auto-focus])', -].join(", "); const DRAG_IGNORE_SELECTOR = [ "input", "textarea", @@ -69,8 +65,6 @@ export function QuestionSnapList({ onActiveIndexChange, alignTop, }: QuestionSnapListProps) { - const { dictionary: t } = useI18n(); - const { isCompleted } = useQuestionProgress(); useQuestionInputFocusSync(); const questions = Children.toArray(children); const wheelLockedRef = useRef(false); @@ -113,6 +107,7 @@ export function QuestionSnapList({ return; } + resetQuestionKeyboardState(); onQuestionExit?.(activeIndex, nextIndex); onQuestionTransition?.(activeIndex, nextIndex); @@ -218,61 +213,6 @@ export function QuestionSnapList({ previousActiveIndexRef.current = activeIndex; }, [activeIndex, onQuestionTransition]); - useEffect(() => { - if (activeIndex === 0) { - return; - } - - const activeQuestion = questionRefs.current[activeIndex]; - - if (!activeQuestion) { - return; - } - - const focusFrame = window.requestAnimationFrame(() => { - if (document.querySelector('[role="dialog"]')) { - return; - } - - const firstFocusableInput = activeQuestion.querySelector< - HTMLInputElement | HTMLTextAreaElement - >(AUTO_FOCUS_SELECTOR); - - if (!firstFocusableInput) { - return; - } - - const isMobile = - /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( - navigator.userAgent, - ); - - firstFocusableInput.focus({ preventScroll: !isMobile }); - - if (isMobile) { - setTimeout(() => { - firstFocusableInput.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - }, 150); - } - - const valueLength = firstFocusableInput.value.length; - - if ( - valueLength > 0 && - typeof firstFocusableInput.setSelectionRange === "function" - ) { - firstFocusableInput.setSelectionRange(valueLength, valueLength); - } - }); - - return () => { - window.cancelAnimationFrame(focusFrame); - }; - }, [activeIndex]); - useEffect(() => { return () => { if (wheelUnlockTimeoutRef.current !== null) { @@ -538,6 +478,7 @@ export function QuestionSnapList({ } const nextIndex = index + direction; + resetQuestionKeyboardState(); snapPanelsTo(direction * drag.height); onQuestionExit?.(index, nextIndex); onQuestionTransition?.(index, nextIndex); diff --git a/src/components/Componentes/use-sheet-scroll-lock.ts b/src/components/Componentes/use-sheet-scroll-lock.ts index a06e343..5e6ed22 100644 --- a/src/components/Componentes/use-sheet-scroll-lock.ts +++ b/src/components/Componentes/use-sheet-scroll-lock.ts @@ -12,6 +12,7 @@ let initialHtmlOverflow = ""; let initialAppShellOverflow = ""; let initialAppShellTouchAction = ""; let lockedAppShell: HTMLElement | null = null; +let keyboardShiftFrame: number | null = null; const SHEET_HISTORY_KEY = "__habibQuestionSheet"; type SheetScrollLockOptions = { @@ -151,30 +152,56 @@ function syncKeyboardShift(isOpen: boolean) { 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 activateFocusedQuestionInput = (event: Event) => { - if (!isKeyboardInputTarget(event.target)) return; - const target = event.target as HTMLElement; - if (!target.closest('.question-snap-item[aria-current="step"]')) return; + const handleFocusIn = (event: FocusEvent) => { + if (!isActiveQuestionKeyboardInput(event.target)) return; hasFocusedKeyboardInput = true; syncQuestionInputOpenClass(); - window.requestAnimationFrame(() => syncKeyboardShift(true)); - }; - const handleFocusIn = (event: FocusEvent) => { - activateFocusedQuestionInput(event); - }; - const handlePointerDown = (event: PointerEvent) => { - activateFocusedQuestionInput(event); + 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 = isKeyboardInputTarget(document.activeElement); + hasFocusedKeyboardInput = isActiveQuestionKeyboardInput( + document.activeElement, + ); syncQuestionInputOpenClass(); if (!hasFocusedKeyboardInput) syncKeyboardShift(false); }, 0); @@ -204,24 +231,16 @@ export function useQuestionInputFocusSync() { document.addEventListener("focusin", handleFocusIn); document.addEventListener("focusout", handleFocusOut); - document.addEventListener("pointerdown", handlePointerDown, true); window.visualViewport?.addEventListener("resize", handleViewportResize); return () => { document.removeEventListener("focusin", handleFocusIn); document.removeEventListener("focusout", handleFocusOut); - document.removeEventListener("pointerdown", handlePointerDown, true); window.visualViewport?.removeEventListener( "resize", handleViewportResize, ); unsubscribeConfig(); - hasFocusedKeyboardInput = false; - keyboardHeight = 0; - document.documentElement.style.setProperty( - "--question-keyboard-shift", - "0px", - ); - document.body.classList.remove("question-keyboard-open"); + resetQuestionKeyboardState(); }; }, []); }