feat: infinite scroll op zoekresultaten — append-based batch loading (50 per batch)
- renderZoeken toont alleen eerste 50 items, geen DOM overload meer - IntersectionObserver laadt volgende batches via append (behoudt scrollpositie) - Event delegation voor openAddToMealForMoment (werkt met dynamisch geladen items) - Pagination reset bij nieuw zoekopdracht, categorie-filter, en tab-switch - Sentinel-element onder foodList als IntersectionObserver target - searchSentinel/sentinel CSS + searchLoading styling
This commit is contained in:
parent
1091eb5bf8
commit
544d71e1ac
1 changed files with 145 additions and 46 deletions
191
index.html
191
index.html
|
|
@ -252,6 +252,19 @@ body {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-sentinel {
|
||||||
|
height: 1px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.food-item {
|
.food-item {
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
|
|
@ -2048,6 +2061,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
<div class="results-section">
|
<div class="results-section">
|
||||||
<div class="results-count" id="resultsCount"></div>
|
<div class="results-count" id="resultsCount"></div>
|
||||||
<ul class="food-list" id="foodList"></ul>
|
<ul class="food-list" id="foodList"></ul>
|
||||||
|
<div id="searchSentinel" class="search-sentinel"></div>
|
||||||
<div class="loading hidden" id="loadingState">
|
<div class="loading hidden" id="loadingState">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
<div>Voedingsmiddelen laden...</div>
|
<div>Voedingsmiddelen laden...</div>
|
||||||
|
|
@ -2355,6 +2369,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
let searchIndex = null; // Map<item, expanded search tokens string>
|
let searchIndex = null; // Map<item, expanded search tokens string>
|
||||||
let dataLoaded = false;
|
let dataLoaded = false;
|
||||||
let maaltijden = []; // [{ id, naam, items: [{item, portie}, ...], khTotaal }]
|
let maaltijden = []; // [{ id, naam, items: [{item, portie}, ...], khTotaal }]
|
||||||
|
const SEARCH_PAGE_SIZE = 50;
|
||||||
|
let searchVisibleCount = SEARCH_PAGE_SIZE;
|
||||||
|
let searchObserver = null;
|
||||||
|
|
||||||
// ===== Utility Functions =====
|
// ===== Utility Functions =====
|
||||||
function formatDate(d) {
|
function formatDate(d) {
|
||||||
|
|
@ -2671,16 +2688,17 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
// ===== Rendering =====
|
// ===== Rendering =====
|
||||||
function renderZoeken() {
|
function renderZoeken() {
|
||||||
if (!dataLoaded) return;
|
if (!dataLoaded) return;
|
||||||
|
if (activeTab !== 'zoeken') return;
|
||||||
|
|
||||||
const items = getFilteredItems();
|
const items = getFilteredItems();
|
||||||
const listEl = document.getElementById('foodList');
|
const listEl = document.getElementById('foodList');
|
||||||
const countEl = document.getElementById('resultsCount');
|
const countEl = document.getElementById('resultsCount');
|
||||||
const emptyEl = document.getElementById('emptyState');
|
const emptyEl = document.getElementById('emptyState');
|
||||||
const container = document.getElementById('tabZoeken');
|
const sentinelEl = document.getElementById('searchSentinel');
|
||||||
const errorEl = document.getElementById('errorState');
|
|
||||||
|
|
||||||
// Make sure zoeken tab is visible if active
|
// Reset pagination on new search/filter
|
||||||
if (activeTab !== 'zoeken') return;
|
searchVisibleCount = SEARCH_PAGE_SIZE;
|
||||||
|
disconnectSearchObserver();
|
||||||
|
|
||||||
// Show results count
|
// Show results count
|
||||||
countEl.textContent = items.length === 0
|
countEl.textContent = items.length === 0
|
||||||
|
|
@ -2691,35 +2709,110 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
emptyEl.classList.remove('hidden');
|
emptyEl.classList.remove('hidden');
|
||||||
} else {
|
sentinelEl.classList.add('hidden');
|
||||||
emptyEl.classList.add('hidden');
|
return;
|
||||||
listEl.innerHTML = items.map((item, idx) => {
|
|
||||||
const kh = parseKh(item.kh);
|
|
||||||
return `
|
|
||||||
<li class="food-item" data-index="${voedingsmiddelen.indexOf(item)}" style="--i:${idx}" role="button" tabindex="0">
|
|
||||||
<div class="food-item-left">
|
|
||||||
<div class="food-item-name">${escapeHtml(displayNaam(item.naam))}</div>
|
|
||||||
<div class="food-item-cat">${escapeHtml(item.cat)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="food-item-right">
|
|
||||||
<div class="food-item-kh">${formatKh(kh)}</div>
|
|
||||||
<div class="food-item-unit">g kh / 100g</div>
|
|
||||||
</div>
|
|
||||||
<span class="food-item-arrow">›</span>
|
|
||||||
</li>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Add click handlers
|
|
||||||
listEl.querySelectorAll('.food-item').forEach(el => {
|
|
||||||
el.addEventListener('click', () => {
|
|
||||||
const idx = parseInt(el.dataset.index);
|
|
||||||
if (idx >= 0 && idx < voedingsmiddelen.length) {
|
|
||||||
showDetail(voedingsmiddelen[idx]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emptyEl.classList.add('hidden');
|
||||||
|
|
||||||
|
// Render first batch
|
||||||
|
const batch = items.slice(0, SEARCH_PAGE_SIZE);
|
||||||
|
listEl.innerHTML = renderFoodItemsHtml(batch, 0);
|
||||||
|
attachFoodItemHandlers(listEl);
|
||||||
|
|
||||||
|
// Setup infinite scroll if more items exist
|
||||||
|
if (items.length > SEARCH_PAGE_SIZE) {
|
||||||
|
sentinelEl.classList.remove('hidden');
|
||||||
|
setupSearchObserver(items);
|
||||||
|
} else {
|
||||||
|
sentinelEl.classList.add('hidden');
|
||||||
|
disconnectSearchObserver();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFoodItemsHtml(items, startIndex) {
|
||||||
|
return items.map((item, idx) => {
|
||||||
|
const globalIdx = startIndex + idx;
|
||||||
|
const kh = parseKh(item.kh);
|
||||||
|
return `
|
||||||
|
<li class="food-item" data-index="${voedingsmiddelen.indexOf(item)}" style="--i:${globalIdx}" role="button" tabindex="0">
|
||||||
|
<div class="food-item-left">
|
||||||
|
<div class="food-item-name">${escapeHtml(displayNaam(item.naam))}</div>
|
||||||
|
<div class="food-item-cat">${escapeHtml(item.cat)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="food-item-right">
|
||||||
|
<div class="food-item-kh">${formatKh(kh)}</div>
|
||||||
|
<div class="food-item-unit">g kh / 100g</div>
|
||||||
|
</div>
|
||||||
|
<span class="food-item-arrow">›</span>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachFoodItemHandlers(listEl) {
|
||||||
|
listEl.querySelectorAll('.food-item').forEach(el => {
|
||||||
|
// Avoid double handlers on re-render edge cases
|
||||||
|
if (el._clickAttached) return;
|
||||||
|
el._clickAttached = true;
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
const idx = parseInt(el.dataset.index);
|
||||||
|
if (idx >= 0 && idx < voedingsmiddelen.length) {
|
||||||
|
showDetail(voedingsmiddelen[idx]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSearchObserver(allItems) {
|
||||||
|
disconnectSearchObserver();
|
||||||
|
const sentinel = document.getElementById('searchSentinel');
|
||||||
|
if (!sentinel) return;
|
||||||
|
|
||||||
|
searchObserver = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
|
appendSearchBatch(allItems);
|
||||||
|
}
|
||||||
|
}, { rootMargin: '200px' });
|
||||||
|
|
||||||
|
searchObserver.observe(sentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSearchBatch(allItems) {
|
||||||
|
const listEl = document.getElementById('foodList');
|
||||||
|
const sentinelEl = document.getElementById('searchSentinel');
|
||||||
|
if (!listEl || !sentinelEl) return;
|
||||||
|
|
||||||
|
const remaining = allItems.length - searchVisibleCount;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
disconnectSearchObserver();
|
||||||
|
sentinelEl.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = Math.min(SEARCH_PAGE_SIZE, remaining);
|
||||||
|
const batch = allItems.slice(searchVisibleCount, searchVisibleCount + batchSize);
|
||||||
|
listEl.insertAdjacentHTML('beforeend', renderFoodItemsHtml(batch, searchVisibleCount));
|
||||||
|
attachFoodItemHandlers(listEl);
|
||||||
|
searchVisibleCount += batchSize;
|
||||||
|
|
||||||
|
// All items loaded — hide sentinel and disconnect
|
||||||
|
if (searchVisibleCount >= allItems.length) {
|
||||||
|
disconnectSearchObserver();
|
||||||
|
sentinelEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectSearchObserver() {
|
||||||
|
if (searchObserver) {
|
||||||
|
searchObserver.disconnect();
|
||||||
|
searchObserver = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSearchPagination() {
|
||||||
|
searchVisibleCount = SEARCH_PAGE_SIZE;
|
||||||
|
disconnectSearchObserver();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProgressBar(totaal) {
|
function renderProgressBar(totaal) {
|
||||||
|
|
@ -3151,21 +3244,21 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
// Store the target moment so next food tap adds to it
|
// Store the target moment so next food tap adds to it
|
||||||
window._targetMoment = momentId;
|
window._targetMoment = momentId;
|
||||||
|
|
||||||
// Override food item click behavior temporarily
|
// Use event delegation so dynamically loaded items also work
|
||||||
const listEl = document.getElementById('foodList');
|
const listEl = document.getElementById('foodList');
|
||||||
listEl.querySelectorAll('.food-item').forEach(el => {
|
const handler = (e) => {
|
||||||
const origClick = el.onclick;
|
const itemEl = e.target.closest('.food-item');
|
||||||
el.addEventListener('click', function handler(e) {
|
if (!itemEl) return;
|
||||||
const idx = parseInt(el.dataset.index);
|
const idx = parseInt(itemEl.dataset.index);
|
||||||
if (idx >= 0 && idx < voedingsmiddelen.length && window._targetMoment) {
|
if (idx >= 0 && idx < voedingsmiddelen.length && window._targetMoment) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showAddToMeal(voedingsmiddelen[idx], window._targetMoment);
|
showAddToMeal(voedingsmiddelen[idx], window._targetMoment);
|
||||||
window._targetMoment = null;
|
window._targetMoment = null;
|
||||||
el.removeEventListener('click', handler);
|
listEl.removeEventListener('click', handler);
|
||||||
}
|
}
|
||||||
}, { once: true });
|
};
|
||||||
});
|
listEl.addEventListener('click', handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addToMeal(item, portie, momentId) {
|
function addToMeal(item, portie, momentId) {
|
||||||
|
|
@ -3546,10 +3639,13 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
setTimeout(() => mainContent.classList.remove('page-transition'), 350);
|
setTimeout(() => mainContent.classList.remove('page-transition'), 350);
|
||||||
|
|
||||||
if (tab === 'zoeken') {
|
if (tab === 'zoeken') {
|
||||||
|
resetSearchPagination();
|
||||||
renderZoeken();
|
renderZoeken();
|
||||||
} else if (tab === 'dagboek') {
|
} else if (tab === 'dagboek') {
|
||||||
|
disconnectSearchObserver();
|
||||||
renderDagboek();
|
renderDagboek();
|
||||||
} else if (tab === 'maaltijden') {
|
} else if (tab === 'maaltijden') {
|
||||||
|
disconnectSearchObserver();
|
||||||
renderMaaltijden();
|
renderMaaltijden();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4599,6 +4695,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
searchInput.addEventListener('input', () => {
|
searchInput.addEventListener('input', () => {
|
||||||
searchQuery = searchInput.value;
|
searchQuery = searchInput.value;
|
||||||
searchClear.classList.toggle('visible', searchQuery.length > 0);
|
searchClear.classList.toggle('visible', searchQuery.length > 0);
|
||||||
|
resetSearchPagination();
|
||||||
renderZoeken();
|
renderZoeken();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -4606,6 +4703,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
searchInput.value = '';
|
searchInput.value = '';
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
searchClear.classList.remove('visible');
|
searchClear.classList.remove('visible');
|
||||||
|
resetSearchPagination();
|
||||||
renderZoeken();
|
renderZoeken();
|
||||||
searchInput.focus();
|
searchInput.focus();
|
||||||
});
|
});
|
||||||
|
|
@ -4619,6 +4717,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
|
|
||||||
selectedCategory = btn.dataset.cat || null;
|
selectedCategory = btn.dataset.cat || null;
|
||||||
|
resetSearchPagination();
|
||||||
renderZoeken();
|
renderZoeken();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue