From 8e719aeeb3afbf08eae79c8820512a324b6eff37 Mon Sep 17 00:00:00 2001 From: nns Date: Wed, 14 Oct 2026 19:15:20 +0500 Subject: [PATCH] =?UTF-8?q?fix(=D0=BA=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5=D1=82/?= =?UTF-8?q?Android):=20=D0=B2=D1=8B=D0=BF=D0=B0=D0=B4=D0=B0=D1=8E=D1=89?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D1=8E=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BE=D1=82=20=D0=BA=D0=BB=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На 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 --- src/food-market.web/src/components/Field.tsx | 100 +++++++++++++++---- 1 file changed, 78 insertions(+), 22 deletions(-) diff --git a/src/food-market.web/src/components/Field.tsx b/src/food-market.web/src/components/Field.tsx index 786a463..f6b533d 100644 --- a/src/food-market.web/src/components/Field.tsx +++ b/src/food-market.web/src/components/Field.tsx @@ -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, + dropdownRef: React.RefObject, + 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) => {