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 { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { localizeNativeValidation } from '@/lib/validation'
|
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 {
|
interface FieldProps {
|
||||||
label: string
|
label: string
|
||||||
error?: string
|
error?: string
|
||||||
|
|
@ -132,17 +193,7 @@ export function Select({
|
||||||
openUp,
|
openUp,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
useLayoutEffect(() => {
|
useDropdownViewport(open, wrapRef, dropdownRef, recomputePos, () => setOpen(false))
|
||||||
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])
|
|
||||||
|
|
||||||
const current = String(value ?? '')
|
const current = String(value ?? '')
|
||||||
const selected = options.find((o) => o.value === current)
|
const selected = options.find((o) => o.value === current)
|
||||||
|
|
@ -167,8 +218,13 @@ export function Select({
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) requestAnimationFrame(() => searchRef.current?.focus())
|
if (!open) return
|
||||||
}, [open])
|
// На сенсорном вводе не фокусируем поиск при коротком списке (≤8) — иначе
|
||||||
|
// клавиатура закрывает и без того короткий перечень. При длинном списке
|
||||||
|
// поиск нужен — фокусируем. На десктопе фокус всегда (ввод с клавиатуры).
|
||||||
|
if (isCoarsePointer() && options.length <= 8) return
|
||||||
|
requestAnimationFrame(() => searchRef.current?.focus())
|
||||||
|
}, [open, options.length])
|
||||||
|
|
||||||
const choose = (v: string) => {
|
const choose = (v: string) => {
|
||||||
setOpen(false); setQuery(''); setCreateError(null)
|
setOpen(false); setQuery(''); setCreateError(null)
|
||||||
|
|
@ -362,14 +418,7 @@ export function AsyncSelect({
|
||||||
const openUp = spaceBelow < 240 && r.top > spaceBelow
|
const openUp = spaceBelow < 240 && r.top > spaceBelow
|
||||||
setPos({ top: openUp ? r.top - 4 : r.bottom + 4, left: r.left, width: r.width, openUp })
|
setPos({ top: openUp ? r.top - 4 : r.bottom + 4, left: r.left, width: r.width, openUp })
|
||||||
}
|
}
|
||||||
useLayoutEffect(() => {
|
useDropdownViewport(open, wrapRef, dropdownRef, recomputePos, () => setOpen(false))
|
||||||
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])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
@ -381,7 +430,14 @@ export function AsyncSelect({
|
||||||
return () => document.removeEventListener('mousedown', onDoc)
|
return () => document.removeEventListener('mousedown', onDoc)
|
||||||
}, [open])
|
}, [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])
|
useEffect(() => { setHighlight(0) }, [query, open])
|
||||||
|
|
||||||
const choose = (id: string, label: string) => {
|
const choose = (id: string, label: string) => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue