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

На 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:
nns 2026-10-14 19:15:20 +05:00
parent 022e09ef7d
commit 8e719aeeb3

View file

@ -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) => {