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
This commit is contained in:
4aiman 2026-05-21 00:48:27 +03:00
commit 049f4d6546
2 changed files with 138 additions and 88 deletions

View file

@ -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 {

View file

@ -447,11 +447,7 @@ function showFoodModal() {
showModal(`
<h3>Еда</h3>
<div class="form-group">
<label>Поиск продукта</label>
<input id="m-prod-search" type="search" placeholder="Название продукта..." />
<div id="m-prod-results" class="modal-search-results"></div>
</div>
<div id="m-search-wrap"></div>
<div id="m-selected" class="modal-selected" style="display:none"></div>
<div id="m-manual-fields">
<div class="form-group">
@ -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 =>
`<div class="modal-prod-item" data-pid="${p.id}">
<span>${esc(p.name)}</span>
<span class="prod-card-kcal">${p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) + " кк" : "?"}</span>
</div>`
).join("") || `<div class="prod-hint">Не найдено</div>`;
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 = `<span>${esc(selectedProduct.name)}</span>`;
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 = `<span>${esc(product.name)}</span>`;
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(`
<h3>Добавить ингредиент</h3>
<div class="form-group">
<label>Поиск продукта</label>
<input id="ing-search" type="search" placeholder="Название продукта..." />
<div id="ing-results" class="modal-search-results"></div>
</div>
<div id="ing-search-wrap"></div>
<div id="ing-selected" class="modal-selected" style="display:none"></div>
<div id="ing-custom-block">
<div class="form-group">
@ -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 =>
`<div class="modal-prod-item" data-pid="${p.id}">
<span>${esc(p.name)}</span>
<span class="prod-card-kcal">${p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) + " кк" : "?"}</span>
</div>`
).join("") || `<div class="prod-hint">Не найдено</div>`;
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 = `<span>${esc(selectedProduct.name)}</span>`;
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 = `<span>${esc(product.name)}</span>`;
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 `<div class="micro-strip">${items.join("")}</div>`;
}
// ---------------------------------------------------------------------------
// 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 = `
<input type="search" class="search-input" placeholder="${esc(opts.placeholder || "Поиск продукта...")}" />
<div class="search-dropdown" style="display:none"></div>`;
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 =>
`<div class="sd-item" data-pid="${p.id}">
<span>${esc(p.name)}</span>
<span class="sd-item-kcal">${p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) + " кк" : "?"}</span>
</div>`
).join("") || `<div class="prod-hint">Не найдено</div>`;
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;