177 lines
5.1 KiB
TypeScript
177 lines
5.1 KiB
TypeScript
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)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|