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
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);
|
|
});
|
|
});
|
|
});
|