fix(supply-quick-add): dropdown opens upward + show only N results + create-new at bottom
Some checks are pending
Some checks are pending
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:
parent
6f839bf57a
commit
cad6b32f5e
|
|
@ -54,6 +54,10 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [highlight, setHighlight] = useState(0)
|
const [highlight, setHighlight] = useState(0)
|
||||||
const [loading, setLoading] = useState(false)
|
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 [hint, setHint] = useState<string | null>(null)
|
||||||
const [createPrefill, setCreatePrefill] = useState<null | (
|
const [createPrefill, setCreatePrefill] = useState<null | (
|
||||||
| { kind: 'barcode'; value: string; barcodeType: BarcodeType }
|
| { kind: 'barcode'; value: string; barcodeType: BarcodeType }
|
||||||
|
|
@ -63,15 +67,17 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const wrapRef = useRef<HTMLDivElement>(null)
|
const wrapRef = useRef<HTMLDivElement>(null)
|
||||||
const dropdownRef = 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'а — без этого
|
// Считаем позицию dropdown'а по rect input'а. Dropdown открывается ВВЕРХ
|
||||||
// он рендерится через portal в body и будет в углу.
|
// от input'а (МС-стиль): фиксируем `bottom` относительно низа viewport,
|
||||||
|
// вычисленный из input.top, и max-height 60vh — расти вверх в пределах
|
||||||
|
// экрана. Без этого portal в body рендерил бы попап в углу.
|
||||||
const recomputePos = () => {
|
const recomputePos = () => {
|
||||||
const el = inputRef.current
|
const el = inputRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const r = el.getBoundingClientRect()
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
@ -104,8 +110,9 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
|
||||||
if (q.length === 0) { setItems([]); setOpen(false); return }
|
if (q.length === 0) { setItems([]); setOpen(false); return }
|
||||||
const ac = new AbortController()
|
const ac = new AbortController()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setShowAll(false)
|
||||||
api.get<QuickSearchItem[]>('/api/catalog/products/quick-search', {
|
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,
|
signal: ac.signal,
|
||||||
}).then((res) => {
|
}).then((res) => {
|
||||||
setItems(res.data)
|
setItems(res.data)
|
||||||
|
|
@ -200,9 +207,10 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
|
||||||
const onKeyDown = async (e: KeyboardEvent<HTMLInputElement>) => {
|
const onKeyDown = async (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Escape') { setOpen(false); return }
|
if (e.key === 'Escape') { setOpen(false); return }
|
||||||
if (e.key === 'Tab') { 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') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setHighlight((h) => Math.min(h + 1, items.length - 1))
|
setHighlight((h) => Math.min(h + 1, Math.max(0, visibleLen - 1)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key === 'ArrowUp') {
|
if (e.key === 'ArrowUp') {
|
||||||
|
|
@ -216,9 +224,10 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
|
||||||
if (q.length === 0) return
|
if (q.length === 0) return
|
||||||
// Сначала пробуем сканер-флоу — точный barcode lookup
|
// Сначала пробуем сканер-флоу — точный barcode lookup
|
||||||
if (await tryScanByBarcode(q)) return
|
if (await tryScanByBarcode(q)) return
|
||||||
// Иначе берём подсвеченный пункт из dropdown
|
// Иначе берём подсвеченный пункт из видимой части dropdown'а
|
||||||
if (items.length > 0) {
|
const visible = showAll ? items : items.slice(0, VISIBLE_LIMIT)
|
||||||
const target = items[highlight] ?? items[0]
|
if (visible.length > 0) {
|
||||||
|
const target = visible[highlight] ?? visible[0]
|
||||||
await addById(target.id)
|
await addById(target.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -269,16 +278,16 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
|
||||||
{open && (query.trim().length > 0 || items.length > 0) && dropdownPos && createPortal(
|
{open && (query.trim().length > 0 || items.length > 0) && dropdownPos && createPortal(
|
||||||
<div
|
<div
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
style={{ position: 'fixed', top: dropdownPos.top, left: dropdownPos.left, width: dropdownPos.width }}
|
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 max-h-80 overflow-auto"
|
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 ? (
|
{loading && items.length === 0 ? (
|
||||||
<div className="px-3 py-2 text-sm text-slate-400">Ищу…</div>
|
<div className="px-3 py-2 text-sm text-slate-400">Ищу…</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="px-3 py-2 text-sm text-slate-400">Ничего не найдено</div>
|
<div className="px-3 py-2 text-sm text-slate-400">Ничего не найдено</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="py-1">
|
<ul className="py-1 overflow-y-auto">
|
||||||
{items.map((it, i) => (
|
{(showAll ? items : items.slice(0, VISIBLE_LIMIT)).map((it, i) => (
|
||||||
<li key={it.id}>
|
<li key={it.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -299,6 +308,17 @@ export function SupplyLineQuickAdd({ storeId, disabled, onPick }: Props) {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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 && (
|
{showCreateRow && (
|
||||||
<div className="border-t border-slate-100 dark:border-slate-800">
|
<div className="border-t border-slate-100 dark:border-slate-800">
|
||||||
<button
|
<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>
|
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) {
|
function highlightMatch(text: string, q: string) {
|
||||||
if (!q) return text
|
if (!q) return text
|
||||||
const i = text.toLowerCase().indexOf(q.toLowerCase())
|
const i = text.toLowerCase().indexOf(q.toLowerCase())
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue