UX: Animaties + haptics + bottom sheet verbeteringen

- Meal-group expand/collapse: smooth max-height transitie
- Items verwijderen: fade-out animatie
- Checkbox toggle: check animatie + kleur-transitie
- Footer knoppen: fadeIn bij verschijnen
- Portie stepper +/-: visuele pulse feedback
- Glucose save: visuele bevestiging
- Destructive haptics: vibrate(15) ipv 8
- Validatiefouten: dubbele korte vibrate
- Bottom sheet: swipe-down gesture + backdrop click fix

38/39 tests groen
This commit is contained in:
cas 2026-07-24 23:05:11 +02:00
parent 065644eb73
commit 81d459cd51
2 changed files with 574 additions and 96 deletions

View file

@ -739,11 +739,15 @@ body {
background: var(--primary-light); background: var(--primary-light);
} }
.meal-group-items { .meal-group-items {
display: none; max-height: 0;
opacity: 0;
overflow: hidden;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
} }
.meal-group-header.expanded + .meal-group-items { .meal-group-header.expanded + .meal-group-items {
display: block; max-height: 2000px;
opacity: 1;
} }
.meal-sub-item { .meal-sub-item {
padding-left: 36px; padding-left: 36px;
@ -839,7 +843,8 @@ body {
} }
.eetmoment-items { .eetmoment-items {
padding: 4px 0; --collapsible-pt: 4px;
--collapsible-pb: 4px;
} }
.eetmoment-item { .eetmoment-item {
@ -1282,11 +1287,23 @@ body {
font-size: var(--font-sm); font-size: var(--font-sm);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.15s, opacity 0.15s; transition: background 0.15s, opacity 0.3s ease, max-height 0.3s ease, padding 0.3s ease, margin 0.3s ease, border-width 0.3s ease;
text-align: center; text-align: center;
font-family: var(--body-font); font-family: var(--body-font);
min-height: var(--touch-min); min-height: var(--touch-min);
line-height: 1.3; line-height: 1.3;
max-height: 80px;
overflow: hidden;
}
.save-meal-footer-btn.footer-hidden {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
margin-top: 0;
margin-bottom: 0;
border-width: 0;
pointer-events: none;
} }
.save-meal-footer-btn:hover { .save-meal-footer-btn:hover {
background: var(--primary); background: var(--primary);
@ -1339,11 +1356,13 @@ body {
font-size: var(--font-sm); font-size: var(--font-sm);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.15s, opacity 0.15s; transition: background 0.15s, opacity 0.3s ease, max-height 0.3s ease, padding 0.3s ease, margin 0.3s ease, border-width 0.3s ease;
text-align: center; text-align: center;
font-family: var(--body-font); font-family: var(--body-font);
min-height: var(--touch-min); min-height: var(--touch-min);
line-height: 1.3; line-height: 1.3;
max-height: 80px;
overflow: hidden;
} }
.delete-selected-footer-btn:hover { .delete-selected-footer-btn:hover {
background: var(--danger); background: var(--danger);
@ -1353,8 +1372,17 @@ body {
background: #B71C1C; background: #B71C1C;
color: var(--white); color: var(--white);
} }
.delete-selected-footer-btn.hidden { .delete-selected-footer-btn.hidden,
display: none; .delete-selected-footer-btn.footer-hidden {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
margin-top: 0;
margin-bottom: 0;
border-width: 0;
pointer-events: none;
overflow: hidden;
} }
/* ===== Diary — nested meal groups ===== */ /* ===== Diary — nested meal groups ===== */
@ -1391,7 +1419,8 @@ body {
} }
.eetmoment-meal-items-wrap { .eetmoment-meal-items-wrap {
border-top: 1px solid rgba(232, 183, 48, 0.2); border-top: 1px solid rgba(232, 183, 48, 0.2);
padding: 2px 0; --collapsible-pt: 2px;
--collapsible-pb: 2px;
} }
.eetmoment-meal-item { .eetmoment-meal-item {
display: flex; display: flex;
@ -2185,6 +2214,11 @@ body {
@keyframes slideOutRight{ from { transform: translateX(0); opacity: 1; } to { transform: translateX(120%); opacity: 0; } } @keyframes slideOutRight{ from { transform: translateX(0); opacity: 1; } to { transform: translateX(120%); opacity: 0; } }
@keyframes scaleIn { from { transform: scale(0.85); opacity: 0; } to { transform: scale(1); opacity: 1; } } @keyframes scaleIn { from { transform: scale(0.85); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.12); } } @keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.12); } }
@keyframes pulseCount { 0%,100% { transform: scale(1); } 50% { transform: scale(1.3); color: var(--primary); } }
@keyframes fadeInDown { from { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; } to { opacity: 1; max-height: 800px; } }
@keyframes fadeOutUp { from { opacity: 1; max-height: 800px; } to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; } }
@keyframes checkPop { 0% { transform: scale(1); } 50% { transform: scale(1.3); } 100% { transform: scale(1); } }
@keyframes saveFlash { 0% { box-shadow: 0 0 0 0 rgba(56,161,105,0.4); } 70% { box-shadow: 0 0 0 12px rgba(56,161,105,0); } 100% { box-shadow: 0 0 0 0 rgba(56,161,105,0); } }
@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
/* -- Button press -- */ /* -- Button press -- */
@ -2284,6 +2318,58 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite; animation: shimmer 1.5s ease-in-out infinite;
} }
/* ===== Collapsible sections — max-height transition ===== */
.collapsible {
overflow: hidden;
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease, padding 0.3s ease, margin 0.3s ease;
max-height: 2000px;
opacity: 1;
padding-top: var(--collapsible-pt, 4px);
padding-bottom: var(--collapsible-pb, 4px);
margin-top: var(--collapsible-mt, 0);
margin-bottom: var(--collapsible-mb, 0);
}
.collapsible.collapsed {
max-height: 0 !important;
opacity: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
overflow: hidden !important;
}
/* ===== Item delete fade-out ===== */
.removing {
animation: fadeOutUp 0.3s ease forwards;
overflow: hidden;
pointer-events: none;
}
/* ===== Footer buttons: slide in/out ===== */
.footer-btn-visible {
animation: fadeInDown 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.footer-btn-hidden {
animation: fadeOutUp 0.25s ease forwards;
pointer-events: none;
}
/* ===== Checkbox check animation ===== */
.item-checkbox-wrapper.just-checked {
animation: checkPop 0.25s ease;
}
/* ===== Glucose save flash ===== */
.glucose-inline.save-flash {
animation: saveFlash 0.6s ease;
}
/* ===== Stepper count bump ===== */
.portie-stepper-count.count-bump {
animation: pulseCount 0.3s ease;
}
</style> </style>
</head> </head>
<body> <body>
@ -2940,6 +3026,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// Validate // Validate
if (val !== null && (isNaN(val) || val < 0 || val > 30)) { if (val !== null && (isNaN(val) || val < 0 || val > 30)) {
hapticError();
input.value = glucose[activeDate][moment] !== null && glucose[activeDate][moment] !== undefined ? glucose[activeDate][moment] : ''; input.value = glucose[activeDate][moment] !== null && glucose[activeDate][moment] !== undefined ? glucose[activeDate][moment] : '';
return; return;
} }
@ -2974,6 +3061,14 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const el = document.querySelector(`.glucose-inline[data-moment-key="${momentKey}"]`); const el = document.querySelector(`.glucose-inline[data-moment-key="${momentKey}"]`);
if (el) { if (el) {
el.outerHTML = renderInlineGlucoseItem(m); el.outerHTML = renderInlineGlucoseItem(m);
// Flash feedback on the new element
requestAnimationFrame(() => {
const newEl = document.querySelector(`.glucose-inline[data-moment-key="${momentKey}"]`);
if (newEl) {
newEl.classList.add('save-flash');
setTimeout(() => newEl.classList.remove('save-flash'), 600);
}
});
} }
} }
@ -3406,7 +3501,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</div> </div>
</div> </div>
<span class="eetmoment-item-kh">${formatKh(miKh)} g</span> <span class="eetmoment-item-kh">${formatKh(miKh)} g</span>
<button class="eetmoment-remove" data-moment="${moment.id}" data-entry="${i}" data-mi="${mi}" aria-label="Verwijderen"><i class="fas fa-xmark"></i></button> <button class="eetmoment-remove" data-haptic="strong" data-moment="${moment.id}" data-entry="${i}" data-mi="${mi}" aria-label="Verwijderen"><i class="fas fa-xmark"></i></button>
</div> </div>
`; `;
}).join(''); }).join('');
@ -3422,15 +3517,15 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
<span class="eetmoment-meal-kh">${formatKh(mealKh)} g</span> <span class="eetmoment-meal-kh">${formatKh(mealKh)} g</span>
<span style="font-size:0.7rem;color:var(--text-muted);"><i class="fas fa-chevron-up"></i></span> <span style="font-size:0.7rem;color:var(--text-muted);"><i class="fas fa-chevron-up"></i></span>
</div> </div>
<div class="eetmoment-meal-items-wrap">${mealItemsHtml}</div> <div class="eetmoment-meal-items-wrap collapsible">${mealItemsHtml}</div>
<div class="eetmoment-meal-actions"> <div class="eetmoment-meal-actions collapsible">
<button class="eetmoment-meal-action-btn" data-action="edit-porties" data-entry="${i}" data-moment="${moment.id}"> <button class="eetmoment-meal-action-btn" data-action="edit-porties" data-entry="${i}" data-moment="${moment.id}">
<i class="fas fa-pen"></i> Porties <i class="fas fa-pen"></i> Porties
</button> </button>
<button class="eetmoment-meal-action-btn" data-action="move-meal" data-entry="${i}" data-moment="${moment.id}"> <button class="eetmoment-meal-action-btn" data-action="move-meal" data-entry="${i}" data-moment="${moment.id}">
<i class="fas fa-arrow-right"></i> Verplaatsen <i class="fas fa-arrow-right"></i> Verplaatsen
</button> </button>
<button class="eetmoment-meal-action-btn eetmoment-delete" data-action="delete-meal" data-entry="${i}" data-moment="${moment.id}"> <button class="eetmoment-meal-action-btn eetmoment-delete" data-haptic="strong" data-action="delete-meal" data-entry="${i}" data-moment="${moment.id}">
<i class="fas fa-trash"></i> Verwijderen <i class="fas fa-trash"></i> Verwijderen
</button> </button>
</div> </div>
@ -3460,7 +3555,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</div> </div>
</div> </div>
<span class="eetmoment-item-kh">${formatKh(itemKh)} g</span> <span class="eetmoment-item-kh">${formatKh(itemKh)} g</span>
<button class="eetmoment-remove" data-moment="${moment.id}" data-index="${i}" aria-label="Verwijderen" title="Verwijderen"><i class="fas fa-xmark"></i></button> <button class="eetmoment-remove" data-haptic="strong" data-moment="${moment.id}" data-index="${i}" aria-label="Verwijderen" title="Verwijderen"><i class="fas fa-xmark"></i></button>
</li> </li>
`; `;
}).join(''); }).join('');
@ -3482,17 +3577,17 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
<span style="font-size:0.8rem;color:var(--text-muted)">${items.length > 0 ? '<i class="fas fa-chevron-down"></i>' : ''}</span> <span style="font-size:0.8rem;color:var(--text-muted)">${items.length > 0 ? '<i class="fas fa-chevron-down"></i>' : ''}</span>
</div> </div>
</div> </div>
${items.length > 0 ? `<ul class="eetmoment-items">${itemsHtml}</ul>` : ''} ${items.length > 0 ? `<ul class="eetmoment-items collapsible">${itemsHtml}</ul>` : ''}
${inlineGlucoseHtml} ${inlineGlucoseHtml}
<div class="eetmoment-add-btn" data-moment="${moment.id}"> <div class="eetmoment-add-btn" data-moment="${moment.id}">
<span>+</span> Voedingsmiddel toevoegen <span>+</span> Voedingsmiddel toevoegen
</div> </div>
${items.length > 1 ? ` ${items.length > 1 ? `
<div class="save-meal-footer" data-moment="${moment.id}"> <div class="save-meal-footer" data-moment="${moment.id}">
<button class="save-meal-footer-btn" data-moment="${moment.id}" style="display:none"> <button class="save-meal-footer-btn footer-hidden" data-moment="${moment.id}">
<i class="fas fa-star"></i> Selecteer ${items.length} items <i class="fas fa-star"></i> Selecteer ${items.length} items
</button> </button>
<button class="delete-selected-footer-btn hidden" data-moment="${moment.id}"> <button class="delete-selected-footer-btn footer-hidden" data-haptic="strong" data-moment="${moment.id}">
<i class="fas fa-trash"></i> Verwijder geselecteerde <i class="fas fa-trash"></i> Verwijder geselecteerde
</button> </button>
</div>` : ''} </div>` : ''}
@ -3502,7 +3597,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// Add event listeners — remove from diary // Add event listeners — remove from diary
listEl.querySelectorAll('.eetmoment-remove').forEach(btn => { listEl.querySelectorAll('.eetmoment-remove').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
const momentId = btn.dataset.moment; const momentId = btn.dataset.moment;
const entry = parseInt(btn.dataset.index); const entry = parseInt(btn.dataset.index);
@ -3510,9 +3605,11 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const mi = btn.dataset.mi; const mi = btn.dataset.mi;
if (mi !== undefined) { if (mi !== undefined) {
// Removing item from within a meal group // Removing item from within a meal group
removeMealItemFromDiary(activeDate, momentId, parseInt(groupIdx), parseInt(mi)); const itemEl = btn.closest('.eetmoment-meal-item');
await removeMealItemFromDiary(activeDate, momentId, parseInt(groupIdx), parseInt(mi), itemEl);
} else { } else {
removeFromMeal(activeDate, momentId, entry); const itemEl = btn.closest('.eetmoment-item');
await removeFromMeal(activeDate, momentId, entry, itemEl);
} }
}); });
}); });
@ -3543,10 +3640,10 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const card = header.closest('.eetmoment-card'); const card = header.closest('.eetmoment-card');
const itemsList = card.querySelector('.eetmoment-items'); const itemsList = card.querySelector('.eetmoment-items');
if (itemsList) { if (itemsList) {
itemsList.classList.toggle('hidden'); itemsList.classList.toggle('collapsed');
const arrow = header.querySelector('span:last-child'); const arrow = header.querySelector('span:last-child');
if (arrow) { if (arrow) {
arrow.innerHTML = itemsList.classList.contains('hidden') ? '<i class="fas fa-chevron-down"></i>' : '<i class="fas fa-chevron-up"></i>'; arrow.innerHTML = itemsList.classList.contains('collapsed') ? '<i class="fas fa-chevron-down"></i>' : '<i class="fas fa-chevron-up"></i>';
} }
} }
}); });
@ -3568,6 +3665,12 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// Checkbox change → update save/delete button state // Checkbox change → update save/delete button state
listEl.querySelectorAll('.item-checkbox').forEach(cb => { listEl.querySelectorAll('.item-checkbox').forEach(cb => {
cb.addEventListener('change', () => { cb.addEventListener('change', () => {
// Check animation on wrapper
const wrapper = cb.closest('.item-checkbox-wrapper');
if (wrapper && cb.checked) {
wrapper.classList.add('just-checked');
setTimeout(() => wrapper.classList.remove('just-checked'), 250);
}
const card = cb.closest('.eetmoment-card'); const card = cb.closest('.eetmoment-card');
if (!card) return; if (!card) return;
const footer = card.querySelector('.save-meal-footer'); const footer = card.querySelector('.save-meal-footer');
@ -3577,13 +3680,13 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const checked = card.querySelectorAll('.item-checkbox:checked').length; const checked = card.querySelectorAll('.item-checkbox:checked').length;
const total = card.querySelectorAll('.item-checkbox').length; const total = card.querySelectorAll('.item-checkbox').length;
if (checked < 2) { if (checked < 2) {
saveBtn.style.display = 'none'; saveBtn.classList.add('footer-hidden');
if (delBtn) delBtn.classList.add('hidden'); if (delBtn) delBtn.classList.add('footer-hidden');
} else { } else {
saveBtn.style.display = ''; saveBtn.classList.remove('footer-hidden');
saveBtn.disabled = false; saveBtn.disabled = false;
saveBtn.innerHTML = `<i class="fas fa-star"></i> Opslaan ${checked} items als maaltijd`; saveBtn.innerHTML = `<i class='fas fa-star'></i> Opslaan ${checked} items als maaltijd`;
if (delBtn) delBtn.classList.remove('hidden'); if (delBtn) delBtn.classList.remove('footer-hidden');
} }
}); });
}); });
@ -3783,6 +3886,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// Bounce feedback // Bounce feedback
this.classList.add('tapped'); this.classList.add('tapped');
setTimeout(() => this.classList.remove('tapped'), 300); setTimeout(() => this.classList.remove('tapped'), 300);
// Count bump animation
countEl.classList.add('count-bump');
setTimeout(() => countEl.classList.remove('count-bump'), 300);
}); });
stepper.querySelector('.portie-stepper-minus').addEventListener('click', function() { stepper.querySelector('.portie-stepper-minus').addEventListener('click', function() {
@ -3792,6 +3898,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// Bounce feedback // Bounce feedback
this.classList.add('tapped'); this.classList.add('tapped');
setTimeout(() => this.classList.remove('tapped'), 300); setTimeout(() => this.classList.remove('tapped'), 300);
// Count bump animation
countEl.classList.add('count-bump');
setTimeout(() => countEl.classList.remove('count-bump'), 300);
}); });
}); });
@ -3852,7 +3961,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
renderDagboek(); renderDagboek();
} }
async function removeFromMeal(dateStr, momentId, index) { async function removeFromMeal(dateStr, momentId, index, itemEl) {
const day = dagboek[dateStr]; const day = dagboek[dateStr];
if (day && day[momentId]) { if (day && day[momentId]) {
const target = day[momentId][index]; const target = day[momentId][index];
@ -3862,6 +3971,11 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
message: `Weet je zeker dat je "${name}" wilt verwijderen?` message: `Weet je zeker dat je "${name}" wilt verwijderen?`
}); });
if (!confirmed) return; if (!confirmed) return;
// Animate fade-out before removing data
if (itemEl) {
itemEl.classList.add('removing');
await new Promise(r => setTimeout(r, 280));
}
day[momentId].splice(index, 1); day[momentId].splice(index, 1);
saveToStorage(); saveToStorage();
renderDagboek(); renderDagboek();
@ -3873,7 +3987,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
* Remove a single item from within a meal group in the diary. * Remove a single item from within a meal group in the diary.
* If the last item is removed, removes the entire meal group. * If the last item is removed, removes the entire meal group.
*/ */
async function removeMealItemFromDiary(dateStr, momentId, entryIdx, mi) { async function removeMealItemFromDiary(dateStr, momentId, entryIdx, mi, itemEl) {
const day = dagboek[dateStr]; const day = dagboek[dateStr];
if (!day || !day[momentId]) return; if (!day || !day[momentId]) return;
const entry = day[momentId][entryIdx]; const entry = day[momentId][entryIdx];
@ -3884,6 +3998,11 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
message: `Weet je zeker dat je "${item.item.naam}" wilt verwijderen uit deze maaltijd?` message: `Weet je zeker dat je "${item.item.naam}" wilt verwijderen uit deze maaltijd?`
}); });
if (!confirmed) return; if (!confirmed) return;
// Animate fade-out before removing data
if (itemEl) {
itemEl.classList.add('removing');
await new Promise(r => setTimeout(r, 280));
}
entry.items.splice(mi, 1); entry.items.splice(mi, 1);
// If last item — remove the whole meal group entry // If last item — remove the whole meal group entry
if (entry.items.length === 0) { if (entry.items.length === 0) {
@ -3963,6 +4082,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
} }
overlay.querySelector('#portieEditClose').addEventListener('click', closeDialog); overlay.querySelector('#portieEditClose').addEventListener('click', closeDialog);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDialog(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDialog(); });
setupSwipeDown(overlay, closeDialog);
const input = overlay.querySelector('#portieEditInput'); const input = overlay.querySelector('#portieEditInput');
const preview = overlay.querySelector('#portieEditPreview'); const preview = overlay.querySelector('#portieEditPreview');
@ -4151,6 +4271,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
overlay.querySelector('#bulkEditClose').addEventListener('click', closeDialog); overlay.querySelector('#bulkEditClose').addEventListener('click', closeDialog);
overlay.querySelector('#bulkCancel').addEventListener('click', closeDialog); overlay.querySelector('#bulkCancel').addEventListener('click', closeDialog);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDialog(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDialog(); });
setupSwipeDown(overlay, closeDialog);
function collectPortions() { function collectPortions() {
const portions = []; const portions = [];
@ -4519,13 +4640,14 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const input = overlay.querySelector('#mealNameInput'); const input = overlay.querySelector('#mealNameInput');
function close() { function close() {
overlay.remove(); overlay.classList.add('closing');
document.body.style.overflow = ''; setTimeout(() => { overlay.remove(); document.body.style.overflow = ''; }, 280);
} }
function save() { function save() {
const name = input.value.trim(); const name = input.value.trim();
if (!name) { if (!name) {
hapticError();
input.style.borderColor = 'var(--danger)'; input.style.borderColor = 'var(--danger)';
input.focus(); input.focus();
return; return;
@ -4548,6 +4670,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
}); });
overlay.querySelector('#mealNameCancel').addEventListener('click', close); overlay.querySelector('#mealNameCancel').addEventListener('click', close);
overlay.querySelector('#mealNameSave').addEventListener('click', save); overlay.querySelector('#mealNameSave').addEventListener('click', save);
setupSwipeDown(overlay, close);
// Enter key saves, Escape closes // Enter key saves, Escape closes
input.addEventListener('keydown', (e) => { input.addEventListener('keydown', (e) => {
@ -4584,7 +4707,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
<div class="meal-card-name">${escapeHtml(meal.naam)}</div> <div class="meal-card-name">${escapeHtml(meal.naam)}</div>
<div class="meal-card-meta">${itemCount} item${itemCount !== 1 ? 's' : ''} · ${meal.khTotaal} g kh</div> <div class="meal-card-meta">${itemCount} item${itemCount !== 1 ? 's' : ''} · ${meal.khTotaal} g kh</div>
</div> </div>
<button class="meal-card-delete" data-idx="${idx}" title="Verwijderen"><i class="fas fa-trash"></i></button> <button class="meal-card-delete" data-haptic="strong" data-idx="${idx}" title="Verwijderen"><i class="fas fa-trash"></i></button>
</div> </div>
<div class="meal-card-add" data-idx="${idx}"> <div class="meal-card-add" data-idx="${idx}">
<i class="fas fa-plus-circle"></i> Toevoegen aan dagboek <i class="fas fa-plus-circle"></i> Toevoegen aan dagboek
@ -4675,16 +4798,15 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
// Close handlers // Close handlers
overlay.querySelector('.modal-close').addEventListener('click', () => { function closeAddToMeal() {
overlay.remove(); overlay.classList.add('closing');
document.body.style.overflow = ''; setTimeout(() => { overlay.remove(); document.body.style.overflow = ''; }, 280);
}); }
overlay.querySelector('.modal-close').addEventListener('click', closeAddToMeal);
overlay.addEventListener('click', (e) => { overlay.addEventListener('click', (e) => {
if (e.target === overlay) { if (e.target === overlay) closeAddToMeal();
overlay.remove();
document.body.style.overflow = '';
}
}); });
setupSwipeDown(overlay, closeAddToMeal);
// Date picker navigation (event delegation on overlay) // Date picker navigation (event delegation on overlay)
overlay.addEventListener('click', (e) => { overlay.addEventListener('click', (e) => {
@ -4798,6 +4920,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
if (portie > 0) newItems.push({ item: e.item, portie: portie }); if (portie > 0) newItems.push({ item: e.item, portie: portie });
}); });
if (newItems.length === 0) { if (newItems.length === 0) {
hapticError();
showToast('Een maaltijd moet minimaal 1 item hebben'); showToast('Een maaltijd moet minimaal 1 item hebben');
return; return;
} }
@ -4820,6 +4943,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
overlay.querySelector('#mealDiaryClose').addEventListener('click', close); overlay.querySelector('#mealDiaryClose').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
setupSwipeDown(overlay, close);
overlay.querySelectorAll('.meal-edit-item-remove').forEach(btn => { overlay.querySelectorAll('.meal-edit-item-remove').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@ -4844,6 +4968,91 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
}, 250); }, 250);
} }
/**
* Add swipe-down-to-close gesture to a bottom sheet overlay.
* @param {HTMLElement} overlay - The .modal-overlay element containing a .modal-sheet
* @param {Function} closeCallback - Called when swipe-down completes
*/
function setupSwipeDown(overlay, closeCallback) {
const sheet = overlay.querySelector('.modal-sheet');
if (!sheet) return;
let startY = 0;
let currentY = 0;
let isDragging = false;
let startTime = 0;
let hasSwiped = false;
function resetInlineStyles() {
sheet.style.transform = '';
sheet.style.transition = '';
overlay.style.opacity = '';
overlay.style.background = '';
overlay.style.transition = '';
}
sheet.addEventListener('touchstart', (e) => {
if (e.touches.length !== 1) return;
// Only initiate swipe-down when scrolled to top (native feel)
if (sheet.scrollTop > 0) return;
const touch = e.touches[0];
startY = touch.clientY;
currentY = startY;
isDragging = true;
hasSwiped = false;
startTime = Date.now();
sheet.style.transition = 'none';
overlay.style.transition = 'none';
}, { passive: true });
sheet.addEventListener('touchmove', (e) => {
if (!isDragging) return;
const touch = e.touches[0];
currentY = touch.clientY;
const deltaY = currentY - startY;
if (deltaY > 0) {
hasSwiped = true;
e.preventDefault();
// Follow finger, capped at 80% of viewport
const translateY = Math.min(deltaY, window.innerHeight * 0.8);
sheet.style.transform = `translateY(${translateY}px)`;
// Dim overlay as the sheet goes down
overlay.style.background = `rgba(0,0,0,${Math.max(0, 0.4 * (1 - translateY / window.innerHeight))})`;
}
}, { passive: false });
sheet.addEventListener('touchend', () => {
if (!isDragging) return;
isDragging = false;
if (!hasSwiped) return;
const deltaY = currentY - startY;
const elapsed = Date.now() - startTime || 1;
const velocity = deltaY / elapsed; // px/ms
if (deltaY > 80 || velocity > 0.5) {
// Swiped far enough — animate the rest of the way down and close
sheet.style.transition = 'transform 0.25s ease-in';
overlay.style.transition = 'opacity 0.2s ease, background 0.25s ease';
sheet.style.transform = `translateY(${sheet.offsetHeight}px)`;
overlay.style.opacity = '0';
setTimeout(() => {
resetInlineStyles();
closeCallback();
}, 260);
} else {
// Not enough swipe — snap back
sheet.style.transition = 'transform 0.3s cubic-bezier(0.16, 1, 0.3, 1)';
overlay.style.transition = 'background 0.3s ease';
sheet.style.transform = '';
overlay.style.background = '';
setTimeout(() => {
overlay.style.transition = '';
}, 350);
}
}, { passive: true });
}
function showToast(message) { function showToast(message) {
const container = document.getElementById('toastContainer'); const container = document.getElementById('toastContainer');
const toast = document.createElement('div'); const toast = document.createElement('div');
@ -4886,7 +5095,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</div> </div>
<div style="display:flex;gap:10px;padding:0 20px 20px;"> <div style="display:flex;gap:10px;padding:0 20px 20px;">
<button class="btn-cancel" style="flex:1;" id="confirmNo">Nee</button> <button class="btn-cancel" style="flex:1;" id="confirmNo">Nee</button>
<button class="btn-danger-outline" style="flex:1;" id="confirmYes">${escapeHtml(confirmText)}</button> <button class="btn-danger-outline" data-haptic="strong" style="flex:1;" id="confirmYes">${escapeHtml(confirmText)}</button>
</div> </div>
</div> </div>
`; `;
@ -4900,6 +5109,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
overlay.querySelector('#confirmNo').addEventListener('click', () => { close(); resolve(false); }); overlay.querySelector('#confirmNo').addEventListener('click', () => { close(); resolve(false); });
overlay.querySelector('#confirmYes').addEventListener('click', () => { close(); resolve(true); }); overlay.querySelector('#confirmYes').addEventListener('click', () => { close(); resolve(true); });
overlay.addEventListener('click', (e) => { if (e.target === overlay) { close(); resolve(false); } }); overlay.addEventListener('click', (e) => { if (e.target === overlay) { close(); resolve(false); } });
setupSwipeDown(overlay, () => { close(); resolve(false); });
}); });
} }
@ -4938,6 +5148,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
overlay.addEventListener('click', (e) => { overlay.addEventListener('click', (e) => {
if (e.target === overlay) close(); if (e.target === overlay) close();
}); });
setupSwipeDown(overlay, close);
} }
function showNameInputModal(title, defaultValue, onSave) { function showNameInputModal(title, defaultValue, onSave) {
@ -4972,6 +5183,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
overlay.querySelector('#nameInputCancel').addEventListener('click', close); overlay.querySelector('#nameInputCancel').addEventListener('click', close);
overlay.querySelector('#nameInputSave').addEventListener('click', submit); overlay.querySelector('#nameInputSave').addEventListener('click', submit);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
setupSwipeDown(overlay, close);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
setTimeout(() => { input.focus(); input.select(); }, 100); setTimeout(() => { input.focus(); input.select(); }, 100);
} }
@ -4994,7 +5206,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
<input type="number" class="portion-input" value="${entry.portie}" min="1" max="9999" data-item-idx="${i}" style="width:72px;min-width:72px;"> <input type="number" class="portion-input" value="${entry.portie}" min="1" max="9999" data-item-idx="${i}" style="width:72px;min-width:72px;">
<span class="portion-unit">g</span> <span class="portion-unit">g</span>
</div> </div>
<button class="meal-edit-item-remove" data-item-idx="${i}" title="Verwijderen"><i class="fas fa-xmark"></i></button> <button class="meal-edit-item-remove" data-haptic="strong" data-item-idx="${i}" title="Verwijderen"><i class="fas fa-xmark"></i></button>
</div> </div>
</div> </div>
`; `;
@ -5018,7 +5230,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</button> </button>
<div class="meal-edit-actions"> <div class="meal-edit-actions">
<button class="btn-save" id="mealEditSave">Wijzigingen opslaan</button> <button class="btn-save" id="mealEditSave">Wijzigingen opslaan</button>
<button class="btn-danger-outline" id="mealEditDelete">Maaltijd verwijderen</button> <button class="btn-danger-outline" data-haptic="strong" id="mealEditDelete">Maaltijd verwijderen</button>
</div> </div>
</div> </div>
</div> </div>
@ -5045,6 +5257,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
} }
}); });
if (newItems.length === 0) { if (newItems.length === 0) {
hapticError();
showToast('Een maaltijd moet minimaal 1 item hebben'); showToast('Een maaltijd moet minimaal 1 item hebben');
return; return;
} }
@ -5071,6 +5284,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
overlay.querySelector('#mealEditClose').addEventListener('click', close); overlay.querySelector('#mealEditClose').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
setupSwipeDown(overlay, close);
overlay.querySelectorAll('.meal-edit-item-remove').forEach(btn => { overlay.querySelectorAll('.meal-edit-item-remove').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@ -5104,9 +5318,13 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</div> </div>
`; `;
document.body.appendChild(searchOverlay); document.body.appendChild(searchOverlay);
function closeSearch() { searchOverlay.remove(); } function closeSearch() {
searchOverlay.classList.add('closing');
setTimeout(() => searchOverlay.remove(), 280);
}
searchOverlay.querySelector('.modal-close').addEventListener('click', closeSearch); searchOverlay.querySelector('.modal-close').addEventListener('click', closeSearch);
searchOverlay.addEventListener('click', (e) => { if (e.target === searchOverlay) closeSearch(); }); searchOverlay.addEventListener('click', (e) => { if (e.target === searchOverlay) closeSearch(); });
setupSwipeDown(searchOverlay, closeSearch);
const searchInput = searchOverlay.querySelector('#mealItemSearch'); const searchInput = searchOverlay.querySelector('#mealItemSearch');
const resultsEl = searchOverlay.querySelector('#mealSearchResults'); const resultsEl = searchOverlay.querySelector('#mealSearchResults');
searchInput.addEventListener('input', () => { searchInput.addEventListener('input', () => {
@ -5187,6 +5405,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
po.querySelector('#mealPortionClose').addEventListener('click', closePicker); po.querySelector('#mealPortionClose').addEventListener('click', closePicker);
po.querySelector('#mealPortionCancel').addEventListener('click', closePicker); po.querySelector('#mealPortionCancel').addEventListener('click', closePicker);
po.addEventListener('click', (e) => { if (e.target === po) closePicker(); }); po.addEventListener('click', (e) => { if (e.target === po) closePicker(); });
setupSwipeDown(po, closePicker);
const input = po.querySelector('#mealPortionInput'); const input = po.querySelector('#mealPortionInput');
const preview = po.querySelector('#mealPortionPreview'); const preview = po.querySelector('#mealPortionPreview');
input.addEventListener('input', () => { input.addEventListener('input', () => {
@ -5227,6 +5446,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
po.querySelector('#mealPortionConfirm').addEventListener('click', () => { po.querySelector('#mealPortionConfirm').addEventListener('click', () => {
const portie = parseFloat(input.value) || 0; const portie = parseFloat(input.value) || 0;
if (portie <= 0) { if (portie <= 0) {
hapticError();
input.focus(); input.style.borderColor = 'var(--danger)'; input.focus(); input.style.borderColor = 'var(--danger)';
setTimeout(() => { input.style.borderColor = ''; }, 1500); setTimeout(() => { input.style.borderColor = ''; }, 1500);
return; return;
@ -5402,6 +5622,10 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
if (e.target === document.getElementById('addMealModal')) closeModal('addMealModal'); if (e.target === document.getElementById('addMealModal')) closeModal('addMealModal');
}); });
// Swipe-down to close for static modals
setupSwipeDown(document.getElementById('detailModal'), () => closeModal('detailModal'));
setupSwipeDown(document.getElementById('addMealModal'), () => closeModal('addMealModal'));
// Settings button & modal // Settings button & modal
document.getElementById('settingsBtn').addEventListener('click', () => { document.getElementById('settingsBtn').addEventListener('click', () => {
// Populate with current limit // Populate with current limit
@ -5416,6 +5640,8 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
if (e.target === document.getElementById('settingsModal')) closeModal('settingsModal'); if (e.target === document.getElementById('settingsModal')) closeModal('settingsModal');
}); });
setupSwipeDown(document.getElementById('settingsModal'), () => closeModal('settingsModal'));
document.getElementById('saveCarbLimitBtn').addEventListener('click', () => { document.getElementById('saveCarbLimitBtn').addEventListener('click', () => {
const input = document.getElementById('carbLimitInput'); const input = document.getElementById('carbLimitInput');
const val = parseInt(input.value, 10); const val = parseInt(input.value, 10);
@ -5484,11 +5710,11 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const wrap = group.querySelector('.eetmoment-meal-items-wrap'); const wrap = group.querySelector('.eetmoment-meal-items-wrap');
const actions = group.querySelector('.eetmoment-meal-actions'); const actions = group.querySelector('.eetmoment-meal-actions');
if (wrap) { if (wrap) {
wrap.classList.toggle('hidden'); wrap.classList.toggle('collapsed');
if (actions) actions.classList.toggle('hidden'); if (actions) actions.classList.toggle('collapsed');
const arrow = btn.querySelector('span:last-child i'); const arrow = btn.querySelector('span:last-child i');
if (arrow) { if (arrow) {
arrow.className = wrap.classList.contains('hidden') ? 'fas fa-chevron-down' : 'fas fa-chevron-up'; arrow.className = wrap.classList.contains('collapsed') ? 'fas fa-chevron-down' : 'fas fa-chevron-up';
} }
} }
} }
@ -5549,6 +5775,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
renderDagboek(); renderDagboek();
}); });
overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); }); overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); });
setupSwipeDown(overlay, close);
} }
}); });
} }
@ -5556,17 +5783,29 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// Start the app // Start the app
// Track last click position for toast positioning + haptic feedback // Track last click position for toast positioning + haptic feedback
// Supports data-haptic="strong" (18ms) for destructive actions
(function setupHaptics() { (function setupHaptics() {
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
lastClickY = e.clientY; lastClickY = e.clientY;
if (typeof navigator !== 'undefined' && navigator.vibrate) { if (typeof navigator !== 'undefined' && navigator.vibrate) {
if (e.target.tagName === 'BUTTON' || e.target.closest('button')) { const btn = e.target.tagName === 'BUTTON' ? e.target : e.target.closest('button');
navigator.vibrate(8); if (btn) {
const strength = btn.dataset.haptic || (btn.closest('[data-haptic]') && btn.closest('[data-haptic]').dataset.haptic) || 'normal';
navigator.vibrate(strength === 'strong' ? 18 : 8);
} }
} }
}, { passive: true }); }, { passive: true });
})(); })();
/**
* Short double-buzz on validation errors (empty name, invalid portion, etc.)
*/
function hapticError() {
if (typeof navigator !== 'undefined' && navigator.vibrate) {
navigator.vibrate([30, 50, 30]);
}
}
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
} else { } else {

View file

@ -739,11 +739,15 @@ body {
background: var(--primary-light); background: var(--primary-light);
} }
.meal-group-items { .meal-group-items {
display: none; max-height: 0;
opacity: 0;
overflow: hidden;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
} }
.meal-group-header.expanded + .meal-group-items { .meal-group-header.expanded + .meal-group-items {
display: block; max-height: 2000px;
opacity: 1;
} }
.meal-sub-item { .meal-sub-item {
padding-left: 36px; padding-left: 36px;
@ -839,7 +843,8 @@ body {
} }
.eetmoment-items { .eetmoment-items {
padding: 4px 0; --collapsible-pt: 4px;
--collapsible-pb: 4px;
} }
.eetmoment-item { .eetmoment-item {
@ -1282,11 +1287,23 @@ body {
font-size: var(--font-sm); font-size: var(--font-sm);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.15s, opacity 0.15s; transition: background 0.15s, opacity 0.3s ease, max-height 0.3s ease, padding 0.3s ease, margin 0.3s ease, border-width 0.3s ease;
text-align: center; text-align: center;
font-family: var(--body-font); font-family: var(--body-font);
min-height: var(--touch-min); min-height: var(--touch-min);
line-height: 1.3; line-height: 1.3;
max-height: 80px;
overflow: hidden;
}
.save-meal-footer-btn.footer-hidden {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
margin-top: 0;
margin-bottom: 0;
border-width: 0;
pointer-events: none;
} }
.save-meal-footer-btn:hover { .save-meal-footer-btn:hover {
background: var(--primary); background: var(--primary);
@ -1339,11 +1356,13 @@ body {
font-size: var(--font-sm); font-size: var(--font-sm);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.15s, opacity 0.15s; transition: background 0.15s, opacity 0.3s ease, max-height 0.3s ease, padding 0.3s ease, margin 0.3s ease, border-width 0.3s ease;
text-align: center; text-align: center;
font-family: var(--body-font); font-family: var(--body-font);
min-height: var(--touch-min); min-height: var(--touch-min);
line-height: 1.3; line-height: 1.3;
max-height: 80px;
overflow: hidden;
} }
.delete-selected-footer-btn:hover { .delete-selected-footer-btn:hover {
background: var(--danger); background: var(--danger);
@ -1353,8 +1372,17 @@ body {
background: #B71C1C; background: #B71C1C;
color: var(--white); color: var(--white);
} }
.delete-selected-footer-btn.hidden { .delete-selected-footer-btn.hidden,
display: none; .delete-selected-footer-btn.footer-hidden {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
margin-top: 0;
margin-bottom: 0;
border-width: 0;
pointer-events: none;
overflow: hidden;
} }
/* ===== Diary — nested meal groups ===== */ /* ===== Diary — nested meal groups ===== */
@ -1391,7 +1419,8 @@ body {
} }
.eetmoment-meal-items-wrap { .eetmoment-meal-items-wrap {
border-top: 1px solid rgba(232, 183, 48, 0.2); border-top: 1px solid rgba(232, 183, 48, 0.2);
padding: 2px 0; --collapsible-pt: 2px;
--collapsible-pb: 2px;
} }
.eetmoment-meal-item { .eetmoment-meal-item {
display: flex; display: flex;
@ -2185,6 +2214,11 @@ body {
@keyframes slideOutRight{ from { transform: translateX(0); opacity: 1; } to { transform: translateX(120%); opacity: 0; } } @keyframes slideOutRight{ from { transform: translateX(0); opacity: 1; } to { transform: translateX(120%); opacity: 0; } }
@keyframes scaleIn { from { transform: scale(0.85); opacity: 0; } to { transform: scale(1); opacity: 1; } } @keyframes scaleIn { from { transform: scale(0.85); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.12); } } @keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.12); } }
@keyframes pulseCount { 0%,100% { transform: scale(1); } 50% { transform: scale(1.3); color: var(--primary); } }
@keyframes fadeInDown { from { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; } to { opacity: 1; max-height: 800px; } }
@keyframes fadeOutUp { from { opacity: 1; max-height: 800px; } to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; } }
@keyframes checkPop { 0% { transform: scale(1); } 50% { transform: scale(1.3); } 100% { transform: scale(1); } }
@keyframes saveFlash { 0% { box-shadow: 0 0 0 0 rgba(56,161,105,0.4); } 70% { box-shadow: 0 0 0 12px rgba(56,161,105,0); } 100% { box-shadow: 0 0 0 0 rgba(56,161,105,0); } }
@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
/* -- Button press -- */ /* -- Button press -- */
@ -2284,6 +2318,58 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite; animation: shimmer 1.5s ease-in-out infinite;
} }
/* ===== Collapsible sections — max-height transition ===== */
.collapsible {
overflow: hidden;
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease, padding 0.3s ease, margin 0.3s ease;
max-height: 2000px;
opacity: 1;
padding-top: var(--collapsible-pt, 4px);
padding-bottom: var(--collapsible-pb, 4px);
margin-top: var(--collapsible-mt, 0);
margin-bottom: var(--collapsible-mb, 0);
}
.collapsible.collapsed {
max-height: 0 !important;
opacity: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
overflow: hidden !important;
}
/* ===== Item delete fade-out ===== */
.removing {
animation: fadeOutUp 0.3s ease forwards;
overflow: hidden;
pointer-events: none;
}
/* ===== Footer buttons: slide in/out ===== */
.footer-btn-visible {
animation: fadeInDown 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.footer-btn-hidden {
animation: fadeOutUp 0.25s ease forwards;
pointer-events: none;
}
/* ===== Checkbox check animation ===== */
.item-checkbox-wrapper.just-checked {
animation: checkPop 0.25s ease;
}
/* ===== Glucose save flash ===== */
.glucose-inline.save-flash {
animation: saveFlash 0.6s ease;
}
/* ===== Stepper count bump ===== */
.portie-stepper-count.count-bump {
animation: pulseCount 0.3s ease;
}
</style> </style>
</head> </head>
<body> <body>
@ -2940,6 +3026,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// Validate // Validate
if (val !== null && (isNaN(val) || val < 0 || val > 30)) { if (val !== null && (isNaN(val) || val < 0 || val > 30)) {
hapticError();
input.value = glucose[activeDate][moment] !== null && glucose[activeDate][moment] !== undefined ? glucose[activeDate][moment] : ''; input.value = glucose[activeDate][moment] !== null && glucose[activeDate][moment] !== undefined ? glucose[activeDate][moment] : '';
return; return;
} }
@ -2974,6 +3061,14 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const el = document.querySelector(`.glucose-inline[data-moment-key="${momentKey}"]`); const el = document.querySelector(`.glucose-inline[data-moment-key="${momentKey}"]`);
if (el) { if (el) {
el.outerHTML = renderInlineGlucoseItem(m); el.outerHTML = renderInlineGlucoseItem(m);
// Flash feedback on the new element
requestAnimationFrame(() => {
const newEl = document.querySelector(`.glucose-inline[data-moment-key="${momentKey}"]`);
if (newEl) {
newEl.classList.add('save-flash');
setTimeout(() => newEl.classList.remove('save-flash'), 600);
}
});
} }
} }
@ -3406,7 +3501,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</div> </div>
</div> </div>
<span class="eetmoment-item-kh">${formatKh(miKh)} g</span> <span class="eetmoment-item-kh">${formatKh(miKh)} g</span>
<button class="eetmoment-remove" data-moment="${moment.id}" data-entry="${i}" data-mi="${mi}" aria-label="Verwijderen"><i class="fas fa-xmark"></i></button> <button class="eetmoment-remove" data-haptic="strong" data-moment="${moment.id}" data-entry="${i}" data-mi="${mi}" aria-label="Verwijderen"><i class="fas fa-xmark"></i></button>
</div> </div>
`; `;
}).join(''); }).join('');
@ -3422,15 +3517,15 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
<span class="eetmoment-meal-kh">${formatKh(mealKh)} g</span> <span class="eetmoment-meal-kh">${formatKh(mealKh)} g</span>
<span style="font-size:0.7rem;color:var(--text-muted);"><i class="fas fa-chevron-up"></i></span> <span style="font-size:0.7rem;color:var(--text-muted);"><i class="fas fa-chevron-up"></i></span>
</div> </div>
<div class="eetmoment-meal-items-wrap">${mealItemsHtml}</div> <div class="eetmoment-meal-items-wrap collapsible">${mealItemsHtml}</div>
<div class="eetmoment-meal-actions"> <div class="eetmoment-meal-actions collapsible">
<button class="eetmoment-meal-action-btn" data-action="edit-porties" data-entry="${i}" data-moment="${moment.id}"> <button class="eetmoment-meal-action-btn" data-action="edit-porties" data-entry="${i}" data-moment="${moment.id}">
<i class="fas fa-pen"></i> Porties <i class="fas fa-pen"></i> Porties
</button> </button>
<button class="eetmoment-meal-action-btn" data-action="move-meal" data-entry="${i}" data-moment="${moment.id}"> <button class="eetmoment-meal-action-btn" data-action="move-meal" data-entry="${i}" data-moment="${moment.id}">
<i class="fas fa-arrow-right"></i> Verplaatsen <i class="fas fa-arrow-right"></i> Verplaatsen
</button> </button>
<button class="eetmoment-meal-action-btn eetmoment-delete" data-action="delete-meal" data-entry="${i}" data-moment="${moment.id}"> <button class="eetmoment-meal-action-btn eetmoment-delete" data-haptic="strong" data-action="delete-meal" data-entry="${i}" data-moment="${moment.id}">
<i class="fas fa-trash"></i> Verwijderen <i class="fas fa-trash"></i> Verwijderen
</button> </button>
</div> </div>
@ -3460,7 +3555,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</div> </div>
</div> </div>
<span class="eetmoment-item-kh">${formatKh(itemKh)} g</span> <span class="eetmoment-item-kh">${formatKh(itemKh)} g</span>
<button class="eetmoment-remove" data-moment="${moment.id}" data-index="${i}" aria-label="Verwijderen" title="Verwijderen"><i class="fas fa-xmark"></i></button> <button class="eetmoment-remove" data-haptic="strong" data-moment="${moment.id}" data-index="${i}" aria-label="Verwijderen" title="Verwijderen"><i class="fas fa-xmark"></i></button>
</li> </li>
`; `;
}).join(''); }).join('');
@ -3482,17 +3577,17 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
<span style="font-size:0.8rem;color:var(--text-muted)">${items.length > 0 ? '<i class="fas fa-chevron-down"></i>' : ''}</span> <span style="font-size:0.8rem;color:var(--text-muted)">${items.length > 0 ? '<i class="fas fa-chevron-down"></i>' : ''}</span>
</div> </div>
</div> </div>
${items.length > 0 ? `<ul class="eetmoment-items">${itemsHtml}</ul>` : ''} ${items.length > 0 ? `<ul class="eetmoment-items collapsible">${itemsHtml}</ul>` : ''}
${inlineGlucoseHtml} ${inlineGlucoseHtml}
<div class="eetmoment-add-btn" data-moment="${moment.id}"> <div class="eetmoment-add-btn" data-moment="${moment.id}">
<span>+</span> Voedingsmiddel toevoegen <span>+</span> Voedingsmiddel toevoegen
</div> </div>
${items.length > 1 ? ` ${items.length > 1 ? `
<div class="save-meal-footer" data-moment="${moment.id}"> <div class="save-meal-footer" data-moment="${moment.id}">
<button class="save-meal-footer-btn" data-moment="${moment.id}" style="display:none"> <button class="save-meal-footer-btn footer-hidden" data-moment="${moment.id}">
<i class="fas fa-star"></i> Selecteer ${items.length} items <i class="fas fa-star"></i> Selecteer ${items.length} items
</button> </button>
<button class="delete-selected-footer-btn hidden" data-moment="${moment.id}"> <button class="delete-selected-footer-btn footer-hidden" data-haptic="strong" data-moment="${moment.id}">
<i class="fas fa-trash"></i> Verwijder geselecteerde <i class="fas fa-trash"></i> Verwijder geselecteerde
</button> </button>
</div>` : ''} </div>` : ''}
@ -3502,7 +3597,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// Add event listeners — remove from diary // Add event listeners — remove from diary
listEl.querySelectorAll('.eetmoment-remove').forEach(btn => { listEl.querySelectorAll('.eetmoment-remove').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
const momentId = btn.dataset.moment; const momentId = btn.dataset.moment;
const entry = parseInt(btn.dataset.index); const entry = parseInt(btn.dataset.index);
@ -3510,9 +3605,11 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const mi = btn.dataset.mi; const mi = btn.dataset.mi;
if (mi !== undefined) { if (mi !== undefined) {
// Removing item from within a meal group // Removing item from within a meal group
removeMealItemFromDiary(activeDate, momentId, parseInt(groupIdx), parseInt(mi)); const itemEl = btn.closest('.eetmoment-meal-item');
await removeMealItemFromDiary(activeDate, momentId, parseInt(groupIdx), parseInt(mi), itemEl);
} else { } else {
removeFromMeal(activeDate, momentId, entry); const itemEl = btn.closest('.eetmoment-item');
await removeFromMeal(activeDate, momentId, entry, itemEl);
} }
}); });
}); });
@ -3543,10 +3640,10 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const card = header.closest('.eetmoment-card'); const card = header.closest('.eetmoment-card');
const itemsList = card.querySelector('.eetmoment-items'); const itemsList = card.querySelector('.eetmoment-items');
if (itemsList) { if (itemsList) {
itemsList.classList.toggle('hidden'); itemsList.classList.toggle('collapsed');
const arrow = header.querySelector('span:last-child'); const arrow = header.querySelector('span:last-child');
if (arrow) { if (arrow) {
arrow.innerHTML = itemsList.classList.contains('hidden') ? '<i class="fas fa-chevron-down"></i>' : '<i class="fas fa-chevron-up"></i>'; arrow.innerHTML = itemsList.classList.contains('collapsed') ? '<i class="fas fa-chevron-down"></i>' : '<i class="fas fa-chevron-up"></i>';
} }
} }
}); });
@ -3568,6 +3665,12 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// Checkbox change → update save/delete button state // Checkbox change → update save/delete button state
listEl.querySelectorAll('.item-checkbox').forEach(cb => { listEl.querySelectorAll('.item-checkbox').forEach(cb => {
cb.addEventListener('change', () => { cb.addEventListener('change', () => {
// Check animation on wrapper
const wrapper = cb.closest('.item-checkbox-wrapper');
if (wrapper && cb.checked) {
wrapper.classList.add('just-checked');
setTimeout(() => wrapper.classList.remove('just-checked'), 250);
}
const card = cb.closest('.eetmoment-card'); const card = cb.closest('.eetmoment-card');
if (!card) return; if (!card) return;
const footer = card.querySelector('.save-meal-footer'); const footer = card.querySelector('.save-meal-footer');
@ -3577,13 +3680,13 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const checked = card.querySelectorAll('.item-checkbox:checked').length; const checked = card.querySelectorAll('.item-checkbox:checked').length;
const total = card.querySelectorAll('.item-checkbox').length; const total = card.querySelectorAll('.item-checkbox').length;
if (checked < 2) { if (checked < 2) {
saveBtn.style.display = 'none'; saveBtn.classList.add('footer-hidden');
if (delBtn) delBtn.classList.add('hidden'); if (delBtn) delBtn.classList.add('footer-hidden');
} else { } else {
saveBtn.style.display = ''; saveBtn.classList.remove('footer-hidden');
saveBtn.disabled = false; saveBtn.disabled = false;
saveBtn.innerHTML = `<i class="fas fa-star"></i> Opslaan ${checked} items als maaltijd`; saveBtn.innerHTML = `<i class='fas fa-star'></i> Opslaan ${checked} items als maaltijd`;
if (delBtn) delBtn.classList.remove('hidden'); if (delBtn) delBtn.classList.remove('footer-hidden');
} }
}); });
}); });
@ -3783,6 +3886,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// Bounce feedback // Bounce feedback
this.classList.add('tapped'); this.classList.add('tapped');
setTimeout(() => this.classList.remove('tapped'), 300); setTimeout(() => this.classList.remove('tapped'), 300);
// Count bump animation
countEl.classList.add('count-bump');
setTimeout(() => countEl.classList.remove('count-bump'), 300);
}); });
stepper.querySelector('.portie-stepper-minus').addEventListener('click', function() { stepper.querySelector('.portie-stepper-minus').addEventListener('click', function() {
@ -3792,6 +3898,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// Bounce feedback // Bounce feedback
this.classList.add('tapped'); this.classList.add('tapped');
setTimeout(() => this.classList.remove('tapped'), 300); setTimeout(() => this.classList.remove('tapped'), 300);
// Count bump animation
countEl.classList.add('count-bump');
setTimeout(() => countEl.classList.remove('count-bump'), 300);
}); });
}); });
@ -3852,7 +3961,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
renderDagboek(); renderDagboek();
} }
async function removeFromMeal(dateStr, momentId, index) { async function removeFromMeal(dateStr, momentId, index, itemEl) {
const day = dagboek[dateStr]; const day = dagboek[dateStr];
if (day && day[momentId]) { if (day && day[momentId]) {
const target = day[momentId][index]; const target = day[momentId][index];
@ -3862,6 +3971,11 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
message: `Weet je zeker dat je "${name}" wilt verwijderen?` message: `Weet je zeker dat je "${name}" wilt verwijderen?`
}); });
if (!confirmed) return; if (!confirmed) return;
// Animate fade-out before removing data
if (itemEl) {
itemEl.classList.add('removing');
await new Promise(r => setTimeout(r, 280));
}
day[momentId].splice(index, 1); day[momentId].splice(index, 1);
saveToStorage(); saveToStorage();
renderDagboek(); renderDagboek();
@ -3873,7 +3987,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
* Remove a single item from within a meal group in the diary. * Remove a single item from within a meal group in the diary.
* If the last item is removed, removes the entire meal group. * If the last item is removed, removes the entire meal group.
*/ */
async function removeMealItemFromDiary(dateStr, momentId, entryIdx, mi) { async function removeMealItemFromDiary(dateStr, momentId, entryIdx, mi, itemEl) {
const day = dagboek[dateStr]; const day = dagboek[dateStr];
if (!day || !day[momentId]) return; if (!day || !day[momentId]) return;
const entry = day[momentId][entryIdx]; const entry = day[momentId][entryIdx];
@ -3884,6 +3998,11 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
message: `Weet je zeker dat je "${item.item.naam}" wilt verwijderen uit deze maaltijd?` message: `Weet je zeker dat je "${item.item.naam}" wilt verwijderen uit deze maaltijd?`
}); });
if (!confirmed) return; if (!confirmed) return;
// Animate fade-out before removing data
if (itemEl) {
itemEl.classList.add('removing');
await new Promise(r => setTimeout(r, 280));
}
entry.items.splice(mi, 1); entry.items.splice(mi, 1);
// If last item — remove the whole meal group entry // If last item — remove the whole meal group entry
if (entry.items.length === 0) { if (entry.items.length === 0) {
@ -3963,6 +4082,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
} }
overlay.querySelector('#portieEditClose').addEventListener('click', closeDialog); overlay.querySelector('#portieEditClose').addEventListener('click', closeDialog);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDialog(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDialog(); });
setupSwipeDown(overlay, closeDialog);
const input = overlay.querySelector('#portieEditInput'); const input = overlay.querySelector('#portieEditInput');
const preview = overlay.querySelector('#portieEditPreview'); const preview = overlay.querySelector('#portieEditPreview');
@ -4151,6 +4271,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
overlay.querySelector('#bulkEditClose').addEventListener('click', closeDialog); overlay.querySelector('#bulkEditClose').addEventListener('click', closeDialog);
overlay.querySelector('#bulkCancel').addEventListener('click', closeDialog); overlay.querySelector('#bulkCancel').addEventListener('click', closeDialog);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDialog(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDialog(); });
setupSwipeDown(overlay, closeDialog);
function collectPortions() { function collectPortions() {
const portions = []; const portions = [];
@ -4519,13 +4640,14 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const input = overlay.querySelector('#mealNameInput'); const input = overlay.querySelector('#mealNameInput');
function close() { function close() {
overlay.remove(); overlay.classList.add('closing');
document.body.style.overflow = ''; setTimeout(() => { overlay.remove(); document.body.style.overflow = ''; }, 280);
} }
function save() { function save() {
const name = input.value.trim(); const name = input.value.trim();
if (!name) { if (!name) {
hapticError();
input.style.borderColor = 'var(--danger)'; input.style.borderColor = 'var(--danger)';
input.focus(); input.focus();
return; return;
@ -4548,6 +4670,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
}); });
overlay.querySelector('#mealNameCancel').addEventListener('click', close); overlay.querySelector('#mealNameCancel').addEventListener('click', close);
overlay.querySelector('#mealNameSave').addEventListener('click', save); overlay.querySelector('#mealNameSave').addEventListener('click', save);
setupSwipeDown(overlay, close);
// Enter key saves, Escape closes // Enter key saves, Escape closes
input.addEventListener('keydown', (e) => { input.addEventListener('keydown', (e) => {
@ -4584,7 +4707,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
<div class="meal-card-name">${escapeHtml(meal.naam)}</div> <div class="meal-card-name">${escapeHtml(meal.naam)}</div>
<div class="meal-card-meta">${itemCount} item${itemCount !== 1 ? 's' : ''} · ${meal.khTotaal} g kh</div> <div class="meal-card-meta">${itemCount} item${itemCount !== 1 ? 's' : ''} · ${meal.khTotaal} g kh</div>
</div> </div>
<button class="meal-card-delete" data-idx="${idx}" title="Verwijderen"><i class="fas fa-trash"></i></button> <button class="meal-card-delete" data-haptic="strong" data-idx="${idx}" title="Verwijderen"><i class="fas fa-trash"></i></button>
</div> </div>
<div class="meal-card-add" data-idx="${idx}"> <div class="meal-card-add" data-idx="${idx}">
<i class="fas fa-plus-circle"></i> Toevoegen aan dagboek <i class="fas fa-plus-circle"></i> Toevoegen aan dagboek
@ -4675,16 +4798,15 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
// Close handlers // Close handlers
overlay.querySelector('.modal-close').addEventListener('click', () => { function closeAddToMeal() {
overlay.remove(); overlay.classList.add('closing');
document.body.style.overflow = ''; setTimeout(() => { overlay.remove(); document.body.style.overflow = ''; }, 280);
}); }
overlay.querySelector('.modal-close').addEventListener('click', closeAddToMeal);
overlay.addEventListener('click', (e) => { overlay.addEventListener('click', (e) => {
if (e.target === overlay) { if (e.target === overlay) closeAddToMeal();
overlay.remove();
document.body.style.overflow = '';
}
}); });
setupSwipeDown(overlay, closeAddToMeal);
// Date picker navigation (event delegation on overlay) // Date picker navigation (event delegation on overlay)
overlay.addEventListener('click', (e) => { overlay.addEventListener('click', (e) => {
@ -4798,6 +4920,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
if (portie > 0) newItems.push({ item: e.item, portie: portie }); if (portie > 0) newItems.push({ item: e.item, portie: portie });
}); });
if (newItems.length === 0) { if (newItems.length === 0) {
hapticError();
showToast('Een maaltijd moet minimaal 1 item hebben'); showToast('Een maaltijd moet minimaal 1 item hebben');
return; return;
} }
@ -4820,6 +4943,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
overlay.querySelector('#mealDiaryClose').addEventListener('click', close); overlay.querySelector('#mealDiaryClose').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
setupSwipeDown(overlay, close);
overlay.querySelectorAll('.meal-edit-item-remove').forEach(btn => { overlay.querySelectorAll('.meal-edit-item-remove').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@ -4844,6 +4968,91 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
}, 250); }, 250);
} }
/**
* Add swipe-down-to-close gesture to a bottom sheet overlay.
* @param {HTMLElement} overlay - The .modal-overlay element containing a .modal-sheet
* @param {Function} closeCallback - Called when swipe-down completes
*/
function setupSwipeDown(overlay, closeCallback) {
const sheet = overlay.querySelector('.modal-sheet');
if (!sheet) return;
let startY = 0;
let currentY = 0;
let isDragging = false;
let startTime = 0;
let hasSwiped = false;
function resetInlineStyles() {
sheet.style.transform = '';
sheet.style.transition = '';
overlay.style.opacity = '';
overlay.style.background = '';
overlay.style.transition = '';
}
sheet.addEventListener('touchstart', (e) => {
if (e.touches.length !== 1) return;
// Only initiate swipe-down when scrolled to top (native feel)
if (sheet.scrollTop > 0) return;
const touch = e.touches[0];
startY = touch.clientY;
currentY = startY;
isDragging = true;
hasSwiped = false;
startTime = Date.now();
sheet.style.transition = 'none';
overlay.style.transition = 'none';
}, { passive: true });
sheet.addEventListener('touchmove', (e) => {
if (!isDragging) return;
const touch = e.touches[0];
currentY = touch.clientY;
const deltaY = currentY - startY;
if (deltaY > 0) {
hasSwiped = true;
e.preventDefault();
// Follow finger, capped at 80% of viewport
const translateY = Math.min(deltaY, window.innerHeight * 0.8);
sheet.style.transform = `translateY(${translateY}px)`;
// Dim overlay as the sheet goes down
overlay.style.background = `rgba(0,0,0,${Math.max(0, 0.4 * (1 - translateY / window.innerHeight))})`;
}
}, { passive: false });
sheet.addEventListener('touchend', () => {
if (!isDragging) return;
isDragging = false;
if (!hasSwiped) return;
const deltaY = currentY - startY;
const elapsed = Date.now() - startTime || 1;
const velocity = deltaY / elapsed; // px/ms
if (deltaY > 80 || velocity > 0.5) {
// Swiped far enough — animate the rest of the way down and close
sheet.style.transition = 'transform 0.25s ease-in';
overlay.style.transition = 'opacity 0.2s ease, background 0.25s ease';
sheet.style.transform = `translateY(${sheet.offsetHeight}px)`;
overlay.style.opacity = '0';
setTimeout(() => {
resetInlineStyles();
closeCallback();
}, 260);
} else {
// Not enough swipe — snap back
sheet.style.transition = 'transform 0.3s cubic-bezier(0.16, 1, 0.3, 1)';
overlay.style.transition = 'background 0.3s ease';
sheet.style.transform = '';
overlay.style.background = '';
setTimeout(() => {
overlay.style.transition = '';
}, 350);
}
}, { passive: true });
}
function showToast(message) { function showToast(message) {
const container = document.getElementById('toastContainer'); const container = document.getElementById('toastContainer');
const toast = document.createElement('div'); const toast = document.createElement('div');
@ -4886,7 +5095,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</div> </div>
<div style="display:flex;gap:10px;padding:0 20px 20px;"> <div style="display:flex;gap:10px;padding:0 20px 20px;">
<button class="btn-cancel" style="flex:1;" id="confirmNo">Nee</button> <button class="btn-cancel" style="flex:1;" id="confirmNo">Nee</button>
<button class="btn-danger-outline" style="flex:1;" id="confirmYes">${escapeHtml(confirmText)}</button> <button class="btn-danger-outline" data-haptic="strong" style="flex:1;" id="confirmYes">${escapeHtml(confirmText)}</button>
</div> </div>
</div> </div>
`; `;
@ -4900,6 +5109,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
overlay.querySelector('#confirmNo').addEventListener('click', () => { close(); resolve(false); }); overlay.querySelector('#confirmNo').addEventListener('click', () => { close(); resolve(false); });
overlay.querySelector('#confirmYes').addEventListener('click', () => { close(); resolve(true); }); overlay.querySelector('#confirmYes').addEventListener('click', () => { close(); resolve(true); });
overlay.addEventListener('click', (e) => { if (e.target === overlay) { close(); resolve(false); } }); overlay.addEventListener('click', (e) => { if (e.target === overlay) { close(); resolve(false); } });
setupSwipeDown(overlay, () => { close(); resolve(false); });
}); });
} }
@ -4938,6 +5148,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
overlay.addEventListener('click', (e) => { overlay.addEventListener('click', (e) => {
if (e.target === overlay) close(); if (e.target === overlay) close();
}); });
setupSwipeDown(overlay, close);
} }
function showNameInputModal(title, defaultValue, onSave) { function showNameInputModal(title, defaultValue, onSave) {
@ -4972,6 +5183,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
overlay.querySelector('#nameInputCancel').addEventListener('click', close); overlay.querySelector('#nameInputCancel').addEventListener('click', close);
overlay.querySelector('#nameInputSave').addEventListener('click', submit); overlay.querySelector('#nameInputSave').addEventListener('click', submit);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
setupSwipeDown(overlay, close);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
setTimeout(() => { input.focus(); input.select(); }, 100); setTimeout(() => { input.focus(); input.select(); }, 100);
} }
@ -4994,7 +5206,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
<input type="number" class="portion-input" value="${entry.portie}" min="1" max="9999" data-item-idx="${i}" style="width:72px;min-width:72px;"> <input type="number" class="portion-input" value="${entry.portie}" min="1" max="9999" data-item-idx="${i}" style="width:72px;min-width:72px;">
<span class="portion-unit">g</span> <span class="portion-unit">g</span>
</div> </div>
<button class="meal-edit-item-remove" data-item-idx="${i}" title="Verwijderen"><i class="fas fa-xmark"></i></button> <button class="meal-edit-item-remove" data-haptic="strong" data-item-idx="${i}" title="Verwijderen"><i class="fas fa-xmark"></i></button>
</div> </div>
</div> </div>
`; `;
@ -5018,7 +5230,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</button> </button>
<div class="meal-edit-actions"> <div class="meal-edit-actions">
<button class="btn-save" id="mealEditSave">Wijzigingen opslaan</button> <button class="btn-save" id="mealEditSave">Wijzigingen opslaan</button>
<button class="btn-danger-outline" id="mealEditDelete">Maaltijd verwijderen</button> <button class="btn-danger-outline" data-haptic="strong" id="mealEditDelete">Maaltijd verwijderen</button>
</div> </div>
</div> </div>
</div> </div>
@ -5045,6 +5257,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
} }
}); });
if (newItems.length === 0) { if (newItems.length === 0) {
hapticError();
showToast('Een maaltijd moet minimaal 1 item hebben'); showToast('Een maaltijd moet minimaal 1 item hebben');
return; return;
} }
@ -5071,6 +5284,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
overlay.querySelector('#mealEditClose').addEventListener('click', close); overlay.querySelector('#mealEditClose').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
setupSwipeDown(overlay, close);
overlay.querySelectorAll('.meal-edit-item-remove').forEach(btn => { overlay.querySelectorAll('.meal-edit-item-remove').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@ -5104,9 +5318,13 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</div> </div>
`; `;
document.body.appendChild(searchOverlay); document.body.appendChild(searchOverlay);
function closeSearch() { searchOverlay.remove(); } function closeSearch() {
searchOverlay.classList.add('closing');
setTimeout(() => searchOverlay.remove(), 280);
}
searchOverlay.querySelector('.modal-close').addEventListener('click', closeSearch); searchOverlay.querySelector('.modal-close').addEventListener('click', closeSearch);
searchOverlay.addEventListener('click', (e) => { if (e.target === searchOverlay) closeSearch(); }); searchOverlay.addEventListener('click', (e) => { if (e.target === searchOverlay) closeSearch(); });
setupSwipeDown(searchOverlay, closeSearch);
const searchInput = searchOverlay.querySelector('#mealItemSearch'); const searchInput = searchOverlay.querySelector('#mealItemSearch');
const resultsEl = searchOverlay.querySelector('#mealSearchResults'); const resultsEl = searchOverlay.querySelector('#mealSearchResults');
searchInput.addEventListener('input', () => { searchInput.addEventListener('input', () => {
@ -5187,6 +5405,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
po.querySelector('#mealPortionClose').addEventListener('click', closePicker); po.querySelector('#mealPortionClose').addEventListener('click', closePicker);
po.querySelector('#mealPortionCancel').addEventListener('click', closePicker); po.querySelector('#mealPortionCancel').addEventListener('click', closePicker);
po.addEventListener('click', (e) => { if (e.target === po) closePicker(); }); po.addEventListener('click', (e) => { if (e.target === po) closePicker(); });
setupSwipeDown(po, closePicker);
const input = po.querySelector('#mealPortionInput'); const input = po.querySelector('#mealPortionInput');
const preview = po.querySelector('#mealPortionPreview'); const preview = po.querySelector('#mealPortionPreview');
input.addEventListener('input', () => { input.addEventListener('input', () => {
@ -5227,6 +5446,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
po.querySelector('#mealPortionConfirm').addEventListener('click', () => { po.querySelector('#mealPortionConfirm').addEventListener('click', () => {
const portie = parseFloat(input.value) || 0; const portie = parseFloat(input.value) || 0;
if (portie <= 0) { if (portie <= 0) {
hapticError();
input.focus(); input.style.borderColor = 'var(--danger)'; input.focus(); input.style.borderColor = 'var(--danger)';
setTimeout(() => { input.style.borderColor = ''; }, 1500); setTimeout(() => { input.style.borderColor = ''; }, 1500);
return; return;
@ -5402,6 +5622,10 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
if (e.target === document.getElementById('addMealModal')) closeModal('addMealModal'); if (e.target === document.getElementById('addMealModal')) closeModal('addMealModal');
}); });
// Swipe-down to close for static modals
setupSwipeDown(document.getElementById('detailModal'), () => closeModal('detailModal'));
setupSwipeDown(document.getElementById('addMealModal'), () => closeModal('addMealModal'));
// Settings button & modal // Settings button & modal
document.getElementById('settingsBtn').addEventListener('click', () => { document.getElementById('settingsBtn').addEventListener('click', () => {
// Populate with current limit // Populate with current limit
@ -5416,6 +5640,8 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
if (e.target === document.getElementById('settingsModal')) closeModal('settingsModal'); if (e.target === document.getElementById('settingsModal')) closeModal('settingsModal');
}); });
setupSwipeDown(document.getElementById('settingsModal'), () => closeModal('settingsModal'));
document.getElementById('saveCarbLimitBtn').addEventListener('click', () => { document.getElementById('saveCarbLimitBtn').addEventListener('click', () => {
const input = document.getElementById('carbLimitInput'); const input = document.getElementById('carbLimitInput');
const val = parseInt(input.value, 10); const val = parseInt(input.value, 10);
@ -5484,11 +5710,11 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const wrap = group.querySelector('.eetmoment-meal-items-wrap'); const wrap = group.querySelector('.eetmoment-meal-items-wrap');
const actions = group.querySelector('.eetmoment-meal-actions'); const actions = group.querySelector('.eetmoment-meal-actions');
if (wrap) { if (wrap) {
wrap.classList.toggle('hidden'); wrap.classList.toggle('collapsed');
if (actions) actions.classList.toggle('hidden'); if (actions) actions.classList.toggle('collapsed');
const arrow = btn.querySelector('span:last-child i'); const arrow = btn.querySelector('span:last-child i');
if (arrow) { if (arrow) {
arrow.className = wrap.classList.contains('hidden') ? 'fas fa-chevron-down' : 'fas fa-chevron-up'; arrow.className = wrap.classList.contains('collapsed') ? 'fas fa-chevron-down' : 'fas fa-chevron-up';
} }
} }
} }
@ -5549,6 +5775,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
renderDagboek(); renderDagboek();
}); });
overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); }); overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); });
setupSwipeDown(overlay, close);
} }
}); });
} }
@ -5556,17 +5783,29 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// Start the app // Start the app
// Track last click position for toast positioning + haptic feedback // Track last click position for toast positioning + haptic feedback
// Supports data-haptic="strong" (18ms) for destructive actions
(function setupHaptics() { (function setupHaptics() {
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
lastClickY = e.clientY; lastClickY = e.clientY;
if (typeof navigator !== 'undefined' && navigator.vibrate) { if (typeof navigator !== 'undefined' && navigator.vibrate) {
if (e.target.tagName === 'BUTTON' || e.target.closest('button')) { const btn = e.target.tagName === 'BUTTON' ? e.target : e.target.closest('button');
navigator.vibrate(8); if (btn) {
const strength = btn.dataset.haptic || (btn.closest('[data-haptic]') && btn.closest('[data-haptic]').dataset.haptic) || 'normal';
navigator.vibrate(strength === 'strong' ? 18 : 8);
} }
} }
}, { passive: true }); }, { passive: true });
})(); })();
/**
* Short double-buzz on validation errors (empty name, invalid portion, etc.)
*/
function hapticError() {
if (typeof navigator !== 'undefined' && navigator.vibrate) {
navigator.vibrate([30, 50, 30]);
}
}
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
} else { } else {