Swipeable calendar
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m15s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m15s
This commit is contained in:
@@ -14,7 +14,8 @@ import {
|
||||
} from '../services/pantryShopping.js?v=2';
|
||||
import { aggregateSelectedDaysIngredientNeed } from '../services/planIngredients.js?v=2';
|
||||
import { loadPlans, dateKey } from '../services/planStore.js?v=2';
|
||||
import { addDays, startOfDay, startOfMonth, startOfWeekMonday } from '../services/dateUtils.js';
|
||||
import { addDays, startOfDay, startOfMonth } from '../services/dateUtils.js';
|
||||
import { createSwipePopoverCalendarHTML, initSwipePopoverCalendar } from '../ui/swipePopoverCalendar.js';
|
||||
import { showAppToast } from '../ui/toast.js';
|
||||
|
||||
/* ── helpers ── */
|
||||
@@ -62,6 +63,7 @@ let expandedIngredientId = null;
|
||||
let expandedAmount = 0;
|
||||
let calendarOpen = false;
|
||||
let calendarMonth = startOfMonth(new Date());
|
||||
let shoppingCalendar = null;
|
||||
|
||||
/* ── day helpers ── */
|
||||
|
||||
@@ -112,10 +114,6 @@ function groupByCategory(items) {
|
||||
/* ══════════════════════ HTML SHELL ══════════════════════ */
|
||||
|
||||
export function getShoppingListHTML() {
|
||||
const weekdayHeader = WEEKDAY_SHORT
|
||||
.map((d) => `<div>${d}</div>`)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div id="shopping-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden z-10" style="background:rgb(var(--app-bg-rgb)) !important;">
|
||||
|
||||
@@ -136,23 +134,11 @@ export function getShoppingListHTML() {
|
||||
|
||||
<!-- popup calendar (absolute, overlays content below) -->
|
||||
<div id="sl-calendar-popup" style="position:absolute; top:calc(100% + 0.5rem); left:0; right:0; z-index:50; pointer-events:none; opacity:0; transform:translateY(-6px) scale(0.98); transition: opacity 0.2s ease, transform 0.2s ease;">
|
||||
<div class="rounded-[1.35rem] px-3 py-3" style="background:rgb(var(--sunken-rgb)); border:1px solid rgb(var(--border-input-rgb)); box-shadow:var(--shadow-shell);">
|
||||
<!-- month nav topbar -->
|
||||
<div class="pb-3 flex items-center justify-end gap-3">
|
||||
<div class="flex h-[2.05rem] min-w-0 max-w-[min(100%,20rem)] items-center gap-px rounded-full border px-0.5" style="background:rgb(var(--app-bg-rgb)); border-color:rgb(var(--card-raised-rgb));">
|
||||
<button type="button" id="sl-cal-prev" class="shrink-0 w-7 h-full flex items-center justify-center rounded-full bg-transparent transition-colors" style="color:rgb(var(--text-body-soft-rgb));">
|
||||
<i class="fas fa-chevron-left text-[10px]"></i>
|
||||
</button>
|
||||
<span id="sl-cal-month-label" class="h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center px-1.5 text-[10px] font-semibold leading-none tabular-nums whitespace-nowrap" style="color:rgb(var(--text-body-soft-rgb));"></span>
|
||||
<button type="button" id="sl-cal-next" class="shrink-0 w-7 h-full flex items-center justify-center rounded-full bg-transparent transition-colors" style="color:rgb(var(--text-body-soft-rgb));">
|
||||
<i class="fas fa-chevron-right text-[10px]"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- weekday header -->
|
||||
<div class="grid grid-cols-7 gap-1.5 text-center text-[8px] font-medium uppercase tracking-wide mb-1 leading-none" style="color:rgb(var(--text-dim-rgb));">${weekdayHeader}</div>
|
||||
<!-- day grid -->
|
||||
<div id="sl-cal-grid" class="grid grid-cols-7 gap-1.5"></div>
|
||||
<div class="rounded-[1.35rem] py-3" style="background:rgb(var(--sunken-rgb)); border:1px solid rgb(var(--border-input-rgb)); box-shadow:var(--shadow-shell);">
|
||||
${createSwipePopoverCalendarHTML({
|
||||
idPrefix: 'sl-cal',
|
||||
weekdays: WEEKDAY_SHORT,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -180,115 +166,41 @@ export function getShoppingListHTML() {
|
||||
|
||||
/* ══════════════════════ CALENDAR ══════════════════════ */
|
||||
|
||||
function getDayRange(startDk, endDk) {
|
||||
const a = new Date(startDk + 'T00:00:00');
|
||||
const b = new Date(endDk + 'T00:00:00');
|
||||
const [from, to] = a <= b ? [a, b] : [b, a];
|
||||
const days = [];
|
||||
for (let d = new Date(from); d <= to; d = addDays(d, 1)) days.push(dateKey(d));
|
||||
return days;
|
||||
}
|
||||
|
||||
function renderCalendarGrid(previewDays = null) {
|
||||
const grid = document.getElementById('sl-cal-grid');
|
||||
const monthLabel = document.getElementById('sl-cal-month-label');
|
||||
if (!grid) return;
|
||||
|
||||
if (monthLabel) {
|
||||
monthLabel.textContent = `${MONTHS_LONG[calendarMonth.getMonth()]} ${calendarMonth.getFullYear()}`;
|
||||
}
|
||||
|
||||
const selected = previewDays ?? new Set(getSelectedDays());
|
||||
const today = todayKey();
|
||||
const first = startOfMonth(calendarMonth);
|
||||
const gridStart = startOfWeekMonday(first);
|
||||
|
||||
const cells = Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
|
||||
while (cells.length > 35 && cells.slice(-7).every((d) => d.getMonth() !== first.getMonth())) {
|
||||
cells.splice(-7);
|
||||
}
|
||||
|
||||
grid.innerHTML = cells.map((day) => {
|
||||
const dk = dateKey(day);
|
||||
const inMonth = day.getMonth() === first.getMonth();
|
||||
const isSel = selected.has(dk);
|
||||
const isToday = dk === today;
|
||||
|
||||
let bg, borderColor, color, opacity, borderClass;
|
||||
if (isSel) {
|
||||
bg = 'rgb(var(--card-rgb))'; borderColor = 'rgb(var(--border-input-rgb))'; color = 'rgb(var(--text-emphasis-rgb))'; opacity = '1'; borderClass = 'border';
|
||||
} else if (!inMonth) {
|
||||
bg = 'transparent'; borderColor = 'transparent'; color = CALENDAR_DIM_TEXT; opacity = CALENDAR_DIM_OPACITY; borderClass = 'border-0';
|
||||
} else {
|
||||
bg = 'rgb(var(--app-bg-rgb))'; borderColor = 'rgb(var(--card-raised-rgb))'; color = 'rgb(var(--text-body-soft-rgb))'; opacity = '1'; borderClass = 'border';
|
||||
}
|
||||
|
||||
const dot = isToday && !isSel
|
||||
? `<span class="absolute left-1/2 -translate-x-1/2 w-1 h-1 rounded-full opacity-75" style="bottom:0.24rem; background:rgb(var(--text-faint-rgb));"></span>`
|
||||
: '';
|
||||
|
||||
return `<button type="button" class="sl-cal-day mx-auto flex h-[2.05rem] w-full min-w-0 max-w-full items-center justify-center rounded-full ${borderClass} text-xs font-medium leading-tight overflow-hidden"
|
||||
style="background:${bg}; border-color:${borderColor}; color:${color}; opacity:${opacity}; touch-action:none;"
|
||||
data-dk="${esc(dk)}">
|
||||
<span class="relative flex h-full w-full flex-col items-center justify-center">
|
||||
<span class="text-[13px] font-semibold leading-none${dot ? ' -translate-y-[0.18rem]' : ''}">${day.getDate()}</span>
|
||||
${dot}
|
||||
</span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function bindCalendarEvents() {
|
||||
const grid = document.getElementById('sl-cal-grid');
|
||||
if (!grid) return;
|
||||
|
||||
let dragStartDk = null;
|
||||
let dragCurrentDk = null;
|
||||
let dragging = false;
|
||||
|
||||
grid.addEventListener('pointerdown', (e) => {
|
||||
e.stopPropagation();
|
||||
const btn = e.target.closest('.sl-cal-day');
|
||||
if (!btn) return;
|
||||
dragStartDk = btn.dataset.dk;
|
||||
dragCurrentDk = btn.dataset.dk;
|
||||
dragging = true;
|
||||
grid.setPointerCapture(e.pointerId);
|
||||
renderCalendarGrid(new Set([dragStartDk]));
|
||||
function initShoppingCalendar() {
|
||||
shoppingCalendar = initSwipePopoverCalendar({
|
||||
idPrefix: 'sl-cal',
|
||||
selectionMode: 'range',
|
||||
monthsLong: MONTHS_LONG,
|
||||
getMonthAnchor: () => calendarMonth,
|
||||
setMonthAnchor: (nextMonth) => {
|
||||
calendarMonth = startOfMonth(nextMonth);
|
||||
},
|
||||
getSelectionKeys: () => getSelectedDays(),
|
||||
onSelectionCommit: (rangeKeys) => {
|
||||
setSelectedDays(rangeKeys);
|
||||
expandedIngredientId = null;
|
||||
updatePillLabel();
|
||||
renderAll();
|
||||
},
|
||||
resolveDayState: (day, { inCurrentMonth, isSelected }) => ({
|
||||
disabled: false,
|
||||
dimmed: !inCurrentMonth,
|
||||
showDot: dateKey(day) === todayKey() && !isSelected,
|
||||
}),
|
||||
theme: {
|
||||
selectedBg: 'rgb(var(--card-rgb))',
|
||||
selectedBorder: 'rgb(var(--border-input-rgb))',
|
||||
selectedText: 'rgb(var(--text-emphasis-rgb))',
|
||||
selectedDot: 'rgb(var(--text-emphasis-rgb))',
|
||||
bg: 'rgb(var(--app-bg-rgb))',
|
||||
border: 'rgb(var(--card-raised-rgb))',
|
||||
text: 'rgb(var(--text-body-soft-rgb))',
|
||||
dimmedBg: 'transparent',
|
||||
dimText: CALENDAR_DIM_TEXT,
|
||||
dimOpacity: Number(CALENDAR_DIM_OPACITY),
|
||||
dot: 'rgb(var(--text-faint-rgb))',
|
||||
},
|
||||
});
|
||||
|
||||
grid.addEventListener('pointermove', (e) => {
|
||||
if (!dragging || !dragStartDk) return;
|
||||
e.preventDefault();
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const btn = el?.closest('.sl-cal-day');
|
||||
if (!btn?.dataset.dk || btn.dataset.dk === dragCurrentDk) return;
|
||||
dragCurrentDk = btn.dataset.dk;
|
||||
renderCalendarGrid(new Set(getDayRange(dragStartDk, dragCurrentDk)));
|
||||
});
|
||||
|
||||
grid.addEventListener('pointerup', (e) => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
const range = getDayRange(dragStartDk, dragCurrentDk);
|
||||
dragStartDk = null;
|
||||
dragCurrentDk = null;
|
||||
setSelectedDays(range);
|
||||
expandedIngredientId = null;
|
||||
updatePillLabel();
|
||||
renderCalendarGrid();
|
||||
renderAll();
|
||||
});
|
||||
|
||||
grid.addEventListener('pointercancel', () => {
|
||||
dragging = false;
|
||||
dragStartDk = null;
|
||||
dragCurrentDk = null;
|
||||
renderCalendarGrid();
|
||||
});
|
||||
|
||||
// Prevent bubbled click events (generated after pointerup) from closing the popup
|
||||
grid.addEventListener('click', (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
function updatePillLabel() {
|
||||
@@ -301,6 +213,7 @@ function openCalendar() {
|
||||
calendarOpen = true;
|
||||
calendarMonth = startOfMonth(new Date());
|
||||
const popup = document.getElementById('sl-calendar-popup');
|
||||
const viewport = document.getElementById('sl-cal-viewport');
|
||||
const chevron = document.getElementById('sl-range-chevron');
|
||||
const pill = document.getElementById('sl-range-pill');
|
||||
if (popup) {
|
||||
@@ -313,7 +226,31 @@ function openCalendar() {
|
||||
pill.style.background = 'rgb(var(--sunken-rgb))';
|
||||
pill.style.borderColor = 'rgb(var(--border-input-rgb))';
|
||||
}
|
||||
renderCalendarGrid();
|
||||
if (viewport) {
|
||||
viewport.style.opacity = '0';
|
||||
viewport.style.visibility = 'hidden';
|
||||
viewport.style.transition = 'opacity 120ms ease';
|
||||
}
|
||||
shoppingCalendar?.render();
|
||||
// Compute geometry while hidden; reveal only after stable layout.
|
||||
const ensureStableCalendarLayout = (attempt = 0) => {
|
||||
const vw = viewport ? (viewport.clientWidth || viewport.getBoundingClientRect().width) : 0;
|
||||
if (vw < 8 && attempt < 8) {
|
||||
requestAnimationFrame(() => ensureStableCalendarLayout(attempt + 1));
|
||||
return;
|
||||
}
|
||||
shoppingCalendar?.reapplyLayout();
|
||||
shoppingCalendar?.resetTrackPosition();
|
||||
requestAnimationFrame(() => {
|
||||
shoppingCalendar?.reapplyLayout();
|
||||
shoppingCalendar?.resetTrackPosition();
|
||||
if (viewport) {
|
||||
viewport.style.visibility = 'visible';
|
||||
viewport.style.opacity = '1';
|
||||
}
|
||||
});
|
||||
};
|
||||
requestAnimationFrame(() => ensureStableCalendarLayout());
|
||||
}
|
||||
|
||||
function closeCalendar() {
|
||||
@@ -331,6 +268,7 @@ function closeCalendar() {
|
||||
pill.style.background = 'rgb(var(--card-rgb))';
|
||||
pill.style.borderColor = 'rgb(var(--border-card-rgb))';
|
||||
}
|
||||
shoppingCalendar?.resetTrackPosition();
|
||||
}
|
||||
|
||||
function toggleCalendar() {
|
||||
@@ -671,6 +609,7 @@ function bindActiveRowEvents(root, activeItems) {
|
||||
export function refreshShoppingList() {
|
||||
updatePillLabel();
|
||||
renderAll();
|
||||
if (calendarOpen) shoppingCalendar?.render();
|
||||
}
|
||||
|
||||
export function setupShoppingList() {
|
||||
@@ -679,7 +618,7 @@ export function setupShoppingList() {
|
||||
updatePillLabel();
|
||||
renderAll();
|
||||
|
||||
bindCalendarEvents();
|
||||
initShoppingCalendar();
|
||||
|
||||
document.getElementById('sl-range-pill')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -708,19 +647,6 @@ export function setupShoppingList() {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('sl-cal-prev')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
calendarMonth = startOfMonth(new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() - 1, 1));
|
||||
renderCalendarGrid();
|
||||
});
|
||||
|
||||
document.getElementById('sl-cal-next')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
calendarMonth = startOfMonth(new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() + 1, 1));
|
||||
renderCalendarGrid();
|
||||
});
|
||||
|
||||
|
||||
document.getElementById('sl-clear-session')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
clearSessionLog();
|
||||
|
||||
Reference in New Issue
Block a user