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:
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user