feat: mobile keyboard auto-scroll for input fields

- initMobileInputKeyboardScroll utility using visualViewport API
- mobile-keyboard-scroll.less for scroll-margin and padding adjustments

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-19 11:52:00 +03:30
co-authored by Cursor
parent e578ba521d
commit 47ac13b114
4 changed files with 230 additions and 0 deletions
+2
View File
@@ -7,6 +7,7 @@ import { setupRouterGuards } from '@/app/setupRouter'
import { useSettingsStore } from '@/store/settingsStore'
import { initPriceRealtimeChannel } from '@/utils/priceSocket'
import { initMobileScrollRestoration } from '@/layouts/composables/useMobileScrollReset'
import { initMobileInputKeyboardScroll } from '@/utils/mobileInputKeyboardScroll'
type BootWindow = Window & {
__BOOT_TIMEOUT__?: number
@@ -76,6 +77,7 @@ export async function bootstrapApp() {
initPriceRealtimeChannel((key) => settingsStore.getSettingByKey(key))
initMobileScrollRestoration()
initMobileInputKeyboardScroll()
setupRouterGuards()
app.mount('#app')
+1
View File
@@ -13,6 +13,7 @@
@import (less) '../../views/base/gold/desktop/styles/desktop-gold.less';
@import (less) '../../views/login/desktop/styles/desktop-login.less';
@import (less) '../../layouts/desktop/styles/desktop-inner-pages.less';
@import (less) './mobile-keyboard-scroll.less';
html {
scroll-behavior: smooth;
@@ -0,0 +1,51 @@
:root {
--app-keyboard-inset: 0px;
--app-visual-viewport-height: 100dvh;
--app-visual-viewport-offset-top: 0px;
}
@media screen and (max-width: 767.98px) {
input,
textarea,
select,
.ant-input,
.ant-input-affix-wrapper,
.ant-select-selection-search-input {
scroll-margin-bottom: calc(var(--app-keyboard-inset) + 16px);
}
html[data-keyboard-open='true'] {
.main-with-bottom-chrome,
.login-vip-page__body,
.login-page,
.login-page-root,
.ant-modal-body,
.ant-drawer-body,
.van-action-sheet__content,
.basic-action-sheet__body {
scroll-padding-bottom: calc(var(--app-keyboard-inset) + 16px);
}
.main-with-bottom-chrome,
.login-vip-page__body,
.login-page {
padding-bottom: var(--app-keyboard-inset);
}
.login-mobile {
height: var(--app-visual-viewport-height, 100dvh);
max-height: var(--app-visual-viewport-height, 100dvh);
}
.login-page.flex {
height: var(--app-visual-viewport-height, 92vh) !important;
max-height: var(--app-visual-viewport-height, 92vh) !important;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
align-items: flex-start !important;
justify-content: flex-start !important;
padding-top: 0.75rem;
}
}
}
+176
View File
@@ -0,0 +1,176 @@
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)
})
}
}