Browse Source

refactor: improve mobile keyboard interaction sync by introducing strict input validation and explicit state resets

master
mortezaei 1 week ago
parent
commit
66cecd6639
  1. 88
      src/components/Componentes/question-snap-list.test.tsx
  2. 71
      src/components/Componentes/question-snap-list.tsx
  3. 61
      src/components/Componentes/use-sheet-scroll-lock.ts

88
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(
<QuestionSnapList>
<input aria-label="Question one" type="text" />
</QuestionSnapList>,
);
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(
<QuestionSnapList>
<input aria-label="Question one" type="text" />
<input aria-label="Question two" type="text" />
</QuestionSnapList>,
);
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(
<>
<QuestionSnapList>
<input aria-label="Question input" type="text" />
</QuestionSnapList>
<div role="dialog">
<input aria-label="Dialog search" type="text" />
</div>
</>,
);
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");
});
});
});

71
src/components/Componentes/question-snap-list.tsx

@ -9,9 +9,10 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } 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 WHEEL_GESTURE_IDLE_MS = 320;
const TOUCH_MIN_DISTANCE = 8; const TOUCH_MIN_DISTANCE = 8;
@ -21,11 +22,6 @@ const DRAG_FLICK_VELOCITY = 0.55;
const SNAP_ANIMATION_MS = 340; const SNAP_ANIMATION_MS = 340;
const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const RUBBER_BAND_RESISTANCE = 0.4; 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 = [ const DRAG_IGNORE_SELECTOR = [
"input", "input",
"textarea", "textarea",
@ -69,8 +65,6 @@ export function QuestionSnapList({
onActiveIndexChange, onActiveIndexChange,
alignTop, alignTop,
}: QuestionSnapListProps) { }: QuestionSnapListProps) {
const { dictionary: t } = useI18n();
const { isCompleted } = useQuestionProgress();
useQuestionInputFocusSync(); useQuestionInputFocusSync();
const questions = Children.toArray(children); const questions = Children.toArray(children);
const wheelLockedRef = useRef(false); const wheelLockedRef = useRef(false);
@ -113,6 +107,7 @@ export function QuestionSnapList({
return; return;
} }
resetQuestionKeyboardState();
onQuestionExit?.(activeIndex, nextIndex); onQuestionExit?.(activeIndex, nextIndex);
onQuestionTransition?.(activeIndex, nextIndex); onQuestionTransition?.(activeIndex, nextIndex);
@ -218,61 +213,6 @@ export function QuestionSnapList({
previousActiveIndexRef.current = activeIndex; previousActiveIndexRef.current = activeIndex;
}, [activeIndex, onQuestionTransition]); }, [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(() => { useEffect(() => {
return () => { return () => {
if (wheelUnlockTimeoutRef.current !== null) { if (wheelUnlockTimeoutRef.current !== null) {
@ -538,6 +478,7 @@ export function QuestionSnapList({
} }
const nextIndex = index + direction; const nextIndex = index + direction;
resetQuestionKeyboardState();
snapPanelsTo(direction * drag.height); snapPanelsTo(direction * drag.height);
onQuestionExit?.(index, nextIndex); onQuestionExit?.(index, nextIndex);
onQuestionTransition?.(index, nextIndex); onQuestionTransition?.(index, nextIndex);

61
src/components/Componentes/use-sheet-scroll-lock.ts

@ -12,6 +12,7 @@ let initialHtmlOverflow = "";
let initialAppShellOverflow = ""; let initialAppShellOverflow = "";
let initialAppShellTouchAction = ""; let initialAppShellTouchAction = "";
let lockedAppShell: HTMLElement | null = null; let lockedAppShell: HTMLElement | null = null;
let keyboardShiftFrame: number | null = null;
const SHEET_HISTORY_KEY = "__habibQuestionSheet"; const SHEET_HISTORY_KEY = "__habibQuestionSheet";
type SheetScrollLockOptions = { type SheetScrollLockOptions = {
@ -151,30 +152,56 @@ function syncKeyboardShift(isOpen: boolean) {
root.style.setProperty("--question-keyboard-shift", `${requiredShift}px`); 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 * Moves the active question up (same animation the dropdown sheet uses) while
* a keyboard input is focused, so the mobile keyboard cannot cover it. * a keyboard input is focused, so the mobile keyboard cannot cover it.
*/ */
export function useQuestionInputFocusSync() { export function useQuestionInputFocusSync() {
useEffect(() => { 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; hasFocusedKeyboardInput = true;
syncQuestionInputOpenClass(); 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) => { const handleFocusOut = (event: FocusEvent) => {
if (!isKeyboardInputTarget(event.target)) return; if (!isKeyboardInputTarget(event.target)) return;
window.setTimeout(() => { window.setTimeout(() => {
hasFocusedKeyboardInput = isKeyboardInputTarget(document.activeElement);
hasFocusedKeyboardInput = isActiveQuestionKeyboardInput(
document.activeElement,
);
syncQuestionInputOpenClass(); syncQuestionInputOpenClass();
if (!hasFocusedKeyboardInput) syncKeyboardShift(false); if (!hasFocusedKeyboardInput) syncKeyboardShift(false);
}, 0); }, 0);
@ -204,24 +231,16 @@ export function useQuestionInputFocusSync() {
document.addEventListener("focusin", handleFocusIn); document.addEventListener("focusin", handleFocusIn);
document.addEventListener("focusout", handleFocusOut); document.addEventListener("focusout", handleFocusOut);
document.addEventListener("pointerdown", handlePointerDown, true);
window.visualViewport?.addEventListener("resize", handleViewportResize); window.visualViewport?.addEventListener("resize", handleViewportResize);
return () => { return () => {
document.removeEventListener("focusin", handleFocusIn); document.removeEventListener("focusin", handleFocusIn);
document.removeEventListener("focusout", handleFocusOut); document.removeEventListener("focusout", handleFocusOut);
document.removeEventListener("pointerdown", handlePointerDown, true);
window.visualViewport?.removeEventListener( window.visualViewport?.removeEventListener(
"resize", "resize",
handleViewportResize, handleViewportResize,
); );
unsubscribeConfig(); unsubscribeConfig();
hasFocusedKeyboardInput = false;
keyboardHeight = 0;
document.documentElement.style.setProperty(
"--question-keyboard-shift",
"0px",
);
document.body.classList.remove("question-keyboard-open");
resetQuestionKeyboardState();
}; };
}, []); }, []);
} }
Loading…
Cancel
Save