From 049f4d654680d21fe65b3985cbfa0259ee627f4d Mon Sep 17 00:00:00 2001 From: 4aiman Date: Thu, 21 May 2026 00:48:27 +0300 Subject: [PATCH] Unified product search dropdown, UI fixes - Reusable createProductSearch() component used in food and ingredient modals - Search dropdown: position:absolute, z-index:100, shadow, scrollable - Products tab: list scrolls independently (max-height: calc(100vh - 209px - safe areas)) - prod-card flex-shrink:0 prevents collapse in scroll container - Products tab reverted to normal list (not dropdown) with expanded cards - Search limit raised to 200, sorted by starts-with-query first - Ingredient modal: uses createProductSearch instead of manual wiring - Food modal: uses createProductSearch instead of manual wiring --- frontend/css/styles.css | 63 +++++++++++++++- frontend/js/main.js | 163 +++++++++++++++++++--------------------- 2 files changed, 138 insertions(+), 88 deletions(-) diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 89833be..b9917e0 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -230,6 +230,7 @@ body { /* Form elements */ .form-group { + position: relative; margin-bottom: 14px; } @@ -771,6 +772,13 @@ select { display: flex; flex-direction: column; gap: 6px; + max-height: calc(100vh - 209px - var(--safe-area-top) - var(--safe-area-bottom)); + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.prod-list .prod-card { + flex-shrink: 0; } /* Product card — collapsed */ @@ -876,8 +884,61 @@ select { } /* Modal product search */ +.search-dropdown { + position: absolute; + left: 0; + right: 0; + z-index: 100; + max-height: 60vh; + overflow-y: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 4px 16px rgba(0,0,0,0.5); + -webkit-overflow-scrolling: touch; +} + +.search-dropdown .sd-item, +.search-dropdown .modal-prod-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + cursor: pointer; + border-bottom: 1px solid var(--border); + font-size: 14px; +} +.search-dropdown .sd-item:active, +.search-dropdown .modal-prod-item:active { + background: rgba(255,255,255,0.05); +} +.sd-item-kcal { + color: var(--muted); + font-size: 13px; + white-space: nowrap; + margin-left: 8px; +} + +.search-dropdown .prod-card { + border: none; + border-bottom: 1px solid var(--border); + border-radius: 0; + background: transparent; +} + .modal-search-results { - margin-top: 4px; + position: absolute; + left: 0; + right: 0; + z-index: 100; + max-height: 50vh; + overflow-y: auto; + margin-top: 2px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 4px 16px rgba(0,0,0,0.5); + -webkit-overflow-scrolling: touch; } .modal-prod-item { diff --git a/frontend/js/main.js b/frontend/js/main.js index b0a2884..6b9d14c 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -447,11 +447,7 @@ function showFoodModal() { showModal(`

Еда

-
- - - -
+
@@ -485,47 +481,21 @@ function showFoodModal() { } }); - // Wire up product search inside the modal const overlay = document.querySelector(".modal-overlay"); - const searchInput = overlay.querySelector("#m-prod-search"); - const resultsEl = overlay.querySelector("#m-prod-results"); const selectedEl = overlay.querySelector("#m-selected"); const manualFields = overlay.querySelector("#m-manual-fields"); - let debounce = null; - searchInput.oninput = () => { - clearTimeout(debounce); - debounce = setTimeout(async () => { - const q = searchInput.value.trim(); - if (q.length < 2) { resultsEl.innerHTML = ""; return; } - const results = await DB.searchProducts(q); - resultsEl.innerHTML = results.map(p => - `` - ).join("") || `
Не найдено
`; - - resultsEl.querySelectorAll(".modal-prod-item").forEach(item => { - item.onclick = () => { - const pid = parseInt(item.dataset.pid); - selectedProduct = results.find(r => r.id === pid); - resultsEl.innerHTML = ""; - searchInput.value = ""; - selectedEl.style.display = "block"; - selectedEl.innerHTML = `${esc(selectedProduct.name)}`; - manualFields.style.display = "none"; - - selectedEl.onclick = () => { - selectedProduct = null; - selectedEl.style.display = "none"; - manualFields.style.display = "block"; - searchInput.focus(); - }; - }; - }); - }, 300); - }; + createProductSearch(overlay.querySelector("#m-search-wrap"), (product) => { + selectedProduct = product; + selectedEl.style.display = "block"; + selectedEl.innerHTML = `${esc(product.name)}`; + manualFields.style.display = "none"; + selectedEl.onclick = () => { + selectedProduct = null; + selectedEl.style.display = "none"; + manualFields.style.display = ""; + }; + }); } function showEditFoodModal(entry) { @@ -1155,11 +1125,7 @@ function showAddIngredientModal(cookingMethods, onAdd) { showModal(`

Добавить ингредиент

-
- - - -
+
@@ -1212,51 +1178,23 @@ function showAddIngredientModal(cookingMethods, onAdd) { } }); - // Wire up product search — use the LAST modal overlay (this one, not the parent recipe editor) const overlays = document.querySelectorAll(".modal-overlay"); const overlay = overlays[overlays.length - 1]; - const searchInput = overlay.querySelector("#ing-search"); - const resultsEl = overlay.querySelector("#ing-results"); const selectedEl = overlay.querySelector("#ing-selected"); const customBlock = overlay.querySelector("#ing-custom-block"); - let debounce = null; - searchInput.oninput = () => { - clearTimeout(debounce); - debounce = setTimeout(async () => { - const q = searchInput.value.trim(); - if (q.length < 2) { resultsEl.innerHTML = ""; customBlock.style.display = ""; return; } - customBlock.style.display = "none"; - const results = await DB.searchProducts(q, 15); - resultsEl.innerHTML = results.map(p => - `` - ).join("") || `
Не найдено
`; - - resultsEl.querySelectorAll(".modal-prod-item").forEach(item => { - item.onclick = async () => { - const pid = parseInt(item.dataset.pid); - selectedProduct = await DB.getProduct(pid); - resultsEl.innerHTML = ""; - searchInput.value = ""; - selectedEl.style.display = "block"; - selectedEl.innerHTML = `${esc(selectedProduct.name)}`; - customBlock.style.display = "none"; - - selectedEl.onclick = () => { - selectedProduct = null; - selectedEl.style.display = "none"; - customBlock.style.display = ""; - searchInput.focus(); - }; - }; - }); - }, 300); - }; - - searchInput.focus(); + const search = createProductSearch(overlay.querySelector("#ing-search-wrap"), (product) => { + selectedProduct = product; + selectedEl.style.display = "block"; + selectedEl.innerHTML = `${esc(product.name)}`; + customBlock.style.display = "none"; + selectedEl.onclick = () => { + selectedProduct = null; + selectedEl.style.display = "none"; + customBlock.style.display = ""; + }; + }); + search.focus(); } // --------------------------------------------------------------------------- @@ -2569,6 +2507,57 @@ function buildMicroStrip(totals) { return `
${items.join("")}
`; } +// --------------------------------------------------------------------------- +// Reusable product search dropdown component +// parentEl: element to insert into (must have position:relative or will be set) +// onSelect(product): called when user picks a product from results +// opts.placeholder, opts.onClear (called when input cleared after selection) +// Returns { el, focus() } +// --------------------------------------------------------------------------- +function createProductSearch(parentEl, onSelect, opts) { + opts = opts || {}; + parentEl.style.position = "relative"; + + const wrap = document.createElement("div"); + wrap.innerHTML = ` + + `; + parentEl.appendChild(wrap); + + const input = wrap.querySelector("input"); + const dropdown = wrap.querySelector(".search-dropdown"); + let debounce = null; + + input.oninput = () => { + clearTimeout(debounce); + debounce = setTimeout(async () => { + const q = input.value.trim(); + if (q.length < 2) { dropdown.style.display = "none"; dropdown.innerHTML = ""; if (opts.onClear) opts.onClear(); return; } + const results = await DB.searchProducts(q); + dropdown.style.display = ""; + dropdown.innerHTML = results.map(p => + `
+ ${esc(p.name)} + ${p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) + " кк" : "?"} +
` + ).join("") || `
Не найдено
`; + + dropdown.querySelectorAll(".sd-item").forEach(item => { + item.onclick = async () => { + const pid = parseInt(item.dataset.pid); + const product = results.find(r => r.id === pid) || await DB.getProduct(pid); + dropdown.style.display = "none"; + dropdown.innerHTML = ""; + input.value = product.name; + onSelect(product); + }; + }); + }, 300); + }; + + return { el: wrap, input, focus() { input.focus(); } }; +} + function calcBmr(weights) { const p = state.profile; if (!p.height_cm || !p.birth_year) return null;