You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

615 lines
19 KiB

import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QuestionSnapList } from "./question-snap-list";
import {
computeQuestionLift,
updateQuestionKeyboardHeight,
} from "./question-viewport-coordinator";
vi.mock("@/translations/provider", () => ({
useI18n: () => ({ dictionary: {} }),
}));
describe("QuestionSnapList keyboard interaction", () => {
afterEach(() => {
cleanup();
document.body.classList.remove("question-keyboard-open");
document.documentElement.style.removeProperty("--question-lift-y");
});
it("computes a stable occlusion lift without overshoot", () => {
expect(
computeQuestionLift({
baseTop: 300,
baseBottom: 600,
visibleTop: 100,
visibleBottom: 500,
}),
).toBe(150);
expect(
computeQuestionLift({
baseTop: 300,
baseBottom: 600,
visibleTop: 100,
visibleBottom: 800,
}),
).toBe(0);
});
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 = document.querySelector<HTMLInputElement>(
'input[aria-label="Question two"]',
);
const content = firstInput.closest<HTMLElement>(".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: () => ({}),
});
firstInput.focus();
updateQuestionKeyboardHeight(300);
await waitFor(() => {
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-lift-y"),
).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>
</>,
);
const questionInput = screen.getByRole("textbox", {
name: "Question input",
});
const content = questionInput.closest<HTMLElement>(
".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(
<QuestionSnapList>
<input aria-label="Persistent input" type="text" />
</QuestionSnapList>,
);
const input = screen.getByRole("textbox", { name: "Persistent input" });
const content = input.closest<HTMLElement>(".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: () => ({}),
});
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();
});
});
describe("touch isolation and first-tap focus reliability", () => {
it("does not step question or blur active input when touch has jitter on input", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<label htmlFor="name-input">Full Name</label>
<input id="name-input" aria-label="Full Name" type="text" />
</div>
<div>
<input aria-label="Second Question" type="text" />
</div>
</QuestionSnapList>,
);
const input = screen.getByRole("textbox", { name: "Full Name" });
input.focus();
expect(input).toHaveFocus();
// Finger touches input with 12px jitter/drift (typical coarse touch contact area shift)
fireEvent.touchStart(input, {
touches: [{ clientY: 300 }],
target: input,
});
fireEvent.touchMove(input, {
touches: [{ clientY: 288 }],
target: input,
});
fireEvent.touchEnd(input, {
changedTouches: [{ clientY: 288 }],
target: input,
});
// Input should remain focused, and active question must remain index 0
expect(input).toHaveFocus();
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
it("does not engage drag or blur input when touched while snap animation is settling", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<textarea aria-label="Bio description" />
</div>
<div>
<textarea aria-label="Second Bio" />
</div>
</QuestionSnapList>,
);
const textarea = screen.getByRole("textbox", {
name: "Bio description",
});
textarea.focus();
// Simulate touching during animation or right as question is rendered
fireEvent.touchStart(textarea, {
touches: [{ clientY: 400 }],
target: textarea,
});
fireEvent.touchEnd(textarea, {
changedTouches: [{ clientY: 390 }],
target: textarea,
});
expect(textarea).toHaveFocus();
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
it("does not step question on option button/label with micro-jitter (clean tap)", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<button type="button">Select Option</button>
</div>
<div>
<button type="button">Next Step</button>
</div>
</QuestionSnapList>,
);
const button = screen.getByRole("button", { name: "Select Option" });
// Small jitter (delta 8px < INTERACTIVE_DRAG_SLOP 16px)
fireEvent.touchStart(button, {
touches: [{ clientX: 100, clientY: 250 }],
target: button,
});
fireEvent.touchMove(button, {
touches: [{ clientX: 100, clientY: 242 }],
target: button,
});
fireEvent.touchEnd(button, {
changedTouches: [{ clientX: 100, clientY: 242 }],
target: button,
});
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
it("allows intentional vertical drag starting on a label/button to swipe questions (soft ownership)", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<label htmlFor="opt-1">Option 1</label>
<input id="opt-1" type="radio" name="opt" />
</div>
<div>
<label htmlFor="opt-2">Option 2</label>
<input id="opt-2" type="radio" name="opt" />
</div>
</QuestionSnapList>,
);
const label = screen.getByText("Option 1");
// Intentional drag (startY: 300, endY: 200 -> deltaY = 100 > slop)
fireEvent.touchStart(label, {
touches: [{ clientX: 100, clientY: 300 }],
target: label,
});
fireEvent.touchMove(label, {
touches: [{ clientX: 100, clientY: 200 }],
target: label,
});
fireEvent.touchEnd(label, {
changedTouches: [{ clientX: 100, clientY: 200 }],
target: label,
});
expect(onActiveIndexChange).toHaveBeenCalledWith(1);
});
it("suppresses trailing click on option after vertical drag swipe", () => {
const onClick = vi.fn();
render(
<QuestionSnapList>
<div>
<button type="button" onClick={onClick}>
Option Button
</button>
</div>
<div>Slide 2</div>
</QuestionSnapList>,
);
const button = screen.getByRole("button", { name: "Option Button" });
const region = screen.getByRole("region", { name: "Questions" });
// Swipe drag starting on button
fireEvent.touchStart(button, {
touches: [{ clientX: 100, clientY: 300 }],
target: button,
});
fireEvent.touchMove(button, {
touches: [{ clientX: 100, clientY: 200 }],
target: button,
});
fireEvent.touchEnd(button, {
changedTouches: [{ clientX: 100, clientY: 200 }],
target: button,
});
// Synthetic click dispatched after drag
fireEvent.click(button);
expect(onClick).not.toHaveBeenCalled();
});
it("allows intentional vertical swipe on non-interactive question background", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div data-testid="slide-1">Slide 1</div>
<div data-testid="slide-2">Slide 2</div>
</QuestionSnapList>,
);
const region = screen.getByRole("region", { name: "Questions" });
// Fast upward flick on background area
fireEvent.touchStart(region, {
touches: [{ clientX: 100, clientY: 400 }],
target: region,
});
fireEvent.touchEnd(region, {
changedTouches: [{ clientX: 100, clientY: 320 }],
target: region,
});
expect(onActiveIndexChange).toHaveBeenCalledWith(1);
});
it("does not swipe or blur when touching and dragging inside a text input", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<input aria-label="First Name" type="text" />
</div>
<div>
<input aria-label="Last Name" type="text" />
</div>
</QuestionSnapList>,
);
const input = screen.getByRole("textbox", { name: "First Name" });
input.focus();
// Large drag on input (e.g. user selecting text or scrolling within text input)
fireEvent.touchStart(input, {
touches: [{ clientX: 100, clientY: 400 }],
target: input,
});
fireEvent.touchMove(input, {
touches: [{ clientX: 100, clientY: 200 }],
target: input,
});
fireEvent.touchEnd(input, {
changedTouches: [{ clientX: 100, clientY: 200 }],
target: input,
});
expect(input).toHaveFocus();
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
it("does not swipe on hard-ignored controls like range sliders", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<input type="range" aria-label="Volume slider" />
</div>
<div>Slide 2</div>
</QuestionSnapList>,
);
const slider = screen.getByRole("slider", { name: "Volume slider" });
fireEvent.touchStart(slider, {
touches: [{ clientX: 100, clientY: 300 }],
target: slider,
});
fireEvent.touchMove(slider, {
touches: [{ clientX: 100, clientY: 200 }],
target: slider,
});
fireEvent.touchEnd(slider, {
changedTouches: [{ clientX: 100, clientY: 200 }],
target: slider,
});
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
});
describe("Periodic scroll hint idle cycle", () => {
it("follows 2s idle -> 2s visible -> 2s hidden -> repeat cycle and resets on user interaction", () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const { container } = render(
<QuestionSnapList
firstQuestionHint={<span data-testid="scroll-hint">Scroll icon</span>}
>
<div>Question 1</div>
<div>Question 2</div>
</QuestionSnapList>,
);
const hintWrapper = container.querySelector(".motion-safe\\:animate-bounce");
expect(hintWrapper).not.toBeNull();
// Initially hidden (waiting 2s)
expect(hintWrapper).toHaveClass("opacity-0");
expect(hintWrapper).not.toHaveClass("opacity-100");
// Advance 1s: still hidden
act(() => {
vi.advanceTimersByTime(1000);
});
expect(hintWrapper).toHaveClass("opacity-0");
// Advance another 1s (total 2s idle): now visible
act(() => {
vi.advanceTimersByTime(1000);
});
expect(hintWrapper).toHaveClass("opacity-100");
// Advance 2s while visible (total 4s): becomes hidden
act(() => {
vi.advanceTimersByTime(2000);
});
expect(hintWrapper).toHaveClass("opacity-0");
// Advance 2s while hidden (total 6s): becomes visible again (cycle repeat)
act(() => {
vi.advanceTimersByTime(2000);
});
expect(hintWrapper).toHaveClass("opacity-100");
// User interacts (touchstart): immediately hides and restarts 2s idle timer
const region = screen.getByRole("region", { name: "Questions" });
act(() => {
fireEvent.touchStart(region);
});
expect(hintWrapper).toHaveClass("opacity-0");
// Advance 1.5s after touch: still hidden
act(() => {
vi.advanceTimersByTime(1500);
});
expect(hintWrapper).toHaveClass("opacity-0");
// Advance another 500ms (total 2s after touch): becomes visible again
act(() => {
vi.advanceTimersByTime(500);
});
expect(hintWrapper).toHaveClass("opacity-100");
vi.useRealTimers();
});
});
describe("Overflowing inline-options vs non-overflowing/sheet questions", () => {
it("allows inner scrolling for overflowing radio questions before snapping to next question", () => {
const onActiveIndexChange = vi.fn();
const { container } = render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<label>
<input type="radio" name="opt" value="1" /> Option 1
</label>
<label>
<input type="radio" name="opt" value="2" /> Option 2
</label>
<label>
<input type="radio" name="opt" value="3" /> Option 3
</label>
<label>
<input type="radio" name="opt" value="4" /> Option 4
</label>
</div>
<div>Question 2</div>
</QuestionSnapList>,
);
const contentEl = container.querySelector<HTMLElement>(".question-snap-content");
expect(contentEl).not.toBeNull();
if (contentEl) {
// Mock overflowing height: scrollHeight 600px > clientHeight 400px (maxScroll = 200px)
Object.defineProperty(contentEl, "scrollHeight", { value: 600, configurable: true });
Object.defineProperty(contentEl, "clientHeight", { value: 400, configurable: true });
contentEl.scrollTop = 0;
}
const region = screen.getByRole("region", { name: "Questions" });
// Drag up while at top (scrollTop = 0 < maxScroll = 200): should NOT flip question
fireEvent.touchStart(region, {
touches: [{ clientX: 100, clientY: 300 }],
});
fireEvent.touchMove(region, {
touches: [{ clientX: 100, clientY: 200 }],
});
fireEvent.touchEnd(region, {
changedTouches: [{ clientX: 100, clientY: 200 }],
});
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
// Now simulate user having scrolled to bottom of options (scrollTop = 200)
if (contentEl) {
contentEl.scrollTop = 200;
}
// Drag up at the bottom: now it SHOULD snap to next question
fireEvent.touchStart(region, {
touches: [{ clientX: 100, clientY: 300 }],
});
fireEvent.touchMove(region, {
touches: [{ clientX: 100, clientY: 150 }],
});
fireEvent.touchEnd(region, {
changedTouches: [{ clientX: 100, clientY: 150 }],
});
expect(onActiveIndexChange).toHaveBeenCalledWith(1);
});
it("immediately snaps for non-overflowing questions (e.g. dropdown or short question)", () => {
const onActiveIndexChange = vi.fn();
const { container } = render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<button>Dropdown Trigger</button>
</div>
<div>Question 2</div>
</QuestionSnapList>,
);
const contentEl = container.querySelector<HTMLElement>(".question-snap-content");
if (contentEl) {
// Fits in screen: scrollHeight 300 <= clientHeight 400
Object.defineProperty(contentEl, "scrollHeight", { value: 300, configurable: true });
Object.defineProperty(contentEl, "clientHeight", { value: 400, configurable: true });
}
const region = screen.getByRole("region", { name: "Questions" });
fireEvent.touchStart(region, {
touches: [{ clientX: 100, clientY: 300 }],
});
fireEvent.touchMove(region, {
touches: [{ clientX: 100, clientY: 150 }],
});
fireEvent.touchEnd(region, {
changedTouches: [{ clientX: 100, clientY: 150 }],
});
expect(onActiveIndexChange).toHaveBeenCalledWith(1);
});
});
});