Browse Source

refactor: replace useQuestionInputFocusSync with question-viewport-coordinator for improved list management

master
mortezaei 1 week ago
parent
commit
6c8cc01727
  1. 33
      src/app/globals.css
  2. 19
      src/components/Componentes/question-sheet.tsx
  3. 94
      src/components/Componentes/question-snap-list.test.tsx
  4. 6
      src/components/Componentes/question-snap-list.tsx
  5. 254
      src/components/Componentes/question-viewport-coordinator.ts
  6. 155
      src/components/Componentes/use-sheet-scroll-lock.ts

33
src/app/globals.css

@ -253,31 +253,14 @@ body.dropdown-open .app-shell {
margin 300ms ease-in-out; margin 300ms ease-in-out;
} }
body.question-keyboard-open .app-shell .question-snap-list {
padding-top: 0;
padding-bottom: 0;
}
body.question-keyboard-open .app-shell .question-snap-item[aria-current="step"] {
top: 0;
bottom: 0;
}
body.question-keyboard-open
.app-shell
.question-snap-item[aria-current="step"]
.question-snap-content {
/* Preserve the normal centered layout and only lift it by measured height. */
margin-top: auto;
margin-bottom: auto;
transform: translateY(calc(var(--question-keyboard-shift, 0px) * -1));
}
body.question-keyboard-open
.app-shell
.question-snap-item[aria-current="step"]
.question-snap-spacer {
display: none;
.question-snap-content {
transform: translate3d(
0,
calc(var(--question-lift-y, 0px) * -1),
0
);
transition: transform 340ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
} }
.page-background-none, .page-background-none,

19
src/components/Componentes/question-sheet.tsx

@ -8,6 +8,7 @@ import { Button } from "./button";
import { ExplanationUiFont } from "./explanation-ui-font"; import { ExplanationUiFont } from "./explanation-ui-font";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
import { registerCompactQuestionSheet } from "./question-viewport-coordinator";
import { useSheetScrollLock } from "./use-sheet-scroll-lock"; import { useSheetScrollLock } from "./use-sheet-scroll-lock";
const EXIT_ANIMATION_MS = 300; const EXIT_ANIMATION_MS = 300;
@ -38,6 +39,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const sheetRef = useRef<HTMLElement>(null);
const closeSheet = useCallback(() => { const closeSheet = useCallback(() => {
setIsClosing(true); setIsClosing(true);
window.setTimeout(() => { window.setTimeout(() => {
@ -77,6 +79,22 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
})(); })();
const isCompact = options.length <= 5; const isCompact = options.length <= 5;
useEffect(() => {
if (!isOpen || isClosing || !isCompact) return;
let unregister: (() => void) | undefined;
const frame = window.requestAnimationFrame(() => {
if (sheetRef.current) {
unregister = registerCompactQuestionSheet(sheetRef.current);
}
});
return () => {
window.cancelAnimationFrame(frame);
unregister?.();
};
}, [isCompact, isClosing, isOpen]);
const filteredOptions = options.filter((option) => const filteredOptions = options.filter((option) =>
option.label.toLowerCase().includes(searchQuery.toLowerCase()), option.label.toLowerCase().includes(searchQuery.toLowerCase()),
); );
@ -200,6 +218,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
}} }}
> >
<section <section
ref={sheetRef}
className={[ className={[
"flex w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom", "flex w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom",
isCompact ? "h-auto max-h-[82svh]" : "h-[82svh]", isCompact ? "h-auto max-h-[82svh]" : "h-[82svh]",

94
src/components/Componentes/question-snap-list.test.tsx

@ -6,6 +6,7 @@ import {
waitFor, waitFor,
} from "@testing-library/react"; } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { updateQuestionKeyboardHeight } from "./question-viewport-coordinator";
import { QuestionSnapList } from "./question-snap-list"; import { QuestionSnapList } from "./question-snap-list";
vi.mock("@/translations/provider", () => ({ vi.mock("@/translations/provider", () => ({
@ -16,7 +17,7 @@ describe("QuestionSnapList keyboard interaction", () => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
document.body.classList.remove("question-keyboard-open"); document.body.classList.remove("question-keyboard-open");
document.documentElement.style.removeProperty("--question-keyboard-shift");
document.documentElement.style.removeProperty("--question-lift-y");
}); });
it("does not treat pointer contact as input focus", () => { it("does not treat pointer contact as input focus", () => {
@ -42,10 +43,27 @@ describe("QuestionSnapList keyboard interaction", () => {
); );
const firstInput = screen.getByRole("textbox", { name: "Question one" }); const firstInput = screen.getByRole("textbox", { name: "Question one" });
const secondInput = screen.getByRole("textbox", { name: "Question two" });
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: () => ({}),
});
fireEvent.focusIn(firstInput);
expect(document.body).toHaveClass("question-keyboard-open");
firstInput.focus();
updateQuestionKeyboardHeight(300);
await waitFor(() => {
expect(document.body).toHaveClass("question-keyboard-open");
});
fireEvent.wheel(screen.getByRole("region", { name: "Questions" }), { fireEvent.wheel(screen.getByRole("region", { name: "Questions" }), {
deltaY: 100, deltaY: 100,
@ -57,7 +75,7 @@ describe("QuestionSnapList keyboard interaction", () => {
expect(secondInput).not.toHaveFocus(); expect(secondInput).not.toHaveFocus();
expect( expect(
document.documentElement.style.getPropertyValue( document.documentElement.style.getPropertyValue(
"--question-keyboard-shift",
"--question-lift-y",
), ),
).toBe("0px"); ).toBe("0px");
}); });
@ -75,14 +93,72 @@ describe("QuestionSnapList keyboard interaction", () => {
</>, </>,
); );
fireEvent.focusIn(screen.getByRole("textbox", { name: "Question input" }));
expect(document.body).toHaveClass("question-keyboard-open");
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: () => ({}),
});
fireEvent.focusOut(screen.getByRole("textbox", { name: "Question input" }));
fireEvent.focusIn(screen.getByRole("textbox", { name: "Dialog search" }));
input.focus();
updateQuestionKeyboardHeight(300);
await waitFor(() => expect(document.body).toHaveClass("question-keyboard-open"));
updateQuestionKeyboardHeight(0);
await waitFor(() => { await waitFor(() => {
expect(document.body).not.toHaveClass("question-keyboard-open"); 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();
}); });
}); });
}); });

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

@ -11,8 +11,8 @@ import {
} from "react"; } from "react";
import { import {
resetQuestionKeyboardState, resetQuestionKeyboardState,
useQuestionInputFocusSync,
} from "./use-sheet-scroll-lock";
useQuestionViewportCoordinator,
} from "./question-viewport-coordinator";
const WHEEL_GESTURE_IDLE_MS = 320; const WHEEL_GESTURE_IDLE_MS = 320;
const TOUCH_MIN_DISTANCE = 8; const TOUCH_MIN_DISTANCE = 8;
@ -65,7 +65,7 @@ export function QuestionSnapList({
onActiveIndexChange, onActiveIndexChange,
alignTop, alignTop,
}: QuestionSnapListProps) { }: QuestionSnapListProps) {
useQuestionInputFocusSync();
useQuestionViewportCoordinator();
const questions = Children.toArray(children); const questions = Children.toArray(children);
const wheelLockedRef = useRef(false); const wheelLockedRef = useRef(false);
const wheelUnlockTimeoutRef = useRef<number | null>(null); const wheelUnlockTimeoutRef = useRef<number | null>(null);

254
src/components/Componentes/question-viewport-coordinator.ts

@ -0,0 +1,254 @@
"use client";
import { useEffect } from "react";
import { viewPaddingsBridge } from "@/lib/view-paddings";
const KEYBOARD_THRESHOLD = 80;
const QUESTION_GAP = 16;
const LIFT_VARIABLE = "--question-lift-y";
let activeQuestionInput: HTMLElement | null = null;
let keyboardVisible = false;
let keyboardHeight = 0;
let lastKeyboardHeight = 0;
let compactSheetTop: number | null = null;
let currentLift = 0;
let geometryFrame: number | null = null;
let closedViewportHeight = 0;
const NON_KEYBOARD_INPUT_TYPES = new Set([
"button",
"checkbox",
"color",
"file",
"hidden",
"image",
"radio",
"range",
"reset",
"submit",
]);
export type QuestionLiftGeometry = {
baseTop: number;
baseBottom: number;
visibleTop: number;
visibleBottom: number;
gap?: number;
};
export function computeQuestionLift({
baseTop,
baseBottom,
visibleTop,
visibleBottom,
gap = QUESTION_GAP,
}: QuestionLiftGeometry) {
const contentHeight = baseBottom - baseTop;
const availableTop = visibleTop + gap;
const availableBottom = visibleBottom - gap;
const availableCenter = (availableTop + availableBottom) / 2;
const contentCenter = baseTop + contentHeight / 2;
const centerShift = Math.max(0, contentCenter - availableCenter);
const overlapShift = Math.max(0, baseBottom - availableBottom);
const maxShift = Math.max(0, baseTop - availableTop);
return Math.min(Math.max(centerShift, overlapShift), maxShift);
}
function isKeyboardInputTarget(target: EventTarget | null): target is HTMLElement {
if (target instanceof HTMLTextAreaElement) {
return !target.disabled && !target.readOnly;
}
if (target instanceof HTMLInputElement) {
const type = (target.getAttribute("type") ?? "text").toLowerCase();
return (
!NON_KEYBOARD_INPUT_TYPES.has(type) &&
!target.disabled &&
!target.readOnly
);
}
return target instanceof HTMLElement && target.isContentEditable;
}
export function isActiveQuestionKeyboardInput(
target: EventTarget | null,
): target is HTMLElement {
return (
isKeyboardInputTarget(target) &&
target.closest('.question-snap-item[aria-current="step"]') !== null
);
}
function setLift(nextLift: number) {
currentLift = Math.max(0, nextLift);
document.documentElement.style.setProperty(
LIFT_VARIABLE,
`${currentLift}px`,
);
document.body.classList.toggle(
"question-keyboard-open",
currentLift > 0 && keyboardVisible && activeQuestionInput !== null,
);
}
function getKeyboardTop() {
if (!keyboardVisible || !activeQuestionInput) return null;
const flutterTop = window.innerHeight - keyboardHeight;
const viewport = window.visualViewport;
const viewportBottom = viewport
? viewport.offsetTop + viewport.height
: window.innerHeight;
return Math.min(flutterTop, viewportBottom);
}
function updateLift() {
const content = document.querySelector<HTMLElement>(
'.question-snap-item[aria-current="step"] .question-snap-content',
);
if (!content) {
setLift(0);
return;
}
const keyboardTop = getKeyboardTop();
const visibleBottomCandidates = [keyboardTop, compactSheetTop].filter(
(value): value is number => value !== null,
);
if (visibleBottomCandidates.length === 0) {
setLift(0);
return;
}
const rect = content.getBoundingClientRect();
const snapList = content.closest<HTMLElement>(".question-snap-list");
const baseTop = rect.top + currentLift;
const baseBottom = rect.bottom + currentLift;
const safeTop = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--safe-top"),
);
setLift(
computeQuestionLift({
baseTop,
baseBottom,
visibleTop: Math.max(
Number.isFinite(safeTop) ? safeTop : 0,
snapList?.getBoundingClientRect().top ?? 0,
),
visibleBottom: Math.min(...visibleBottomCandidates),
}),
);
}
function scheduleLiftUpdate() {
if (geometryFrame !== null) window.cancelAnimationFrame(geometryFrame);
geometryFrame = window.requestAnimationFrame(() => {
geometryFrame = null;
updateLift();
});
}
export function updateQuestionKeyboardHeight(height: number) {
keyboardHeight = Math.max(0, height);
keyboardVisible = keyboardHeight > KEYBOARD_THRESHOLD;
if (keyboardVisible) {
lastKeyboardHeight = keyboardHeight;
if (isActiveQuestionKeyboardInput(document.activeElement)) {
activeQuestionInput = document.activeElement;
}
}
scheduleLiftUpdate();
}
export function registerCompactQuestionSheet(element: HTMLElement) {
compactSheetTop = element.getBoundingClientRect().top;
scheduleLiftUpdate();
return () => {
compactSheetTop = null;
scheduleLiftUpdate();
};
}
export function resetQuestionKeyboardState() {
if (geometryFrame !== null) {
window.cancelAnimationFrame(geometryFrame);
geometryFrame = null;
}
if (isActiveQuestionKeyboardInput(document.activeElement)) {
document.activeElement.blur();
}
activeQuestionInput = null;
keyboardVisible = false;
keyboardHeight = 0;
compactSheetTop = null;
setLift(0);
}
export function useQuestionViewportCoordinator() {
useEffect(() => {
closedViewportHeight = window.visualViewport?.height ?? window.innerHeight;
const handleFocusIn = (event: FocusEvent) => {
if (!isActiveQuestionKeyboardInput(event.target)) return;
activeQuestionInput = event.target;
// Start with the last known keyboard geometry so lifting begins in the
// same frame as focus; Flutter then replaces it with the exact height.
if (!keyboardVisible && lastKeyboardHeight > KEYBOARD_THRESHOLD) {
keyboardHeight = lastKeyboardHeight;
keyboardVisible = true;
}
scheduleLiftUpdate();
};
const handleFocusOut = () => {
window.setTimeout(() => {
activeQuestionInput = isActiveQuestionKeyboardInput(
document.activeElement,
)
? document.activeElement
: null;
if (!activeQuestionInput) scheduleLiftUpdate();
}, 0);
};
const unsubscribeConfig = viewPaddingsBridge.subscribeConfig((config) => {
updateQuestionKeyboardHeight(config.keyboardHeight);
});
const handleViewportResize = () => {
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
const reduction = closedViewportHeight - viewportHeight;
if (reduction > KEYBOARD_THRESHOLD) {
if (isActiveQuestionKeyboardInput(document.activeElement)) {
activeQuestionInput = document.activeElement;
keyboardVisible = true;
keyboardHeight = Math.max(keyboardHeight, reduction);
lastKeyboardHeight = keyboardHeight;
scheduleLiftUpdate();
}
} else if (keyboardHeight <= KEYBOARD_THRESHOLD) {
keyboardVisible = false;
keyboardHeight = 0;
closedViewportHeight = viewportHeight;
scheduleLiftUpdate();
}
};
document.addEventListener("focusin", handleFocusIn);
document.addEventListener("focusout", handleFocusOut);
window.visualViewport?.addEventListener("resize", handleViewportResize);
return () => {
document.removeEventListener("focusin", handleFocusIn);
document.removeEventListener("focusout", handleFocusOut);
window.visualViewport?.removeEventListener("resize", handleViewportResize);
unsubscribeConfig();
resetQuestionKeyboardState();
};
}, []);
}

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

@ -1,18 +1,14 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { viewPaddingsBridge } from "@/lib/view-paddings";
let activeSheetCount = 0; let activeSheetCount = 0;
let hasFocusedKeyboardInput = false;
let keyboardHeight = 0;
let bodyHadDropdownClass = false; let bodyHadDropdownClass = false;
let initialBodyOverflow = ""; let initialBodyOverflow = "";
let initialHtmlOverflow = ""; 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 = {
@ -93,154 +89,3 @@ export function useSheetScrollLock(
}; };
}, [isOpen]); }, [isOpen]);
} }
const NON_KEYBOARD_INPUT_TYPES = new Set([
"button",
"checkbox",
"color",
"file",
"hidden",
"image",
"radio",
"range",
"reset",
"submit",
]);
function isKeyboardInputTarget(target: EventTarget | null): boolean {
if (target instanceof HTMLTextAreaElement) {
return !target.disabled && !target.readOnly;
}
if (target instanceof HTMLInputElement) {
const type = (target.getAttribute("type") ?? "text").toLowerCase();
return (
!NON_KEYBOARD_INPUT_TYPES.has(type) &&
!target.disabled &&
!target.readOnly
);
}
if (target instanceof HTMLElement) {
return target.isContentEditable;
}
return false;
}
function syncQuestionInputOpenClass() {
if (hasFocusedKeyboardInput) {
document.body.classList.add("question-keyboard-open");
} else {
document.body.classList.remove("question-keyboard-open");
}
}
function syncKeyboardShift(isOpen: boolean) {
const root = document.documentElement;
if (!isOpen) {
root.style.setProperty("--question-keyboard-shift", "0px");
return;
}
const activeContent = document.querySelector<HTMLElement>(
'.question-snap-item[aria-current="step"] .question-snap-content',
);
const snapList = document.querySelector<HTMLElement>(".question-snap-list");
if (!activeContent || !snapList) return;
const contentTop = activeContent.getBoundingClientRect().top;
const targetTop = snapList.getBoundingClientRect().top + 8;
const requiredShift = Math.max(0, contentTop - targetTop);
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 handleFocusIn = (event: FocusEvent) => {
if (!isActiveQuestionKeyboardInput(event.target)) return;
hasFocusedKeyboardInput = true;
syncQuestionInputOpenClass();
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 = isActiveQuestionKeyboardInput(
document.activeElement,
);
syncQuestionInputOpenClass();
if (!hasFocusedKeyboardInput) syncKeyboardShift(false);
}, 0);
};
const unsubscribeConfig = viewPaddingsBridge.subscribeConfig((config) => {
keyboardHeight = config.keyboardHeight;
if (keyboardHeight === 0) {
// Android Back can hide Flutter's keyboard without dispatching blur.
hasFocusedKeyboardInput = false;
}
syncQuestionInputOpenClass();
if (keyboardHeight === 0) syncKeyboardShift(false);
});
const initialViewportHeight = window.visualViewport?.height ?? 0;
const handleViewportResize = () => {
const viewportHeight = window.visualViewport?.height ?? 0;
if (
initialViewportHeight > 0 &&
viewportHeight >= initialViewportHeight - 40
) {
hasFocusedKeyboardInput = false;
syncQuestionInputOpenClass();
}
};
document.addEventListener("focusin", handleFocusIn);
document.addEventListener("focusout", handleFocusOut);
window.visualViewport?.addEventListener("resize", handleViewportResize);
return () => {
document.removeEventListener("focusin", handleFocusIn);
document.removeEventListener("focusout", handleFocusOut);
window.visualViewport?.removeEventListener(
"resize",
handleViewportResize,
);
unsubscribeConfig();
resetQuestionKeyboardState();
};
}, []);
}
Loading…
Cancel
Save