fix(кабинет/Android): выпадающие списки не закрываются от клавиатуры
Some checks failed
Auto-tag / Create date-tag (push) Has been cancelled
CI / Backend (.NET 8) (push) Has been cancelled
CI / Web (React + Vite) (push) Has been cancelled
CI / POS (WPF, Windows) (push) Has been cancelled
Docker Web / Build + push Web (push) Has been cancelled
Docker Web / Deploy Web on stage (push) Has been cancelled
Some checks failed
Auto-tag / Create date-tag (push) Has been cancelled
CI / Backend (.NET 8) (push) Has been cancelled
CI / Web (React + Vite) (push) Has been cancelled
CI / POS (WPF, Windows) (push) Has been cancelled
Docker Web / Build + push Web (push) Has been cancelled
Docker Web / Deploy Web on stage (push) Has been cancelled
На Android Select в кабинете («Тип счёта», «Счёт для наличных», «Магазин»…) не открывались: автофокус в «Поиск…» выдвигал экранную клавиатуру → Chrome слал window resize (только высота) и scroll к полю → слушатель `() => setOpen(false)` тут же закрывал список. Общий хук useDropdownViewport (для обоих Select и AsyncSelect): - resize закрывает только при смене ШИРИНЫ (поворот); изменение высоты (клавиатура) — лишь пересчёт позиции, список остаётся открыт; - scroll внутри самого списка игнорируется; внешний скролл пересчитывает позицию и закрывает, только если триггер ушёл за экран; - visualViewport resize/scroll (клавиатура) — пересчёт позиции. Автофокус в поле поиска: на сенсорных устройствах (pointer: coarse) не ставим при коротком списке (≤8) — клавиатура мешала бы; при длинном поиск нужен. На десктопе поведение прежнее (фокус, Esc и клик-вне закрывают). Проверено в WebKit и Chromium (моб. 360×700 с эмуляцией клавиатуры через изменение высоты вьюпорта, и десктоп): открытие по тапу, устойчивость к клавиатуре, закрытие при повороте/уходе триггера за экран, Esc, клик вне — 19/19. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
022e09ef7d
commit
8e719aeeb3
|
|
@ -10,6 +10,67 @@ import { api } from '@/lib/api'
|
|||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||
import { localizeNativeValidation } from '@/lib/validation'
|
||||
|
||||
/** Сенсорный ввод (телефон/планшет): на таких устройствах фокус в поле поиска
|
||||
* выдвигает экранную клавиатуру. */
|
||||
function isCoarsePointer(): boolean {
|
||||
try { return window.matchMedia?.('(pointer: coarse)')?.matches ?? false } catch { return false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Пока выпадающий список открыт — держим его на месте при скролле/resize, но
|
||||
* НЕ закрываем из-за экранной клавиатуры.
|
||||
*
|
||||
* Android-баг: автофокус в «Поиск…» выдвигает клавиатуру → Chrome шлёт resize
|
||||
* (меняется только высота) и scroll к сфокусированному полю → список тут же
|
||||
* закрывался. Теперь:
|
||||
* - resize закрывает только при смене ШИРИНЫ (поворот экрана); иначе —
|
||||
* пересчёт позиции (клавиатура меняет лишь высоту);
|
||||
* - scroll внутри самого списка игнорируем; внешний скролл пересчитывает
|
||||
* позицию и закрывает, только если триггер ушёл за пределы экрана;
|
||||
* - visualViewport (клавиатура) — просто пересчёт позиции.
|
||||
*/
|
||||
function useDropdownViewport(
|
||||
open: boolean,
|
||||
wrapRef: React.RefObject<HTMLElement | null>,
|
||||
dropdownRef: React.RefObject<HTMLElement | null>,
|
||||
recomputePos: () => void,
|
||||
close: () => void,
|
||||
) {
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
recomputePos()
|
||||
const initialWidth = window.innerWidth
|
||||
const onResize = () => {
|
||||
if (window.innerWidth !== initialWidth) { close(); return } // поворот/смена ширины
|
||||
recomputePos() // только высота (клавиатура) — держим открытым
|
||||
}
|
||||
const onScroll = (e: Event) => {
|
||||
const t = e.target as Node | null
|
||||
// Скролл ВНУТРИ списка — не трогаем.
|
||||
if (t && dropdownRef.current && (dropdownRef.current === t || dropdownRef.current.contains(t))) return
|
||||
recomputePos()
|
||||
const el = wrapRef.current
|
||||
if (el) {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.bottom < 0 || r.top > window.innerHeight) close() // триггер ушёл за экран
|
||||
}
|
||||
}
|
||||
const vv = window.visualViewport
|
||||
const onVv = () => recomputePos()
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
window.addEventListener('resize', onResize)
|
||||
vv?.addEventListener('resize', onVv)
|
||||
vv?.addEventListener('scroll', onVv)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
window.removeEventListener('resize', onResize)
|
||||
vv?.removeEventListener('resize', onVv)
|
||||
vv?.removeEventListener('scroll', onVv)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
}
|
||||
|
||||
interface FieldProps {
|
||||
label: string
|
||||
error?: string
|
||||
|
|
@ -132,17 +193,7 @@ export function Select({
|
|||
openUp,
|
||||
})
|
||||
}
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
recomputePos()
|
||||
const onScrollOrResize = () => setOpen(false)
|
||||
window.addEventListener('scroll', onScrollOrResize, true)
|
||||
window.addEventListener('resize', onScrollOrResize)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScrollOrResize, true)
|
||||
window.removeEventListener('resize', onScrollOrResize)
|
||||
}
|
||||
}, [open])
|
||||
useDropdownViewport(open, wrapRef, dropdownRef, recomputePos, () => setOpen(false))
|
||||
|
||||
const current = String(value ?? '')
|
||||
const selected = options.find((o) => o.value === current)
|
||||
|
|
@ -167,8 +218,13 @@ export function Select({
|
|||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) requestAnimationFrame(() => searchRef.current?.focus())
|
||||
}, [open])
|
||||
if (!open) return
|
||||
// На сенсорном вводе не фокусируем поиск при коротком списке (≤8) — иначе
|
||||
// клавиатура закрывает и без того короткий перечень. При длинном списке
|
||||
// поиск нужен — фокусируем. На десктопе фокус всегда (ввод с клавиатуры).
|
||||
if (isCoarsePointer() && options.length <= 8) return
|
||||
requestAnimationFrame(() => searchRef.current?.focus())
|
||||
}, [open, options.length])
|
||||
|
||||
const choose = (v: string) => {
|
||||
setOpen(false); setQuery(''); setCreateError(null)
|
||||
|
|
@ -362,14 +418,7 @@ export function AsyncSelect({
|
|||
const openUp = spaceBelow < 240 && r.top > spaceBelow
|
||||
setPos({ top: openUp ? r.top - 4 : r.bottom + 4, left: r.left, width: r.width, openUp })
|
||||
}
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
recomputePos()
|
||||
const fn = () => setOpen(false)
|
||||
window.addEventListener('scroll', fn, true)
|
||||
window.addEventListener('resize', fn)
|
||||
return () => { window.removeEventListener('scroll', fn, true); window.removeEventListener('resize', fn) }
|
||||
}, [open])
|
||||
useDropdownViewport(open, wrapRef, dropdownRef, recomputePos, () => setOpen(false))
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
|
@ -381,7 +430,14 @@ export function AsyncSelect({
|
|||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => { if (open) requestAnimationFrame(() => searchRef.current?.focus()) }, [open])
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
// AsyncSelect — поиск по серверу (большие справочники), для него поле поиска
|
||||
// ключевое, поэтому фокусируем и на сенсорном вводе. Но если уже загрузился
|
||||
// короткий список (≤8) — на телефоне не выдвигаем клавиатуру.
|
||||
if (isCoarsePointer() && options.length > 0 && options.length <= 8) return
|
||||
requestAnimationFrame(() => searchRef.current?.focus())
|
||||
}, [open, options.length])
|
||||
useEffect(() => { setHighlight(0) }, [query, open])
|
||||
|
||||
const choose = (id: string, label: string) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue