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:
parent
3e5333cec6
commit
049f4d6546
2 changed files with 138 additions and 88 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue