fix(supply-quick-add): dropdown opens upward + show only N results + create-new at bottom

UX как в сторонняя системае:
- Dropdown открывается ВВЕРХ от input'а (anchor по input.top, fixed
  bottom). Max-height 60vh — не перекрывает шапку, внутри overflow-y.
  Раньше выпадал вниз и при добавлении строк визуально не оставалось
  места.
- Показываем первые 10 матчей (VISIBLE_LIMIT=10), под ними ссылка
  «Ещё N товаров» которая раскрывает полный список (limit=50 на
  сервере). На запрос приходит до 50 — этого достаточно для
  подавляющего большинства поисков.
- Под списком (через тонкий разделитель) — пункт «+ Создать новый
  товар: «{q}»» / «Создать товар со штрихкодом «…»». Всегда последний
  визуально, ближайший к input'у — самый удобный для быстрого клика.

Стрелки ↑↓ работают по видимой части (1..10 или весь список после
expand). Enter подбирает подсвеченный из видимой части.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 02:33:57 +05:00
parent 95bf2188c6
commit b56c499b45

View file

@ -54,6 +54,10 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
const [open, setOpen] = useState(false)
const [highlight, setHighlight] = useState(0)
const [loading, setLoading] = useState(false)
// По умолчанию показываем первые 10 матчей. Ссылка «Ещё N товаров»
// расширяет dropdown до полного списка (limit=50 на запрос).
const VISIBLE_LIMIT = 10
const [showAll, setShowAll] = useState(false)
const [hint, setHint] = useState<string | null>(null)
const [createPrefill, setCreatePrefill] = useState<null | (
| { kind: 'barcode'; value: string; barcodeType: BarcodeType }
@ -63,15 +67,17 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
const inputRef = useRef<HTMLInputElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; width: number } | null>(null)
const [dropdownPos, setDropdownPos] = useState<{ bottom: number; left: number; width: number } | null>(null)
// Считаем позицию dropdown'а по rect input'а — без этого
// он рендерится через portal в body и будет в углу.
// Считаем позицию dropdown'а по rect input'а. Dropdown открывается ВВЕРХ
// от input'а (МС-стиль): фиксируем `bottom` относительно низа viewport,
// вычисленный из input.top, и max-height 60vh — расти вверх в пределах
// экрана. Без этого portal в body рендерил бы попап в углу.
const recomputePos = () => {
const el = inputRef.current
if (!el) return
const r = el.getBoundingClientRect()
setDropdownPos({ top: r.bottom + 4, left: r.left, width: r.width })
setDropdownPos({ bottom: window.innerHeight - r.top + 4, left: r.left, width: r.width })
}
useLayoutEffect(() => {
if (!open) return
@ -104,8 +110,9 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
if (q.length === 0) { setItems([]); setOpen(false); return }
const ac = new AbortController()
setLoading(true)
setShowAll(false)
api.get<QuickSearchItem[]>('/api/catalog/products/quick-search', {
params: { search: q, storeId: storeId || undefined, limit: 20 },
params: { search: q, storeId: storeId || undefined, limit: 50 },
signal: ac.signal,
}).then((res) => {
setItems(res.data)
@ -200,9 +207,10 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
const onKeyDown = async (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') { setOpen(false); return }
if (e.key === 'Tab') { setOpen(false); return }
const visibleLen = (showAll ? items.length : Math.min(items.length, VISIBLE_LIMIT))
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlight((h) => Math.min(h + 1, items.length - 1))
setHighlight((h) => Math.min(h + 1, Math.max(0, visibleLen - 1)))
return
}
if (e.key === 'ArrowUp') {
@ -216,9 +224,10 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
if (q.length === 0) return
// Сначала пробуем сканер-флоу — точный barcode lookup
if (await tryScanByBarcode(q)) return
// Иначе берём подсвеченный пункт из dropdown
if (items.length > 0) {
const target = items[highlight] ?? items[0]
// Иначе берём подсвеченный пункт из видимой части dropdown'а
const visible = showAll ? items : items.slice(0, VISIBLE_LIMIT)
if (visible.length > 0) {
const target = visible[highlight] ?? visible[0]
await addById(target.id)
return
}
@ -269,16 +278,16 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
{open && (query.trim().length > 0 || items.length > 0) && dropdownPos && createPortal(
<div
ref={dropdownRef}
style={{ position: 'fixed', top: dropdownPos.top, left: dropdownPos.left, width: dropdownPos.width }}
className="z-[100] rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg max-h-80 overflow-auto"
style={{ position: 'fixed', bottom: dropdownPos.bottom, left: dropdownPos.left, width: dropdownPos.width, maxHeight: '60vh' }}
className="z-[100] rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg flex flex-col overflow-hidden"
>
{loading && items.length === 0 ? (
<div className="px-3 py-2 text-sm text-slate-400">Ищу</div>
) : items.length === 0 ? (
<div className="px-3 py-2 text-sm text-slate-400">Ничего не найдено</div>
) : (
<ul className="py-1">
{items.map((it, i) => (
<ul className="py-1 overflow-y-auto">
{(showAll ? items : items.slice(0, VISIBLE_LIMIT)).map((it, i) => (
<li key={it.id}>
<button
type="button"
@ -299,6 +308,17 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
))}
</ul>
)}
{!showAll && items.length > VISIBLE_LIMIT && (
<div className="border-t border-slate-100 dark:border-slate-800">
<button
type="button"
onClick={() => setShowAll(true)}
className="w-full text-left px-3 py-2 text-sm text-[var(--color-brand)] hover:bg-slate-100 dark:hover:bg-slate-800"
>
Ещё {items.length - VISIBLE_LIMIT} {plural(items.length - VISIBLE_LIMIT, 'товар', 'товара', 'товаров')}
</button>
</div>
)}
{showCreateRow && (
<div className="border-t border-slate-100 dark:border-slate-800">
<button
@ -343,6 +363,14 @@ function StockBadge({ qty }: { qty: number | null }) {
return <span className={`text-xs font-mono px-1.5 py-0.5 rounded border ${cls}`}>{qty}</span>
}
function plural(n: number, one: string, few: string, many: string): string {
const mod10 = n % 10
const mod100 = n % 100
if (mod10 === 1 && mod100 !== 11) return one
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few
return many
}
function highlightMatch(text: string, q: string) {
if (!q) return text
const i = text.toLowerCase().indexOf(q.toLowerCase())