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();
};
}, []);
}