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