fix(searchable-select): dropdown opens as floating overlay (Portal + absolute)

Старый 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 e96f1cdc86
commit dc4b5360b9

View file

@ -1,7 +1,8 @@
import { import {
Children, isValidElement, useEffect, useMemo, useRef, useState, Children, isValidElement, useEffect, useLayoutEffect, useMemo, useRef, useState,
type InputHTMLAttributes, type SelectHTMLAttributes, type ReactNode, type TextareaHTMLAttributes, type InputHTMLAttributes, type SelectHTMLAttributes, type ReactNode, type TextareaHTMLAttributes,
} from 'react' } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown } from 'lucide-react' import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
@ -88,6 +89,37 @@ export function Select({
const [createError, setCreateError] = useState<string | null>(null) const [createError, setCreateError] = useState<string | null>(null)
const wrapRef = useRef<HTMLDivElement>(null) const wrapRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(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 current = String(value ?? '')
const selected = options.find((o) => o.value === current) const selected = options.find((o) => o.value === current)
@ -102,9 +134,10 @@ export function Select({
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const onDoc = (e: MouseEvent) => { const onDoc = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { const t = e.target as Node
setOpen(false); setQuery('') const inWrap = wrapRef.current?.contains(t)
} const inDropdown = dropdownRef.current?.contains(t)
if (!inWrap && !inDropdown) { setOpen(false); setQuery('') }
} }
document.addEventListener('mousedown', onDoc) document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc) return () => document.removeEventListener('mousedown', onDoc)
@ -156,8 +189,17 @@ export function Select({
<span className={cn('truncate', !selected && 'text-slate-400')}>{triggerLabel}</span> <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" /> <ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
</button> </button>
{open && ( {open && pos && createPortal(
<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"> <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"> <div className="p-2 border-b border-slate-100 dark:border-slate-800">
<input <input
ref={searchRef} ref={searchRef}
@ -219,7 +261,8 @@ export function Select({
</li> </li>
)} )}
</ul> </ul>
</div> </div>,
document.body,
)} )}
</div> </div>
) )