Browse Source
refactor: replace useQuestionInputFocusSync with question-viewport-coordinator for improved list management
master
refactor: replace useQuestionInputFocusSync with question-viewport-coordinator for improved list management
master
6 changed files with 369 additions and 192 deletions
-
33src/app/globals.css
-
19src/components/Componentes/question-sheet.tsx
-
90src/components/Componentes/question-snap-list.test.tsx
-
6src/components/Componentes/question-snap-list.tsx
-
254src/components/Componentes/question-viewport-coordinator.ts
-
155src/components/Componentes/use-sheet-scroll-lock.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(); |
||||
|
}; |
||||
|
}, []); |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue