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:
parent
e96f1cdc86
commit
dc4b5360b9
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue