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
-
94src/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