fix(searchable-select): dropdown opens as floating overlay (Portal + absolute)
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 42s
CI / Web (React + Vite) (push) Successful in 38s
Docker Web / Build + push Web (push) Successful in 28s
Docker Web / Deploy Web on stage (push) Successful in 12s

Старый dropdown использовал position:absolute внутри Section (а у того
overflow-hidden для скруглений), из-за чего он клипался границами
карточки. На некоторых страницах визуально это смотрелось так, будто
список «раздвигает» layout.

Решение — тот же Portal-паттерн что у SupplyLineQuickAdd и DateField:
- dropdown рендерится через createPortal в document.body
- position: fixed, координаты по getBoundingClientRect() trigger'а
- z-[100], sticky-headers/секции не перекроют
- Auto-flip: если внизу <240px и сверху больше — открываем вверх
  (anchor через bottom: window.innerHeight - rect.top)
- Outside-click учитывает обе ноды (wrap + dropdown в portal)
- На window.scroll/resize dropdown закрывается чтобы не «уплывать»
  относительно trigger'а

Фиксит сразу все Select'ы в проекте (тип штрихкода, валюта, страна,
группа товара, тип цены и т.д.) — компонент один.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 03:56:03 +05:00
parent 37bacc196e
commit d447f431ba

View file

@ -1,7 +1,8 @@
import {
Children, isValidElement, useEffect, useMemo, useRef, useState,
Children, isValidElement, useEffect, useLayoutEffect, useMemo, useRef, useState,
type InputHTMLAttributes, type SelectHTMLAttributes, type ReactNode, type TextareaHTMLAttributes,
} from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useOrgSettings } from '@/lib/useOrgSettings'
@ -88,6 +89,37 @@ export function Select({
const [createError, setCreateError] = useState<string | null>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ top: number; left: number; width: number; openUp: boolean } | null>(null)
// Считаем позицию dropdown'а: предпочтительно вниз, но если внизу < 240px,
// а вверху больше — открываем вверх (auto-flip). Через portal в body
// — overflow-hidden родителей не режет.
const recomputePos = () => {
const el = wrapRef.current
if (!el) return
const r = el.getBoundingClientRect()
const spaceBelow = window.innerHeight - r.bottom
const spaceAbove = r.top
const openUp = spaceBelow < 240 && spaceAbove > spaceBelow
setPos({
top: openUp ? r.top - 4 : r.bottom + 4,
left: r.left,
width: r.width,
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])
const current = String(value ?? '')
const selected = options.find((o) => o.value === current)
@ -102,9 +134,10 @@ export function Select({
useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false); setQuery('')
}
const t = e.target as Node
const inWrap = wrapRef.current?.contains(t)
const inDropdown = dropdownRef.current?.contains(t)
if (!inWrap && !inDropdown) { setOpen(false); setQuery('') }
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
@ -156,8 +189,17 @@ export function Select({
<span className={cn('truncate', !selected && 'text-slate-400')}>{triggerLabel}</span>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
</button>
{open && (
<div className="absolute z-50 mt-1 w-full max-h-72 overflow-hidden rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg flex flex-col">
{open && pos && createPortal(
<div
ref={dropdownRef}
style={{
position: 'fixed',
left: pos.left,
width: pos.width,
...(pos.openUp ? { bottom: window.innerHeight - pos.top } : { top: pos.top }),
}}
className="z-[100] max-h-72 overflow-hidden rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg flex flex-col"
>
<div className="p-2 border-b border-slate-100 dark:border-slate-800">
<input
ref={searchRef}
@ -219,7 +261,8 @@ export function Select({
</li>
)}
</ul>
</div>
</div>,
document.body,
)}
</div>
)