Files
ghatreh_user/src/utils/mobileInputKeyboardScroll.ts
T

177 lines
5.1 KiB
TypeScript
Raw Normal View History

const MOBILE_MEDIA = '(max-width: 767.98px)'
const KEYBOARD_GAP_PX = 16
const SCROLL_RETRY_MS = [0, 90, 220, 420, 640]
let started = false
let activeField: HTMLElement | null = null
let blurTimer = 0
function isMobileLayout(): boolean {
return window.matchMedia(MOBILE_MEDIA).matches
}
function getKeyboardInset(): number {
const vv = window.visualViewport
if (!vv) return 0
return Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop))
}
function getVisibleViewportBottom(): number {
const vv = window.visualViewport
return vv ? vv.offsetTop + vv.height : window.innerHeight
}
function syncViewportVars(hasFocusedField: boolean) {
const vv = window.visualViewport
const inset = getKeyboardInset()
document.documentElement.style.setProperty('--app-keyboard-inset', `${inset}px`)
if (vv) {
document.documentElement.style.setProperty('--app-visual-viewport-height', `${Math.round(vv.height)}px`)
document.documentElement.style.setProperty('--app-visual-viewport-offset-top', `${Math.round(vv.offsetTop)}px`)
}
if (hasFocusedField && inset > 0) {
document.documentElement.dataset.keyboardOpen = 'true'
return
}
if (!hasFocusedField) {
delete document.documentElement.dataset.keyboardOpen
document.documentElement.style.setProperty('--app-keyboard-inset', '0px')
}
}
function resolveEditableTarget(node: EventTarget | null): HTMLElement | null {
if (!(node instanceof HTMLElement)) return null
const field = node.closest('input, textarea, select, [contenteditable="true"]')
if (!(field instanceof HTMLElement)) return null
if (field instanceof HTMLInputElement) {
if (field.disabled || field.readOnly || field.type === 'hidden') return null
}
if (field instanceof HTMLTextAreaElement) {
if (field.disabled || field.readOnly) return null
}
if (field instanceof HTMLSelectElement && field.disabled) return null
return field
}
function findScrollableAncestor(el: HTMLElement): HTMLElement | null {
let node: HTMLElement | null = el.parentElement
while (node && node !== document.body && node !== document.documentElement) {
const style = window.getComputedStyle(node)
const overflowY = style.overflowY
const scrollable = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'
if (scrollable && node.scrollHeight > node.clientHeight + 1) {
return node
}
node = node.parentElement
}
return null
}
function ensureFieldVisible(field: HTMLElement) {
if (!isMobileLayout()) return
syncViewportVars(true)
const rect = field.getBoundingClientRect()
const visibleBottom = getVisibleViewportBottom() - KEYBOARD_GAP_PX
const scrollParent = findScrollableAncestor(field)
if (rect.bottom > visibleBottom) {
const delta = rect.bottom - visibleBottom
if (scrollParent) {
scrollParent.scrollTop += delta
} else {
window.scrollBy({top: delta, left: 0, behavior: 'auto'})
}
}
if (rect.top < KEYBOARD_GAP_PX && scrollParent) {
scrollParent.scrollTop += rect.top - KEYBOARD_GAP_PX
}
field.scrollIntoView({block: 'nearest', inline: 'nearest', behavior: 'auto'})
}
function scheduleEnsureVisible(field: HTMLElement) {
SCROLL_RETRY_MS.forEach((delay) => {
window.setTimeout(() => {
const active = resolveEditableTarget(document.activeElement)
if (!active || active !== field) return
ensureFieldVisible(field)
}, delay)
})
}
function onFocusIn(event: FocusEvent) {
if (!isMobileLayout()) return
const field = resolveEditableTarget(event.target)
if (!field) return
activeField = field
window.clearTimeout(blurTimer)
scheduleEnsureVisible(field)
}
function onFocusOut() {
window.clearTimeout(blurTimer)
blurTimer = window.setTimeout(() => {
const next = resolveEditableTarget(document.activeElement)
activeField = next
if (!next) {
syncViewportVars(false)
return
}
syncViewportVars(true)
scheduleEnsureVisible(next)
}, 120)
}
function onViewportChange() {
if (!isMobileLayout()) return
syncViewportVars(Boolean(activeField))
const active = resolveEditableTarget(document.activeElement) ?? activeField
if (!active) return
activeField = active
ensureFieldVisible(active)
}
/** موبایل: با باز شدن کیبورد، فیلد فوکوس‌شده بالای کیبورد دیده شود. */
export function initMobileInputKeyboardScroll() {
if (typeof window === 'undefined' || started) return
started = true
document.addEventListener('focusin', onFocusIn, true)
document.addEventListener('focusout', onFocusOut, true)
window.visualViewport?.addEventListener('resize', onViewportChange, {passive: true})
window.visualViewport?.addEventListener('scroll', onViewportChange, {passive: true})
if (import.meta.hot) {
import.meta.hot.dispose(() => {
started = false
document.removeEventListener('focusin', onFocusIn, true)
document.removeEventListener('focusout', onFocusOut, true)
window.visualViewport?.removeEventListener('resize', onViewportChange)
window.visualViewport?.removeEventListener('scroll', onViewportChange)
})
}
}