Compare commits

...

3 Commits

Author SHA1 Message Date
08a275093c Unify calendar code
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m16s
2026-04-20 23:44:18 +02:00
c43b3766cd Fix calendar styling 2026-04-20 23:04:28 +02:00
63937ed7d1 Block calendar swiping outside possible range 2026-04-20 22:22:51 +02:00
7 changed files with 880 additions and 583 deletions

167
js/ui/calendarPopover.js Normal file
View File

@@ -0,0 +1,167 @@
const DEFAULT_POPOVER_CLASS = 'absolute left-0 right-0 top-full mt-2 z-[50] transition-all duration-200 pointer-events-none';
const DEFAULT_POPOVER_STYLE = 'opacity:0; transform:translateY(-6px) scale(0.98);';
const DEFAULT_PANEL_CLASS = 'rounded-[1.35rem] py-3';
const DEFAULT_PANEL_STYLE = 'background:rgb(var(--sunken-rgb)); border:1px solid rgb(var(--border-input-rgb)); box-shadow:var(--shadow-shell);';
const DEFAULT_OPEN_TRIGGER_STYLE = {
background: 'rgb(var(--sunken-rgb))',
borderColor: 'rgb(var(--border-input-rgb))',
};
const DEFAULT_CLOSED_TRIGGER_STYLE = {
background: 'rgb(var(--card-rgb))',
borderColor: 'rgb(var(--border-card-rgb))',
};
function byId(idOrElement) {
if (!idOrElement) return null;
if (typeof idOrElement === 'string') return document.getElementById(idOrElement);
return idOrElement;
}
function setStyles(el, styles = {}, important = false) {
if (!el) return;
Object.entries(styles).forEach(([name, value]) => {
if (value == null) return;
if (important) {
const cssName = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
el.style.setProperty(cssName, value, 'important');
}
else el.style[name] = value;
});
}
export function createCalendarPopoverHTML({
id,
calendarHTML,
popoverClass = DEFAULT_POPOVER_CLASS,
popoverStyle = DEFAULT_POPOVER_STYLE,
panelClass = DEFAULT_PANEL_CLASS,
panelStyle = DEFAULT_PANEL_STYLE,
wrapInPanel = true,
}) {
const body = wrapInPanel
? `<div class="${panelClass}" style="${panelStyle}">${calendarHTML}</div>`
: calendarHTML;
return `
<div id="${id}" class="${popoverClass}" style="${popoverStyle}">
${body}
</div>
`;
}
export function syncCalendarPopoverVisibility({
popup,
isOpen,
chevron,
trigger,
openTriggerStyle = DEFAULT_OPEN_TRIGGER_STYLE,
closedTriggerStyle = DEFAULT_CLOSED_TRIGGER_STYLE,
triggerImportant = false,
}) {
const popupEl = byId(popup);
if (popupEl) {
popupEl.style.opacity = isOpen ? '1' : '0';
popupEl.style.transform = isOpen ? 'translateY(0) scale(1)' : 'translateY(-6px) scale(0.98)';
popupEl.style.pointerEvents = isOpen ? 'auto' : 'none';
}
const chevronEl = byId(chevron);
if (chevronEl) chevronEl.style.transform = isOpen ? 'rotate(180deg)' : 'rotate(0deg)';
setStyles(
byId(trigger),
isOpen ? openTriggerStyle : closedTriggerStyle,
triggerImportant,
);
}
export function stabilizeSwipeCalendarLayout({
calendar,
viewport,
hideViewport = false,
maxAttempts = 8,
}) {
const viewportEl = byId(viewport);
if (hideViewport && viewportEl) {
viewportEl.style.opacity = '0';
viewportEl.style.visibility = 'hidden';
viewportEl.style.transition = 'opacity 120ms ease';
}
calendar?.render?.();
const ensureStableLayout = (attempt = 0) => {
const width = viewportEl ? (viewportEl.clientWidth || viewportEl.getBoundingClientRect().width) : 0;
if (viewportEl && width < 8 && attempt < maxAttempts) {
requestAnimationFrame(() => ensureStableLayout(attempt + 1));
return;
}
calendar?.reapplyLayout?.();
calendar?.resetTrackPosition?.();
requestAnimationFrame(() => {
calendar?.reapplyLayout?.();
calendar?.resetTrackPosition?.();
if (hideViewport && viewportEl) {
viewportEl.style.visibility = 'visible';
viewportEl.style.opacity = '1';
}
});
};
requestAnimationFrame(() => ensureStableLayout());
}
export function createCalendarPopoverController({
popupId,
viewportId,
triggerId,
chevronId,
getCalendar,
openTriggerStyle = DEFAULT_OPEN_TRIGGER_STYLE,
closedTriggerStyle = DEFAULT_CLOSED_TRIGGER_STYLE,
triggerImportant = false,
hideViewportDuringLayout = false,
}) {
const calendar = () => (typeof getCalendar === 'function' ? getCalendar() : null);
const sync = (isOpen) => {
syncCalendarPopoverVisibility({
popup: popupId,
isOpen,
chevron: chevronId,
trigger: triggerId,
openTriggerStyle,
closedTriggerStyle,
triggerImportant,
});
};
const stabilize = () => {
stabilizeSwipeCalendarLayout({
calendar: calendar(),
viewport: viewportId,
hideViewport: hideViewportDuringLayout,
});
};
const close = ({ clearPendingRange = false } = {}) => {
sync(false);
const instance = calendar();
if (clearPendingRange) instance?.clearPendingRange?.();
instance?.resetTrackPosition?.();
};
const open = () => {
sync(true);
stabilize();
};
return {
sync,
open,
close,
stabilize,
};
}

View File

@@ -1,6 +1,9 @@
import { import {
addDays, addDays,
addMonths,
addWeeks,
sameDay, sameDay,
sameMonth,
startOfDay, startOfDay,
startOfMonth, startOfMonth,
startOfWeekMonday, startOfWeekMonday,
@@ -21,10 +24,19 @@ export const CALENDAR_WEEKDAYS_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'so', 'nd
export const CALENDAR_DAY_ATTR = 'data-calendar-day'; export const CALENDAR_DAY_ATTR = 'data-calendar-day';
export const CALENDAR_HANDLE_CLASS = 'block h-1 w-10 rounded-full bg-[rgb(var(--text-subdued-rgb))]/75'; export const CALENDAR_HANDLE_CLASS = 'block h-1 w-10 rounded-full bg-[rgb(var(--text-subdued-rgb))]/75';
function getCalendarDayHTML(day, meta, dayState, dayAttr, theme = {}) { function escapeAttrValue(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;');
}
function getCalendarDayHTML(day, meta, dayState, dayAttr, theme = {}, options = {}) {
const { mode, selectedDate } = meta; const { mode, selectedDate } = meta;
const isSelected = selectedDate && sameDay(day, selectedDate); const isSelected = typeof meta.isSelected === 'boolean'
const showIndicator = !!dayState.showIndicator; ? meta.isSelected
: !!(selectedDate && sameDay(day, selectedDate));
const showIndicator = !!(dayState.showIndicator || dayState.showDot);
const isDisabled = !!dayState.disabled; const isDisabled = !!dayState.disabled;
const isDimmed = !!dayState.dimmed && !isSelected; const isDimmed = !!dayState.dimmed && !isSelected;
const defaultBg = 'rgb(var(--card-soft-rgb))'; const defaultBg = 'rgb(var(--card-soft-rgb))';
@@ -34,16 +46,25 @@ function getCalendarDayHTML(day, meta, dayState, dayAttr, theme = {}) {
let bg; let bg;
let borderColor; let borderColor;
let text; let text;
let borderClass = 'border'; let borderClass = theme.borderClass || 'border';
let shadow = theme.shadow || 'none';
if (isSelected) { if (isSelected) {
bg = theme.selectedBg || 'rgb(var(--card-rgb))'; const keepDimmedBg = !!dayState.dimmed
&& theme.selectedBg == null
&& theme.selectedUsesDimmedBg !== false;
bg = theme.selectedBg
|| (keepDimmedBg ? (theme.dimmedBg ?? 'transparent') : null)
|| theme.bg
|| 'rgb(var(--card-rgb))';
borderColor = theme.selectedBorder || 'rgb(var(--border-input-rgb))'; borderColor = theme.selectedBorder || 'rgb(var(--border-input-rgb))';
text = theme.selectedText || 'rgb(var(--text-emphasis-rgb))'; text = theme.selectedText || 'rgb(var(--text-emphasis-rgb))';
shadow = theme.selectedShadow || shadow;
borderClass = theme.selectedBorderClass || 'border';
} else if (isDimmed) { } else if (isDimmed) {
bg = theme.dimmedBg ?? 'transparent'; bg = theme.dimmedBg ?? 'transparent';
text = theme.dimText || 'rgb(var(--text-faint-rgb))'; text = theme.dimText || 'rgb(var(--text-faint-rgb))';
borderClass = 'border-0'; borderClass = theme.dimmedBorderClass || 'border-0';
} else { } else {
bg = theme.bg || defaultBg; bg = theme.bg || defaultBg;
borderColor = theme.border || defaultBorder; borderColor = theme.border || defaultBorder;
@@ -53,18 +74,23 @@ function getCalendarDayHTML(day, meta, dayState, dayAttr, theme = {}) {
const dot = isSelected ? (theme.selectedDot || 'rgb(var(--text-emphasis-rgb))') : (theme.dot || 'rgb(var(--text-faint-rgb))'); const dot = isSelected ? (theme.selectedDot || 'rgb(var(--text-emphasis-rgb))') : (theme.dot || 'rgb(var(--text-faint-rgb))');
const opacity = isDimmed ? String(theme.dimOpacity ?? 0.72) : '1'; const opacity = isDimmed ? String(theme.dimOpacity ?? 0.72) : '1';
const borderStyle = borderColor ? `border-color:${borderColor};` : 'border:none;'; const borderStyle = borderColor ? `border-color:${borderColor};` : 'border:none;';
const outerClass = `${mode === 'month' ? '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 transition-colors leading-tight overflow-hidden`; const extraClass = !isDisabled && options.dayClassName ? ` ${options.dayClassName}` : '';
const outerClass = `${mode === 'month' ? 'mx-auto ' : ''}flex h-[2.05rem] w-full min-w-0 max-w-full items-center justify-center rounded-full ${borderClass}${extraClass} text-xs font-medium transition-colors leading-tight overflow-hidden`;
const innerClass = mode === 'month' const innerClass = mode === 'month'
? 'relative flex h-full w-full flex-col items-center justify-center' ? 'relative flex h-full w-full flex-col items-center justify-center'
: 'relative flex h-full w-full items-center justify-center'; : 'relative flex h-full w-full items-center justify-center';
const dotBottom = mode === 'month' ? '0.24rem' : '0.2rem'; const dotBottom = mode === 'month' ? '0.24rem' : '0.2rem';
const tagName = isDisabled ? 'div' : 'button'; const tagName = isDisabled ? 'div' : 'button';
const buttonAttrs = isDisabled ? '' : ` type="button" ${dayAttr}="${day.getTime()}"`; const dayAttrValue = typeof options.getDayAttrValue === 'function'
? options.getDayAttrValue(day, meta)
: day.getTime();
const buttonAttrs = isDisabled ? '' : ` type="button" ${dayAttr}="${escapeAttrValue(dayAttrValue)}"`;
const dayStyle = options.dayStyle || '';
return ` return `
<${tagName}${buttonAttrs} <${tagName}${buttonAttrs}
class="${outerClass}" class="${outerClass}"
style="background:${bg};${borderStyle}color:${text};opacity:${opacity};"> style="background:${bg};${borderStyle}color:${text};opacity:${opacity};box-shadow:${shadow};${dayStyle}">
<span class="${innerClass}"> <span class="${innerClass}">
<span class="text-[13px] font-semibold leading-none ${showIndicator ? '-translate-y-[0.18rem]' : ''}">${day.getDate()}</span> <span class="text-[13px] font-semibold leading-none ${showIndicator ? '-translate-y-[0.18rem]' : ''}">${day.getDate()}</span>
${showIndicator ${showIndicator
@@ -75,12 +101,13 @@ function getCalendarDayHTML(day, meta, dayState, dayAttr, theme = {}) {
`; `;
} }
function getMonthCells(monthAnchor) { export function getCalendarMonthCells(monthAnchor, { fixedWeekCount = null } = {}) {
const first = startOfMonth(monthAnchor); const first = startOfMonth(monthAnchor);
const startGrid = startOfWeekMonday(first); const startGrid = startOfWeekMonday(first);
const cells = []; const cells = [];
for (let i = 0; i < 42; i++) cells.push(addDays(startGrid, i)); const maxCells = fixedWeekCount ? fixedWeekCount * 7 : 42;
while (cells.length > 35 && cells.slice(-7).every((day) => day.getMonth() !== first.getMonth())) { for (let i = 0; i < maxCells; i++) cells.push(addDays(startGrid, i));
while (!fixedWeekCount && cells.length > 35 && cells.slice(-7).every((day) => day.getMonth() !== first.getMonth())) {
cells.splice(-7); cells.splice(-7);
} }
return { cells, month: first.getMonth() }; return { cells, month: first.getMonth() };
@@ -99,7 +126,7 @@ function getDayState(day, meta, resolveDayState) {
return { return {
disabled: !!resolved.disabled, disabled: !!resolved.disabled,
dimmed: !!resolved.dimmed, dimmed: !!resolved.dimmed,
showIndicator: !!resolved.showIndicator, showIndicator: !!(resolved.showIndicator || resolved.showDot),
}; };
} }
@@ -116,8 +143,8 @@ export function createCalendarWeekdayHeaderHTML(labels = CALENDAR_WEEKDAYS_SHORT
export function createCalendarTopbarHTML({ export function createCalendarTopbarHTML({
todayId, todayId,
wrapperClass = 'px-4 pt-4 pb-3 flex items-center justify-end', wrapperClass = 'px-4 pt-4 pb-3 flex items-center justify-end',
controlsStyle = 'background:rgb(var(--card-soft-rgb));border-color:rgb(var(--card-strong-rgb));', controlsStyle = 'background:transparent;border-color:rgb(var(--card-strong-rgb));',
todayButtonActiveClass = 'h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center rounded-full bg-transparent px-3 text-[10px] font-semibold leading-none tabular-nums text-[rgb(var(--text-body-soft-rgb))] active:bg-transparent whitespace-nowrap', todayButtonActiveClass = 'h-full shrink-0 inline-flex min-w-[7.25rem] max-w-[12.5rem] items-center justify-center rounded-full bg-transparent px-3 text-[10px] font-semibold leading-none tabular-nums text-[rgb(var(--text-body-soft-rgb))] active:bg-transparent whitespace-nowrap',
todayButtonDimClass = 'h-full shrink-0 inline-flex items-center justify-center rounded-full px-3 text-[10px] font-semibold leading-none text-[rgb(var(--text-faint-rgb))] cursor-default', todayButtonDimClass = 'h-full shrink-0 inline-flex items-center justify-center rounded-full px-3 text-[10px] font-semibold leading-none text-[rgb(var(--text-faint-rgb))] cursor-default',
}) { }) {
return ` return `
@@ -133,6 +160,45 @@ export function createCalendarTopbarHTML({
`; `;
} }
export function createCollapsibleCalendarHTML({
idPrefix = 'calendar',
swipeZoneId = `${idPrefix}-swipe-zone`,
weekWrapId = `${idPrefix}-week-wrap`,
weekGridId = `${idPrefix}-week-grid`,
monthWrapId = `${idPrefix}-month-wrap`,
monthGridId = `${idPrefix}-month-grid`,
toggleId = `${idPrefix}-mode-toggle`,
iconId = `${idPrefix}-handle-icon`,
wrapperClass = 'overflow-x-hidden bg-[rgb(var(--app-bg-rgb))]',
wrapperStyle = 'touch-action: none',
weekWrapClass = 'px-3 overflow-x-hidden bg-[rgb(var(--app-bg-rgb))]',
weekWrapStyle = 'overflow: hidden; max-height: 10rem; opacity: 1; padding-bottom: 0.75rem',
monthWrapClass = 'px-3 bg-[rgb(var(--app-bg-rgb))]',
monthWrapStyle = 'overflow: hidden; max-height: 0; opacity: 0; padding-bottom: 0',
weekGridClass = 'grid grid-cols-7 gap-1.5 max-w-full overflow-x-hidden',
monthGridClass = 'grid grid-cols-7 gap-1.5',
toggleClass = 'w-full flex items-center justify-center py-1 pb-2 pt-0.5 text-[rgb(var(--text-faint-rgb))] hover:text-[rgb(var(--text-body-soft-rgb))] transition-colors',
toggleLabel = 'Przełącz widok kalendarza',
weekdayLabels = CALENDAR_WEEKDAYS_SHORT,
weekdayHeaderOptions = {},
} = {}) {
return `
<div id="${swipeZoneId}" class="${wrapperClass}" style="${wrapperStyle}">
<div id="${weekWrapId}" class="${weekWrapClass}" style="${weekWrapStyle}">
${createCalendarWeekdayHeaderHTML(weekdayLabels, weekdayHeaderOptions)}
<div id="${weekGridId}" class="${weekGridClass}"></div>
</div>
<div id="${monthWrapId}" class="${monthWrapClass}" style="${monthWrapStyle}">
${createCalendarWeekdayHeaderHTML(weekdayLabels, weekdayHeaderOptions)}
<div id="${monthGridId}" class="${monthGridClass}"></div>
</div>
<button id="${toggleId}" type="button" class="${toggleClass}" aria-label="${toggleLabel}">
<i id="${iconId}" class="fas fa-chevron-down text-[10px]"></i>
</button>
</div>
`;
}
export function formatCalendarMonthYear(date) { export function formatCalendarMonthYear(date) {
return `${CALENDAR_MONTHS_LONG[date.getMonth()]} ${date.getFullYear()}`; return `${CALENDAR_MONTHS_LONG[date.getMonth()]} ${date.getFullYear()}`;
} }
@@ -141,6 +207,27 @@ export function formatCalendarSelectedDate(date) {
return `${date.getDate()} ${CALENDAR_MONTHS_SHORT[date.getMonth()]} ${date.getFullYear()}`; return `${date.getDate()} ${CALENDAR_MONTHS_SHORT[date.getMonth()]} ${date.getFullYear()}`;
} }
export function formatCalendarWeekRange(weekAnchorDate) {
const start = startOfWeekMonday(weekAnchorDate);
const end = addDays(start, 6);
const sameYear = start.getFullYear() === end.getFullYear();
const sameMonth = sameYear && start.getMonth() === end.getMonth();
if (sameMonth) {
return `${start.getDate()}${end.getDate()} ${CALENDAR_MONTHS_SHORT[end.getMonth()]} ${end.getFullYear()}`;
}
if (sameYear) {
return `${start.getDate()} ${CALENDAR_MONTHS_SHORT[start.getMonth()]} ${end.getDate()} ${CALENDAR_MONTHS_SHORT[end.getMonth()]} ${end.getFullYear()}`;
}
return `${start.getDate()} ${CALENDAR_MONTHS_SHORT[start.getMonth()]} ${start.getFullYear()} ${end.getDate()} ${CALENDAR_MONTHS_SHORT[end.getMonth()]} ${end.getFullYear()}`;
}
export function formatCalendarPeriodLabel(mode, weekAnchorDate, monthAnchorDate) {
return mode === 'week'
? formatCalendarWeekRange(weekAnchorDate)
: formatCalendarMonthYear(monthAnchorDate);
}
export function isCalendarOnToday(mode, weekStart, monthAnchor, selectedDate) { export function isCalendarOnToday(mode, weekStart, monthAnchor, selectedDate) {
const today = startOfDay(new Date()); const today = startOfDay(new Date());
if (!sameDay(selectedDate, today)) return false; if (!sameDay(selectedDate, today)) return false;
@@ -157,10 +244,13 @@ export function syncCalendarTodayButton(buttonEl, isOnToday, selectedDate, optio
const { const {
ariaLabelGo = 'Przejdź do dzisiejszego dnia', ariaLabelGo = 'Przejdź do dzisiejszego dnia',
ariaLabelCurrent = 'Widok jest ustawiony na bieżący okres', ariaLabelCurrent = 'Widok jest ustawiony na bieżący okres',
labelText,
} = options; } = options;
const active = buttonEl.dataset.calActiveClass const active = buttonEl.dataset.calActiveClass
|| 'h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center rounded-full bg-transparent px-3 text-[10px] font-semibold leading-none tabular-nums text-[rgb(var(--text-body-soft-rgb))] active:bg-transparent whitespace-nowrap'; || 'h-full shrink-0 inline-flex min-w-[7.25rem] max-w-[12.5rem] items-center justify-center rounded-full bg-transparent px-3 text-[10px] font-semibold leading-none tabular-nums text-[rgb(var(--text-body-soft-rgb))] active:bg-transparent whitespace-nowrap';
if (selectedDate != null) { if (labelText != null) {
buttonEl.textContent = labelText;
} else if (selectedDate != null) {
buttonEl.textContent = formatCalendarSelectedDate(selectedDate); buttonEl.textContent = formatCalendarSelectedDate(selectedDate);
} }
buttonEl.className = active; buttonEl.className = active;
@@ -174,8 +264,13 @@ export function renderCalendarGrid({
mode, mode,
anchorDate, anchorDate,
selectedDate, selectedDate,
isSelectedDate,
resolveDayState, resolveDayState,
dayAttr = CALENDAR_DAY_ATTR, dayAttr = CALENDAR_DAY_ATTR,
getDayAttrValue,
dayClassName = '',
dayStyle = '',
fixedWeekCount = null,
theme, theme,
}) { }) {
if (!gridEl) return; if (!gridEl) return;
@@ -185,25 +280,48 @@ export function renderCalendarGrid({
const cells = []; const cells = [];
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const day = addDays(weekStart, i); const day = addDays(weekStart, i);
const selected = typeof isSelectedDate === 'function'
? !!isSelectedDate(day, { mode, selectedDate, inCurrentMonth: true })
: !!(selectedDate && sameDay(day, selectedDate));
const meta = { const meta = {
mode, mode,
selectedDate, selectedDate,
inCurrentMonth: true, inCurrentMonth: true,
isSelected: selected,
}; };
cells.push(getCalendarDayHTML(day, meta, getDayState(day, meta, resolveDayState), dayAttr, theme)); cells.push(getCalendarDayHTML(
day,
meta,
getDayState(day, meta, resolveDayState),
dayAttr,
theme,
{ getDayAttrValue, dayClassName, dayStyle },
));
} }
gridEl.innerHTML = cells.join(''); gridEl.innerHTML = cells.join('');
return; return;
} }
const { cells, month } = getMonthCells(anchorDate); const { cells, month } = getCalendarMonthCells(anchorDate, { fixedWeekCount });
gridEl.innerHTML = cells.map((day) => { gridEl.innerHTML = cells.map((day) => {
const inCurrentMonth = day.getMonth() === month;
const selected = typeof isSelectedDate === 'function'
? !!isSelectedDate(day, { mode, selectedDate, inCurrentMonth })
: !!(selectedDate && sameDay(day, selectedDate));
const meta = { const meta = {
mode, mode,
selectedDate, selectedDate,
inCurrentMonth: day.getMonth() === month, inCurrentMonth,
isSelected: selected,
}; };
return getCalendarDayHTML(day, meta, getDayState(day, meta, resolveDayState), dayAttr, theme); return getCalendarDayHTML(
day,
meta,
getDayState(day, meta, resolveDayState),
dayAttr,
theme,
{ getDayAttrValue, dayClassName, dayStyle },
);
}).join(''); }).join('');
} }
@@ -215,6 +333,7 @@ export function renderCollapsibleCalendar({
selectedDate, selectedDate,
resolveDayState, resolveDayState,
dayAttr = CALENDAR_DAY_ATTR, dayAttr = CALENDAR_DAY_ATTR,
theme,
}) { }) {
renderCalendarGrid({ renderCalendarGrid({
gridEl: weekGridEl, gridEl: weekGridEl,
@@ -223,6 +342,7 @@ export function renderCollapsibleCalendar({
selectedDate, selectedDate,
resolveDayState, resolveDayState,
dayAttr, dayAttr,
theme,
}); });
renderCalendarGrid({ renderCalendarGrid({
gridEl: monthGridEl, gridEl: monthGridEl,
@@ -231,6 +351,7 @@ export function renderCollapsibleCalendar({
selectedDate, selectedDate,
resolveDayState, resolveDayState,
dayAttr, dayAttr,
theme,
}); });
} }
@@ -256,6 +377,253 @@ export function syncCollapsibleCalendarMode({
if (handleEl) handleEl.className = CALENDAR_HANDLE_CLASS; if (handleEl) handleEl.className = CALENDAR_HANDLE_CLASS;
} }
export function syncCollapsibleCalendarToggleIcon(iconEl, mode) {
if (iconEl) iconEl.className = mode === 'month' ? 'fas fa-chevron-up text-[10px]' : 'fas fa-chevron-down text-[10px]';
}
export function bindCollapsibleCalendarSwipeGesture({
zoneEl,
weekWrapEl,
monthWrapEl,
getMode,
setMode,
getWeekAnchor,
setWeekAnchor,
getMonthAnchor,
setMonthAnchor,
getSelectedDate,
setSelectedDate,
rerender,
resolveDayState,
dayAttr = CALENDAR_DAY_ATTR,
theme,
selectOnNavigateOutside = true,
enableVerticalModeSwipe = true,
threshold = 40,
animationMs = 260,
} = {}) {
if (!zoneEl) return () => {};
let startX = 0;
let startY = 0;
let ptrId = null;
let moved = false;
let axisLocked = null;
let suppressClickUntil = 0;
let animatingNav = false;
let dragWrap = null;
let wrapWidth = 0;
let prevGhost = null;
let nextGhost = null;
let prevWrapPosition = '';
let prevWrapOverflow = '';
const getActiveWrap = () => (getMode?.() === 'week' ? weekWrapEl : monthWrapEl);
const buildGhost = (anchorDate, mode) => {
if (!dragWrap) return null;
const ghost = dragWrap.cloneNode(true);
ghost.removeAttribute('id');
ghost.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
ghost.style.position = 'absolute';
ghost.style.top = '0';
ghost.style.width = '100%';
ghost.style.pointerEvents = 'none';
ghost.setAttribute('aria-hidden', 'true');
let ghostGridEl = null;
ghost.querySelectorAll('.grid-cols-7').forEach((grid) => {
if (!grid.classList.contains('text-center')) ghostGridEl = grid;
});
if (ghostGridEl) {
renderCalendarGrid({
gridEl: ghostGridEl,
mode,
anchorDate,
selectedDate: getSelectedDate?.(),
resolveDayState,
dayAttr,
theme,
});
}
return ghost;
};
const activateCarousel = () => {
const mode = getMode?.() || 'week';
dragWrap = getActiveWrap();
if (!dragWrap) return;
wrapWidth = dragWrap.getBoundingClientRect().width || zoneEl.getBoundingClientRect().width;
if (wrapWidth <= 0) return;
prevWrapPosition = dragWrap.style.position;
prevWrapOverflow = dragWrap.style.overflow;
dragWrap.style.position = 'relative';
dragWrap.style.overflow = 'visible';
const prevAnchor = mode === 'week'
? addWeeks(getWeekAnchor?.() || new Date(), -1)
: addMonths(getMonthAnchor?.() || new Date(), -1);
const nextAnchor = mode === 'week'
? addWeeks(getWeekAnchor?.() || new Date(), 1)
: addMonths(getMonthAnchor?.() || new Date(), 1);
prevGhost = buildGhost(prevAnchor, mode);
nextGhost = buildGhost(nextAnchor, mode);
if (prevGhost) {
prevGhost.style.left = `-${wrapWidth}px`;
dragWrap.appendChild(prevGhost);
}
if (nextGhost) {
nextGhost.style.left = `${wrapWidth}px`;
dragWrap.appendChild(nextGhost);
}
dragWrap.style.willChange = 'transform';
dragWrap.style.transition = 'none';
};
const clearCarousel = () => {
if (prevGhost?.parentNode) prevGhost.parentNode.removeChild(prevGhost);
if (nextGhost?.parentNode) nextGhost.parentNode.removeChild(nextGhost);
prevGhost = null;
nextGhost = null;
if (dragWrap) {
dragWrap.style.transition = '';
dragWrap.style.transform = '';
dragWrap.style.willChange = '';
dragWrap.style.position = prevWrapPosition;
dragWrap.style.overflow = prevWrapOverflow;
}
dragWrap = null;
};
const setTranslate = (x, ms) => {
if (!dragWrap) return;
dragWrap.style.transition = ms ? `transform ${ms}ms ease` : 'none';
dragWrap.style.transform = `translate3d(${x}px, 0, 0)`;
};
const onPointerDown = (event) => {
if (ptrId !== null || animatingNav) return;
if (event.pointerType === 'mouse' && event.button !== 0) return;
startX = event.clientX;
startY = event.clientY;
ptrId = event.pointerId;
moved = false;
axisLocked = null;
try { zoneEl.setPointerCapture(event.pointerId); } catch (_) {}
};
const onPointerMove = (event) => {
if (event.pointerId !== ptrId) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (!moved && (Math.abs(dx) > 6 || Math.abs(dy) > 6)) {
moved = true;
axisLocked = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
if (axisLocked === 'x') activateCarousel();
}
if (axisLocked === 'x' && dragWrap) {
setTranslate(dx, 0);
}
};
const onPointerUp = (event) => {
if (event.pointerId !== ptrId) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
ptrId = null;
if (!moved) return;
const horizontal = axisLocked === 'x';
if (horizontal && dragWrap) {
if (Math.abs(dx) < threshold) {
setTranslate(0, animationMs);
setTimeout(clearCarousel, animationMs + 20);
return;
}
suppressClickUntil = performance.now() + 500;
animatingNav = true;
const targetX = dx > 0 ? wrapWidth : -wrapWidth;
setTranslate(targetX, animationMs);
setTimeout(() => {
const mode = getMode?.() || 'week';
const sign = dx > 0 ? -1 : 1;
const selected = getSelectedDate?.();
if (mode === 'week') {
const nextWeek = addWeeks(getWeekAnchor?.() || selected || new Date(), sign);
setWeekAnchor?.(nextWeek);
if (selectOnNavigateOutside && selected && !weekContains(nextWeek, selected)) {
setSelectedDate?.(new Date(nextWeek));
}
} else {
const nextMonth = addMonths(getMonthAnchor?.() || selected || new Date(), sign);
setMonthAnchor?.(nextMonth);
if (selectOnNavigateOutside && selected && !sameMonth(nextMonth, selected)) {
setSelectedDate?.(startOfMonth(nextMonth));
}
}
clearCarousel();
rerender?.();
animatingNav = false;
}, animationMs);
return;
}
if (enableVerticalModeSwipe && !horizontal && Math.abs(dy) >= 30) {
const mode = getMode?.() || 'week';
const selected = getSelectedDate?.() || new Date();
let triggered = false;
if (mode === 'week' && dy > 0) {
setMode?.('month');
setMonthAnchor?.(startOfMonth(selected));
triggered = true;
} else if (mode === 'month' && dy < 0) {
setMode?.('week');
setWeekAnchor?.(startOfWeekMonday(selected));
triggered = true;
}
if (triggered) {
suppressClickUntil = performance.now() + 350;
rerender?.();
}
}
};
const onClickCapture = (event) => {
if (performance.now() < suppressClickUntil) {
event.stopPropagation();
event.preventDefault();
suppressClickUntil = 0;
}
};
const onPointerCancel = () => {
ptrId = null;
moved = false;
if (dragWrap) {
setTranslate(0, animationMs);
setTimeout(clearCarousel, animationMs + 20);
}
};
zoneEl.addEventListener('pointerdown', onPointerDown);
zoneEl.addEventListener('pointermove', onPointerMove);
zoneEl.addEventListener('pointerup', onPointerUp);
zoneEl.addEventListener('click', onClickCapture, { capture: true });
zoneEl.addEventListener('pointercancel', onPointerCancel);
return () => {
zoneEl.removeEventListener('pointerdown', onPointerDown);
zoneEl.removeEventListener('pointermove', onPointerMove);
zoneEl.removeEventListener('pointerup', onPointerUp);
zoneEl.removeEventListener('click', onClickCapture, { capture: true });
zoneEl.removeEventListener('pointercancel', onPointerCancel);
if (dragWrap) clearCarousel();
};
}
export function bindCalendarDayClicks(containerEl, onSelect, dayAttr = CALENDAR_DAY_ATTR) { export function bindCalendarDayClicks(containerEl, onSelect, dayAttr = CALENDAR_DAY_ATTR) {
if (!containerEl || typeof onSelect !== 'function') return; if (!containerEl || typeof onSelect !== 'function') return;
containerEl.addEventListener('click', (event) => { containerEl.addEventListener('click', (event) => {

View File

@@ -1,10 +1,7 @@
import { INGREDIENTS, RECIPES, PRODUCTS, getProductsForIngredient } from '../data/catalog.js?v=9'; import { INGREDIENTS, RECIPES, PRODUCTS, getProductsForIngredient } from '../data/catalog.js?v=9';
import { MEAL_SLOTS } from '../planner/mealSlots.js'; import { MEAL_SLOTS } from '../planner/mealSlots.js';
import { import {
addDays,
addMonths,
sameDay, sameDay,
sameMonth,
startOfDay, startOfDay,
startOfWeekMonday, startOfWeekMonday,
} from '../services/dateUtils.js'; } from '../services/dateUtils.js';
@@ -17,14 +14,17 @@ import {
import { dayHasAnyMeal, autoSelectProducts, saveLastProductSelection } from '../services/planIngredients.js?v=4'; import { dayHasAnyMeal, autoSelectProducts, saveLastProductSelection } from '../services/planIngredients.js?v=4';
import { loadPantry } from '../services/pantryShopping.js?v=2'; import { loadPantry } from '../services/pantryShopping.js?v=2';
import { import {
bindCollapsibleCalendarSwipeGesture,
bindCalendarDayClicks, bindCalendarDayClicks,
bindCalendarHorizontalSwipe, createCollapsibleCalendarHTML,
createCalendarTopbarHTML, createCalendarTopbarHTML,
createCalendarWeekdayHeaderHTML, formatCalendarPeriodLabel,
isCalendarOnToday, isCalendarOnToday,
renderCalendarGrid, renderCollapsibleCalendar,
syncCalendarTodayButton, syncCalendarTodayButton,
} from './mealCalendar.js?v=14'; syncCollapsibleCalendarMode,
syncCollapsibleCalendarToggleIcon,
} from './mealCalendar.js?v=15';
import { createIngredientCardController, getIngredientCardHTML } from './ingredientCard.js?v=20260417-116'; import { createIngredientCardController, getIngredientCardHTML } from './ingredientCard.js?v=20260417-116';
function esc(s) { function esc(s) {
@@ -52,25 +52,38 @@ export function getMealPlanEditorHTML() {
</button> </button>
</div> </div>
</div> </div>
<div id="mpe-cal-wrap" class="hidden relative z-[1] shrink-0 px-5 pt-3 pb-3 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important;"> <div id="mpe-cal-wrap" class="hidden relative z-[2] shrink-0 px-5 pt-3 pb-3 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important;">
<div id="mpe-top-shadow" class="pointer-events-none absolute inset-x-0 -bottom-3 h-3 opacity-0 transition-opacity duration-200" style="background:linear-gradient(to bottom, rgba(var(--overlay-rgb),0.12), rgba(var(--overlay-rgb),0.03), rgba(var(--overlay-rgb),0));"></div>
<div id="mpe-cal-section" class="hidden"> <div id="mpe-cal-section" class="hidden">
${createCalendarTopbarHTML({ ${createCalendarTopbarHTML({
todayId: 'mpe-cal-today', todayId: 'mpe-cal-today',
wrapperClass: 'mb-2 flex items-center justify-end gap-3', wrapperClass: 'mb-2 flex items-center justify-end gap-3',
})} })}
${createCalendarWeekdayHeaderHTML()} ${createCollapsibleCalendarHTML({
<div id="mpe-cal-grid" class="grid grid-cols-7 gap-1.5"></div> idPrefix: 'mpe-cal',
<button id="mpe-cal-toggle" type="button" class="w-full flex items-center justify-center py-1 mt-1 text-gray-400 hover:text-gray-600 transition-colors"><i id="mpe-cal-toggle-icon" class="fas fa-chevron-down text-[10px]"></i></button> swipeZoneId: 'mpe-cal-swipe-zone',
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mt-3 mb-2">Pora posiłku</p> weekWrapId: 'mpe-cal-week-wrap',
<div id="mpe-slot-chips" class="flex flex-wrap gap-1.5"></div> weekGridId: 'mpe-cal-week-grid',
monthWrapId: 'mpe-cal-month-wrap',
monthGridId: 'mpe-cal-month-grid',
toggleId: 'mpe-cal-toggle',
iconId: 'mpe-cal-toggle-icon',
weekWrapClass: 'overflow-x-hidden bg-[rgb(var(--app-bg-rgb))]',
monthWrapClass: 'bg-[rgb(var(--app-bg-rgb))]',
weekWrapStyle: 'overflow: hidden; max-height: 10rem; opacity: 1; padding-bottom: 0.25rem',
toggleClass: 'w-full flex items-center justify-center py-1 mt-1 text-gray-400 hover:text-gray-600 transition-colors',
})}
</div> </div>
</div> </div>
<div id="mpe-summary-wrap" class="relative z-[1] shrink-0 px-5 pb-3 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important;">
<div id="mpe-nutrition-section"></div>
<div id="mpe-servings-row" class="mt-3"></div>
<div id="mpe-top-shadow" class="pointer-events-none absolute inset-x-0 -bottom-3 h-3 opacity-0 transition-opacity duration-200" style="background:linear-gradient(to bottom, rgba(var(--overlay-rgb),0.12), rgba(var(--overlay-rgb),0.03), rgba(var(--overlay-rgb),0));"></div>
</div>
<div id="mpe-ing-scroll" class="flex-1 min-h-0 overflow-y-auto no-scrollbar px-5 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important; padding-bottom:calc(1.5rem + env(safe-area-inset-bottom));"> <div id="mpe-ing-scroll" class="flex-1 min-h-0 overflow-y-auto no-scrollbar px-5 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important; padding-bottom:calc(1.5rem + env(safe-area-inset-bottom));">
<div id="mpe-slot-section" class="pt-3 pb-1 hidden">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Pora posiłku</p>
<div id="mpe-slot-chips" class="flex flex-wrap gap-1.5"></div>
</div>
<div id="mpe-summary-wrap" class="relative z-[1] pt-3 pb-3 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important;">
<div id="mpe-nutrition-section"></div>
<div id="mpe-servings-row" class="mt-3"></div>
</div>
<div id="mpe-ing-section" class="mb-4"> <div id="mpe-ing-section" class="mb-4">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Składniki</p> <p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Składniki</p>
<div id="mpe-ing-list" class="space-y-1.5"></div> <div id="mpe-ing-list" class="space-y-1.5"></div>
@@ -155,6 +168,18 @@ export function setupMealPlanEditor() {
/* ── Calendar ──────────────────────────────────── */ /* ── Calendar ──────────────────────────────────── */
function resolveCalendarDayState(day, meta, plans = loadPlans(), today = startOfDay(new Date())) {
const isSelected = S.date && sameDay(day, S.date);
const isPast = day.getTime() < today.getTime();
return {
disabled: isPast && !isSelected,
dimmed: (isPast || (meta.mode === 'month' && !meta.inCurrentMonth)) && !isSelected,
showIndicator: meta.mode === 'month'
? meta.inCurrentMonth && dayHasAnyMeal(plans, day)
: dayHasAnyMeal(plans, day),
};
}
function renderCal() { function renderCal() {
const wrap = document.getElementById('mpe-cal-wrap'); const wrap = document.getElementById('mpe-cal-wrap');
const sec = document.getElementById('mpe-cal-section'); const sec = document.getElementById('mpe-cal-section');
@@ -169,45 +194,46 @@ export function setupMealPlanEditor() {
} }
wrap.classList.remove('hidden'); wrap.classList.remove('hidden');
sec.classList.remove('hidden'); sec.classList.remove('hidden');
const grid = document.getElementById('mpe-cal-grid'); const weekGrid = document.getElementById('mpe-cal-week-grid');
const monthGrid = document.getElementById('mpe-cal-month-grid');
const todayBtn = document.getElementById('mpe-cal-today'); const todayBtn = document.getElementById('mpe-cal-today');
const icon = document.getElementById('mpe-cal-toggle-icon'); const icon = document.getElementById('mpe-cal-toggle-icon');
if (!grid) return; if (!weekGrid || !monthGrid) return;
const today = startOfDay(new Date()); const today = startOfDay(new Date());
const plans = loadPlans(); const plans = loadPlans();
const mode = S.calExpanded ? 'month' : 'week'; const mode = S.calExpanded ? 'month' : 'week';
if (icon) { syncCollapsibleCalendarMode({
icon.className = S.calExpanded ? 'fas fa-chevron-up text-[10px]' : 'fas fa-chevron-down text-[10px]'; mode,
} weekWrapEl: document.getElementById('mpe-cal-week-wrap'),
monthWrapEl: document.getElementById('mpe-cal-month-wrap'),
activePaddingBottom: '0.25rem',
});
syncCollapsibleCalendarToggleIcon(icon, mode);
syncCalendarTodayButton( syncCalendarTodayButton(
todayBtn, todayBtn,
isCalendarOnToday(mode, startOfWeekMonday(S.calDate), S.calDate, S.date), isCalendarOnToday(mode, startOfWeekMonday(S.calDate), S.calDate, S.date),
S.date, S.date,
{
labelText: formatCalendarPeriodLabel(mode, S.calDate, S.calDate),
},
); );
renderCalendarGrid({ renderCollapsibleCalendar({
gridEl: grid, weekGridEl: weekGrid,
mode, monthGridEl: monthGrid,
anchorDate: S.calDate, weekAnchorDate: S.calDate,
monthAnchorDate: S.calDate,
selectedDate: S.date, selectedDate: S.date,
resolveDayState: (day, meta) => { resolveDayState: (day, meta) => resolveCalendarDayState(day, meta, plans, today),
const isSelected = S.date && sameDay(day, S.date);
const isPast = day.getTime() < today.getTime();
return {
disabled: isPast && !isSelected,
dimmed: (isPast || (meta.mode === 'month' && !meta.inCurrentMonth)) && !isSelected,
showIndicator: meta.mode === 'month'
? meta.inCurrentMonth && dayHasAnyMeal(plans, day)
: dayHasAnyMeal(plans, day),
};
},
}); });
syncScrollShadows(); syncScrollShadows();
} }
function renderSlots() { function renderSlots() {
const el = document.getElementById('mpe-slot-chips'); const el = document.getElementById('mpe-slot-chips');
const sec = document.getElementById('mpe-slot-section');
if (sec) sec.classList.toggle('hidden', !S.showCal);
if (!el || !S.showCal) return; if (!el || !S.showCal) return;
const r = RECIPES[S.recipeId]; const r = RECIPES[S.recipeId];
if (!r) return; if (!r) return;
@@ -611,50 +637,39 @@ export function setupMealPlanEditor() {
}); });
} }
document.getElementById('mpe-confirm-btn')?.addEventListener('click', handleConfirm); document.getElementById('mpe-confirm-btn')?.addEventListener('click', handleConfirm);
bindCalendarDayClicks(document.getElementById('mpe-cal-grid'), (date) => { const selectCalendarDate = (date) => {
S.date = date; S.date = date;
S.calDate = new Date(date); S.calDate = new Date(date);
renderCal(); renderCal();
});
bindCalendarHorizontalSwipe(document.getElementById('mpe-cal-grid'), {
onPrev: () => {
if (!S.showCal) return;
S.calDate = S.calExpanded ? addMonths(S.calDate, -1) : addDays(S.calDate, -7);
renderCal();
},
onNext: () => {
if (!S.showCal) return;
S.calDate = S.calExpanded ? addMonths(S.calDate, 1) : addDays(S.calDate, 7);
renderCal();
},
renderGhost: (ghostGrid, direction) => {
if (!S.showCal) return false;
const sign = direction === 'prev' ? -1 : 1;
const mode = S.calExpanded ? 'month' : 'week';
const anchor = S.calExpanded
? addMonths(S.calDate, sign)
: addDays(S.calDate, 7 * sign);
const today = startOfDay(new Date());
const plans = loadPlans();
renderCalendarGrid({
gridEl: ghostGrid,
mode,
anchorDate: anchor,
selectedDate: S.date,
resolveDayState: (day, meta) => {
const isSelected = S.date && sameDay(day, S.date);
const isPast = day.getTime() < today.getTime();
return {
disabled: isPast && !isSelected,
dimmed: (isPast || (meta.mode === 'month' && !meta.inCurrentMonth)) && !isSelected,
showIndicator: meta.mode === 'month'
? meta.inCurrentMonth && dayHasAnyMeal(plans, day)
: dayHasAnyMeal(plans, day),
}; };
bindCalendarDayClicks(document.getElementById('mpe-cal-week-grid'), selectCalendarDate);
bindCalendarDayClicks(document.getElementById('mpe-cal-month-grid'), selectCalendarDate);
bindCollapsibleCalendarSwipeGesture({
zoneEl: document.getElementById('mpe-cal-swipe-zone'),
weekWrapEl: document.getElementById('mpe-cal-week-wrap'),
monthWrapEl: document.getElementById('mpe-cal-month-wrap'),
getMode: () => (S.calExpanded ? 'month' : 'week'),
setMode: (mode) => {
S.calExpanded = mode === 'month';
}, },
}); getWeekAnchor: () => S.calDate,
setWeekAnchor: (date) => {
S.calDate = startOfDay(date);
}, },
getMonthAnchor: () => S.calDate,
setMonthAnchor: (date) => {
S.calDate = startOfDay(date);
},
getSelectedDate: () => S.date,
setSelectedDate: (date) => {
S.date = startOfDay(date);
},
rerender: renderCal,
resolveDayState: (day, meta) => resolveCalendarDayState(day, meta),
selectOnNavigateOutside: false,
enableVerticalModeSwipe: false,
}); });
document.getElementById('mpe-cal-today')?.addEventListener('click', () => { document.getElementById('mpe-cal-today')?.addEventListener('click', () => {
const today = startOfDay(new Date()); const today = startOfDay(new Date());
@@ -824,7 +839,10 @@ export function setupMealPlanEditor() {
if (e.target.closest('#mpe-add-btn')) { if (e.target.closest('#mpe-add-btn')) {
S.addOpen = true; S.addQuery = ''; S.addOpen = true; S.addQuery = '';
renderAddArea(); renderAddArea();
document.getElementById('mpe-add-search')?.focus(); requestAnimationFrame(() => {
document.getElementById('mpe-add-area')?.scrollIntoView({ behavior: 'smooth', block: 'end' });
document.getElementById('mpe-add-search')?.focus({ preventScroll: true });
});
return; return;
} }

View File

@@ -1,4 +1,5 @@
import { addDays, startOfMonth, startOfWeekMonday } from '../services/dateUtils.js'; import { addDays, startOfMonth } from '../services/dateUtils.js';
import { renderCalendarGrid } from './mealCalendar.js?v=15';
const DEFAULT_WEEKDAYS = ['pn', 'wt', 'śr', 'cz', 'pt', 'sb', 'nd']; const DEFAULT_WEEKDAYS = ['pn', 'wt', 'śr', 'cz', 'pt', 'sb', 'nd'];
const DEFAULT_MONTHS_LONG = [ const DEFAULT_MONTHS_LONG = [
@@ -7,14 +8,17 @@ const DEFAULT_MONTHS_LONG = [
]; ];
const DEFAULT_THEME = { const DEFAULT_THEME = {
selectedBg: 'rgb(var(--card-rgb))', selectedBorder: 'rgba(var(--text-emphasis-rgb),0.34)',
selectedBorder: 'rgb(var(--border-input-rgb))', selectedBorderClass: 'border',
selectedText: 'rgb(var(--text-emphasis-rgb))', selectedText: 'rgb(var(--text-emphasis-rgb))',
selectedDot: 'rgb(var(--text-emphasis-rgb))', selectedDot: 'rgb(var(--text-emphasis-rgb))',
selectedShadow: '0 0 0 1px rgba(var(--text-emphasis-rgb),0.10)',
bg: 'rgb(var(--app-bg-rgb))', bg: 'rgb(var(--app-bg-rgb))',
border: 'rgb(var(--card-raised-rgb))', border: 'transparent',
borderClass: 'border-0',
text: 'rgb(var(--text-body-soft-rgb))', text: 'rgb(var(--text-body-soft-rgb))',
dimmedBg: 'transparent', dimmedBg: 'transparent',
dimmedBorderClass: 'border-0',
dimText: 'rgb(var(--text-faint-rgb))', dimText: 'rgb(var(--text-faint-rgb))',
dimOpacity: 0.58, dimOpacity: 0.58,
dot: 'rgb(var(--text-faint-rgb))', dot: 'rgb(var(--text-faint-rgb))',
@@ -54,50 +58,30 @@ export function createSwipePopoverCalendarHTML({
monthLabelTextClass = 'text-[10px] font-semibold leading-none tabular-nums whitespace-nowrap', monthLabelTextClass = 'text-[10px] font-semibold leading-none tabular-nums whitespace-nowrap',
}) { }) {
const weekdayHeader = ` const weekdayHeader = `
<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));"> <div class="grid grid-cols-7 gap-1.5 text-center text-[8px] font-medium uppercase tracking-wide mb-1.5 leading-none" style="color:rgb(var(--text-dim-rgb));">
${weekdays.map((d) => `<div>${d}</div>`).join('')} ${weekdays.map((d) => `<div>${d}</div>`).join('')}
</div> </div>
`; `;
const gripLeft = `
<div data-swc-grip aria-hidden="true" style="position:absolute; left:0; top:50%; transform:translateY(-50%); width:0.76rem; height:1rem; pointer-events:none; opacity:0.66;">
<span style="position:absolute; left:0; top:50%; transform:translateY(-50%); width:1.5px; height:1.22rem; border-radius:999px; background:rgba(var(--text-faint-rgb),0.84);"></span>
<span style="position:absolute; left:0.16rem; top:50%; transform:translateY(-50%); width:1.5px; height:1.0rem; border-radius:999px; background:rgba(var(--text-faint-rgb),0.74);"></span>
<span style="position:absolute; left:0.34rem; top:50%; transform:translateY(-50%); width:1.5px; height:0.56rem; border-radius:999px; background:rgba(var(--text-faint-rgb),0.62);"></span>
</div>
`;
const gripRight = `
<div data-swc-grip aria-hidden="true" style="position:absolute; right:0; top:50%; transform:translateY(-50%); width:0.76rem; height:1rem; pointer-events:none; opacity:0.66;">
<span style="position:absolute; right:0; top:50%; transform:translateY(-50%); width:1.5px; height:1.22rem; border-radius:999px; background:rgba(var(--text-faint-rgb),0.84);"></span>
<span style="position:absolute; right:0.16rem; top:50%; transform:translateY(-50%); width:1.5px; height:1.0rem; border-radius:999px; background:rgba(var(--text-faint-rgb),0.74);"></span>
<span style="position:absolute; right:0.34rem; top:50%; transform:translateY(-50%); width:1.5px; height:0.56rem; border-radius:999px; background:rgba(var(--text-faint-rgb),0.62);"></span>
</div>
`;
return ` return `
<div class="pb-3 px-3 flex items-center justify-end gap-3"> <div class="pb-3 px-3 flex items-center justify-end gap-3">
<div class="flex h-[2.05rem] min-w-0 max-w-[min(100%,20rem)] items-center justify-center rounded-full border" style="background:rgb(var(--app-bg-rgb)); border-color:rgb(var(--card-raised-rgb));"> <div class="flex h-[2.05rem] min-w-0 max-w-[min(100%,20rem)] items-center justify-center rounded-full border" style="background:transparent; border-color:rgb(var(--border-input-rgb));">
<span id="${idPrefix}-month-label" class="h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center px-3 ${monthLabelTextClass}" style="color:rgb(var(--text-body-soft-rgb));"></span> <span id="${idPrefix}-month-label" class="h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center px-3 ${monthLabelTextClass}" style="color:rgb(var(--text-body-soft-rgb));"></span>
</div> </div>
</div> </div>
<div id="${idPrefix}-viewport" style="overflow:hidden; position:relative; touch-action:pan-y;"> <div id="${idPrefix}-viewport" style="overflow:hidden; position:relative; touch-action:pan-y; -webkit-user-select:none; user-select:none; cursor:grab;">
<div id="${idPrefix}-track" style="display:flex; align-items:flex-start; position:relative; will-change:transform;"> <div id="${idPrefix}-track" style="display:flex; align-items:flex-start; position:relative; will-change:transform;">
<div class="swc-panel" data-panel="prev" style="flex-shrink:0; pointer-events:none; position:relative; overflow:hidden;"> <div class="swc-panel" data-panel="prev" style="flex-shrink:0; pointer-events:none; position:relative; overflow:hidden;">
${weekdayHeader} ${weekdayHeader}
<div id="${idPrefix}-prev-grid" class="grid grid-cols-7 gap-1.5"></div> <div id="${idPrefix}-prev-grid" class="grid grid-cols-7 gap-1.5"></div>
${gripLeft}
${gripRight}
</div> </div>
<div class="swc-panel" data-panel="current" style="flex-shrink:0; position:relative; overflow:hidden;"> <div class="swc-panel" data-panel="current" style="flex-shrink:0; position:relative; overflow:hidden;">
${weekdayHeader} ${weekdayHeader}
<div id="${idPrefix}-grid" class="grid grid-cols-7 gap-1.5"></div> <div id="${idPrefix}-grid" class="grid grid-cols-7 gap-1.5"></div>
${gripLeft}
${gripRight}
</div> </div>
<div class="swc-panel" data-panel="next" style="flex-shrink:0; pointer-events:none; position:relative; overflow:hidden;"> <div class="swc-panel" data-panel="next" style="flex-shrink:0; pointer-events:none; position:relative; overflow:hidden;">
${weekdayHeader} ${weekdayHeader}
<div id="${idPrefix}-next-grid" class="grid grid-cols-7 gap-1.5"></div> <div id="${idPrefix}-next-grid" class="grid grid-cols-7 gap-1.5"></div>
${gripLeft}
${gripRight}
</div> </div>
</div> </div>
</div> </div>
@@ -111,6 +95,7 @@ export function initSwipePopoverCalendar({
theme = DEFAULT_THEME, theme = DEFAULT_THEME,
getMonthAnchor, getMonthAnchor,
setMonthAnchor, setMonthAnchor,
canNavigateToMonth,
getSelectionKeys, getSelectionKeys,
onSelectionCommit, onSelectionCommit,
resolveDayState, resolveDayState,
@@ -137,50 +122,37 @@ export function initSwipePopoverCalendar({
const SWIPE_THRESHOLD = 40; const SWIPE_THRESHOLD = 40;
const ANIMATION_MS = 260; const ANIMATION_MS = 260;
let viewportWidth = 0;
let panelWidth = 0; let panelWidth = 0;
let panelHandle = 0; let panelInset = 0;
let dragHandleWidth = 0;
let restOffset = 0; let restOffset = 0;
let animatingNav = false; let animatingNav = false;
let pendingRangeStart = null;
let suppressClickUntil = 0;
const panels = Array.from(track.querySelectorAll('.swc-panel')); const panels = Array.from(track.querySelectorAll('.swc-panel'));
const syncGripVisibility = (showAdjacent = false) => {
panels.forEach((panel) => {
const isCurrent = panel.dataset.panel === 'current';
panel.querySelectorAll('[data-swc-grip]').forEach((grip) => {
grip.style.opacity = (isCurrent || showAdjacent) ? '0.66' : '0';
});
});
};
const applyLayout = () => { const applyLayout = () => {
const vw = viewport.clientWidth || viewport.getBoundingClientRect().width; const vw = viewport.clientWidth || viewport.getBoundingClientRect().width;
if (!vw) return; if (!vw) return;
viewportWidth = vw; const computedInset = panelHandlePx == null
const computedHandle = panelHandlePx == null
? Math.round(vw * panelHandleRatio) ? Math.round(vw * panelHandleRatio)
: panelHandlePx; : panelHandlePx;
panelHandle = Math.max(panelHandleMin, Math.min(panelHandleMax, computedHandle)); panelInset = Math.max(panelHandleMin, Math.min(panelHandleMax, computedInset));
dragHandleWidth = panelHandle;
panelWidth = vw; panelWidth = vw;
restOffset = -panelWidth; restOffset = -panelWidth;
panels.forEach((panel) => { panels.forEach((panel) => {
panel.style.width = `${panelWidth}px`; panel.style.width = `${panelWidth}px`;
panel.style.boxSizing = 'border-box'; panel.style.boxSizing = 'border-box';
panel.style.paddingLeft = `${panelHandle}px`; panel.style.paddingLeft = `${panelInset}px`;
panel.style.paddingRight = `${panelHandle}px`; panel.style.paddingRight = `${panelInset}px`;
}); });
track.style.transition = 'none'; track.style.transition = 'none';
track.style.transform = `translate3d(${restOffset}px, 0, 0)`; track.style.transform = `translate3d(${restOffset}px, 0, 0)`;
syncGripVisibility(false);
}; };
const resetTrackPosition = () => { const resetTrackPosition = () => {
track.style.transition = 'none'; track.style.transition = 'none';
track.style.transform = `translate3d(${restOffset}px, 0, 0)`; track.style.transform = `translate3d(${restOffset}px, 0, 0)`;
syncGripVisibility(false);
}; };
const setDragTranslate = (dx, ms) => { const setDragTranslate = (dx, ms) => {
@@ -188,6 +160,27 @@ export function initSwipePopoverCalendar({
track.style.transform = `translate3d(${restOffset + dx}px, 0, 0)`; track.style.transform = `translate3d(${restOffset + dx}px, 0, 0)`;
}; };
const snapBack = () => {
setDragTranslate(0, ANIMATION_MS);
};
const getNavigationTarget = (monthDelta) => {
const anchor = normalizeMonth(getMonthAnchor());
return startOfMonth(new Date(anchor.getFullYear(), anchor.getMonth() + monthDelta, 1));
};
const canNavigate = (monthDelta) => {
if (typeof canNavigateToMonth !== 'function') return true;
const anchor = normalizeMonth(getMonthAnchor());
const target = getNavigationTarget(monthDelta);
return canNavigateToMonth(target, { currentMonth: anchor, monthDelta }) !== false;
};
const getAllowedDragDx = (dx) => {
if (dx === 0) return 0;
return canNavigate(dx > 0 ? -1 : 1) ? dx : 0;
};
const getSelectedSet = (previewSelection = null) => { const getSelectedSet = (previewSelection = null) => {
const raw = previewSelection ?? getSelectionKeys(); const raw = previewSelection ?? getSelectionKeys();
if (Array.isArray(raw)) return new Set(raw); if (Array.isArray(raw)) return new Set(raw);
@@ -197,60 +190,43 @@ export function initSwipePopoverCalendar({
const renderMonthGrid = (targetGrid, monthAnchor, selectedSet) => { const renderMonthGrid = (targetGrid, monthAnchor, selectedSet) => {
if (!targetGrid) return; if (!targetGrid) return;
const first = startOfMonth(monthAnchor); const calendarTheme = {
const gridStart = startOfWeekMonday(first); ...DEFAULT_THEME,
const cells = Array.from({ length: 42 }, (_, i) => addDays(gridStart, i)); ...theme,
targetGrid.innerHTML = cells.map((day) => { borderClass: theme.borderClass || DEFAULT_THEME.borderClass,
const dk = dateKeyLocal(day); dimmedBorderClass: theme.dimmedBorderClass || DEFAULT_THEME.dimmedBorderClass,
const inCurrentMonth = day.getMonth() === first.getMonth(); selectedBorderClass: theme.selectedBorderClass || DEFAULT_THEME.selectedBorderClass,
const isSelected = selectedSet.has(dk); };
renderCalendarGrid({
gridEl: targetGrid,
mode: 'month',
anchorDate: monthAnchor,
fixedWeekCount: 6,
selectedDate: null,
isSelectedDate: (day) => selectedSet.has(dateKeyLocal(day)),
dayAttr: 'data-dk',
getDayAttrValue: dateKeyLocal,
dayClassName: 'swc-day',
dayStyle: 'touch-action:pan-y;',
theme: calendarTheme,
resolveDayState: (day, { inCurrentMonth, isSelected }) => {
const resolved = (typeof resolveDayState === 'function' const resolved = (typeof resolveDayState === 'function'
? resolveDayState(day, { inCurrentMonth, isSelected }) ? resolveDayState(day, { inCurrentMonth, isSelected })
: {}) || {}; : {}) || {};
const disabled = !!resolved.disabled; return {
const dimmed = !!resolved.dimmed; disabled: !!resolved.disabled,
const showDot = !!resolved.showDot; dimmed: !!resolved.dimmed,
showIndicator: !!resolved.showDot,
let bg; };
let borderColor; },
let text; });
let borderClass = 'border';
if (isSelected) {
bg = theme.selectedBg;
borderColor = theme.selectedBorder;
text = theme.selectedText;
} else if (dimmed) {
bg = theme.dimmedBg;
borderColor = 'transparent';
text = theme.dimText;
borderClass = 'border-0';
} else {
bg = theme.bg;
borderColor = theme.border;
text = theme.text;
}
const opacity = dimmed && !isSelected ? String(theme.dimOpacity ?? 0.58) : '1';
const dotColor = isSelected ? theme.selectedDot : theme.dot;
const tag = disabled ? 'div' : 'button';
const attrs = disabled ? '' : `type="button" data-dk="${dk}"`;
const dayClass = disabled ? '' : ' swc-day';
return `
<${tag} ${attrs}
class="mx-auto flex h-[2.05rem] w-full min-w-0 max-w-full items-center justify-center rounded-full ${borderClass}${dayClass} text-xs font-medium leading-tight overflow-hidden"
style="background:${bg}; border-color:${borderColor}; color:${text}; opacity:${opacity}; touch-action:none;">
<span class="relative flex h-full w-full flex-col items-center justify-center">
<span class="text-[13px] font-semibold leading-none ${showDot ? '-translate-y-[0.18rem]' : ''}">${day.getDate()}</span>
${showDot ? `<span class="absolute left-1/2 w-1 h-1 -translate-x-1/2 rounded-full opacity-75" style="bottom:0.24rem; background:${dotColor};" aria-hidden="true"></span>` : ''}
</span>
</${tag}>
`;
}).join('');
}; };
const render = (previewSelection = null) => { const render = (previewSelection = null) => {
const anchor = normalizeMonth(getMonthAnchor()); const anchor = normalizeMonth(getMonthAnchor());
const selectedSet = getSelectedSet(previewSelection); const rangePreview = selectionMode === 'range' && pendingRangeStart ? [pendingRangeStart] : null;
const selectedSet = getSelectedSet(previewSelection ?? rangePreview);
if (monthLabelEl) monthLabelEl.textContent = monthLabel(anchor, monthsLong); if (monthLabelEl) monthLabelEl.textContent = monthLabel(anchor, monthsLong);
renderMonthGrid(gridEl, anchor, selectedSet); renderMonthGrid(gridEl, anchor, selectedSet);
renderMonthGrid(prevGridEl, new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1), selectedSet); renderMonthGrid(prevGridEl, new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1), selectedSet);
@@ -259,12 +235,15 @@ export function initSwipePopoverCalendar({
}; };
const commitNavigation = (monthDelta) => { const commitNavigation = (monthDelta) => {
if (!canNavigate(monthDelta)) {
snapBack();
return;
}
animatingNav = true; animatingNav = true;
const targetDx = monthDelta < 0 ? panelWidth : -panelWidth; const targetDx = monthDelta < 0 ? panelWidth : -panelWidth;
setDragTranslate(targetDx, ANIMATION_MS); setDragTranslate(targetDx, ANIMATION_MS);
setTimeout(() => { setTimeout(() => {
const anchor = normalizeMonth(getMonthAnchor()); setMonthAnchor(getNavigationTarget(monthDelta));
setMonthAnchor(startOfMonth(new Date(anchor.getFullYear(), anchor.getMonth() + monthDelta, 1)));
render(); render();
resetTrackPosition(); resetTrackPosition();
animatingNav = false; animatingNav = false;
@@ -272,49 +251,28 @@ export function initSwipePopoverCalendar({
}; };
if (selectionMode === 'range') { if (selectionMode === 'range') {
let dragStart = null; gridEl.addEventListener('click', (e) => {
let dragCurrent = null;
let dragging = false;
gridEl.addEventListener('pointerdown', (e) => {
if (animatingNav) return;
const btn = e.target.closest('.swc-day'); const btn = e.target.closest('.swc-day');
if (!btn) return; if (!btn) return;
e.stopPropagation(); e.stopPropagation();
dragStart = btn.dataset.dk; const selectedKey = btn.dataset.dk;
dragCurrent = btn.dataset.dk; if (!pendingRangeStart) {
dragging = true; pendingRangeStart = selectedKey;
gridEl.setPointerCapture(e.pointerId); if (typeof onSelectionCommit === 'function') onSelectionCommit([selectedKey]);
render([dragStart]); render([selectedKey]);
}); return;
gridEl.addEventListener('pointermove', (e) => {
if (!dragging || animatingNav) return;
e.preventDefault();
const el = document.elementFromPoint(e.clientX, e.clientY);
const btn = el?.closest('.swc-day');
if (btn?.dataset.dk && btn.dataset.dk !== dragCurrent) {
dragCurrent = btn.dataset.dk;
render(dayRange(dragStart, dragCurrent));
} }
});
gridEl.addEventListener('pointerup', () => { const range = dayRange(pendingRangeStart, selectedKey);
if (!dragging) return; pendingRangeStart = null;
dragging = false;
const range = dayRange(dragStart, dragCurrent);
dragStart = null;
dragCurrent = null;
if (typeof onSelectionCommit === 'function') onSelectionCommit(range); if (typeof onSelectionCommit === 'function') onSelectionCommit(range);
render(); render();
}); });
gridEl.addEventListener('pointercancel', () => {
dragging = false;
dragStart = null;
dragCurrent = null;
render();
});
} else { } else {
gridEl.addEventListener('click', (e) => { gridEl.addEventListener('click', (e) => {
const btn = e.target.closest('.swc-day'); const btn = e.target.closest('.swc-day');
if (!btn) return; if (!btn) return;
e.stopPropagation();
if (typeof onSelectionCommit === 'function') onSelectionCommit(btn.dataset.dk); if (typeof onSelectionCommit === 'function') onSelectionCommit(btn.dataset.dk);
render(); render();
}); });
@@ -325,22 +283,18 @@ export function initSwipePopoverCalendar({
let startY = 0; let startY = 0;
let moved = false; let moved = false;
let axis = null; let axis = null;
let hasPointerCapture = false;
viewport.addEventListener('pointerdown', (e) => { viewport.addEventListener('pointerdown', (e) => {
if (animatingNav || ptrId !== null) return; if (animatingNav || ptrId !== null) return;
if (e.pointerType === 'mouse' && e.button !== 0) return; if (e.pointerType === 'mouse' && e.button !== 0) return;
if (!panelWidth) applyLayout(); if (!panelWidth) applyLayout();
const rect = viewport.getBoundingClientRect();
const localX = e.clientX - rect.left;
const inLeft = localX <= dragHandleWidth;
const inRight = localX >= (viewportWidth - dragHandleWidth);
if (!inLeft && !inRight) return;
ptrId = e.pointerId; ptrId = e.pointerId;
startX = e.clientX; startX = e.clientX;
startY = e.clientY; startY = e.clientY;
moved = false; moved = false;
axis = null; axis = null;
try { viewport.setPointerCapture(e.pointerId); } catch (_) {} hasPointerCapture = false;
}); });
viewport.addEventListener('pointermove', (e) => { viewport.addEventListener('pointermove', (e) => {
@@ -350,28 +304,52 @@ export function initSwipePopoverCalendar({
if (!moved && (Math.abs(dx) > MOVE_THRESHOLD || Math.abs(dy) > MOVE_THRESHOLD)) { if (!moved && (Math.abs(dx) > MOVE_THRESHOLD || Math.abs(dy) > MOVE_THRESHOLD)) {
moved = true; moved = true;
axis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y'; axis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
if (axis === 'x') syncGripVisibility(true);
} }
if (axis === 'x') setDragTranslate(dx, 0); if (axis === 'x') {
e.preventDefault();
suppressClickUntil = Date.now() + 450;
if (!hasPointerCapture) {
try {
viewport.setPointerCapture(e.pointerId);
hasPointerCapture = true;
} catch (_) {}
}
setDragTranslate(getAllowedDragDx(dx), 0);
}
}); });
const endGesture = (e) => { const endGesture = (e) => {
if (e && e.pointerId !== ptrId) return; if (e && e.pointerId !== ptrId) return;
if (e && hasPointerCapture) {
try { viewport.releasePointerCapture(e.pointerId); } catch (_) {}
}
hasPointerCapture = false;
ptrId = null; ptrId = null;
if (!moved || axis !== 'x') return; if (!moved || axis !== 'x') return;
const dx = e ? e.clientX - startX : 0; const dx = e ? e.clientX - startX : 0;
if (Math.abs(dx) >= SWIPE_THRESHOLD) commitNavigation(dx > 0 ? -1 : 1); const monthDelta = dx > 0 ? -1 : 1;
if (Math.abs(dx) >= SWIPE_THRESHOLD && canNavigate(monthDelta)) commitNavigation(monthDelta);
else { else {
setDragTranslate(0, ANIMATION_MS); snapBack();
setTimeout(() => syncGripVisibility(false), ANIMATION_MS + 20);
} }
moved = false; moved = false;
axis = null; axis = null;
}; };
viewport.addEventListener('pointerup', endGesture); viewport.addEventListener('click', (e) => {
viewport.addEventListener('pointercancel', endGesture); if (Date.now() > suppressClickUntil) return;
suppressClickUntil = 0;
e.preventDefault();
e.stopPropagation();
}, true);
window.addEventListener('pointerup', endGesture);
window.addEventListener('pointercancel', endGesture);
window.addEventListener('resize', applyLayout); window.addEventListener('resize', applyLayout);
return { render, reapplyLayout: applyLayout, resetTrackPosition }; const clearPendingRange = () => {
pendingRangeStart = null;
render();
};
return { render, reapplyLayout: applyLayout, resetTrackPosition, clearPendingRange };
} }

View File

@@ -1,14 +1,10 @@
import { INGREDIENTS, RECIPES } from '../data/catalog.js?v=9'; import { INGREDIENTS, RECIPES } from '../data/catalog.js?v=9';
import { MEAL_SLOTS } from '../planner/mealSlots.js'; import { MEAL_SLOTS } from '../planner/mealSlots.js';
import { import {
addMonths,
addWeeks,
sameDay, sameDay,
sameMonth,
startOfDay, startOfDay,
startOfMonth, startOfMonth,
startOfWeekMonday, startOfWeekMonday,
weekContains,
} from '../services/dateUtils.js'; } from '../services/dateUtils.js';
import { import {
computeEntryNutrition, computeEntryNutrition,
@@ -26,17 +22,18 @@ import {
savePlans, savePlans,
} from '../services/planStore.js?v=2'; } from '../services/planStore.js?v=2';
import { import {
CALENDAR_HANDLE_CLASS,
CALENDAR_MONTHS_SHORT, CALENDAR_MONTHS_SHORT,
bindCollapsibleCalendarSwipeGesture,
bindCalendarDayClicks, bindCalendarDayClicks,
createCollapsibleCalendarHTML,
createCalendarTopbarHTML, createCalendarTopbarHTML,
createCalendarWeekdayHeaderHTML, formatCalendarPeriodLabel,
isCalendarOnToday, isCalendarOnToday,
renderCalendarGrid,
renderCollapsibleCalendar, renderCollapsibleCalendar,
syncCalendarTodayButton, syncCalendarTodayButton,
syncCollapsibleCalendarMode, syncCollapsibleCalendarMode,
} from '../ui/mealCalendar.js?v=14'; syncCollapsibleCalendarToggleIcon,
} from '../ui/mealCalendar.js?v=15';
import { import {
filterRecipesByQuery, filterRecipesByQuery,
renderRecipeGrid, renderRecipeGrid,
@@ -67,6 +64,9 @@ function syncTodayButton(mode, weekStart, monthAnchor, selected) {
document.getElementById('cal-go-today'), document.getElementById('cal-go-today'),
isCalendarOnToday(mode, weekStart, monthAnchor, selected), isCalendarOnToday(mode, weekStart, monthAnchor, selected),
selected, selected,
{
labelText: formatCalendarPeriodLabel(mode, weekStart, monthAnchor),
},
); );
} }
@@ -81,19 +81,7 @@ export function getMealPlannerHTML() {
wrapperClass: 'flex shrink-0 items-center justify-end', wrapperClass: 'flex shrink-0 items-center justify-end',
})} })}
</div> </div>
<div id="calendar-swipe-zone" class="overflow-x-hidden bg-[rgb(var(--app-bg-rgb))]" style="touch-action: none"> ${createCollapsibleCalendarHTML({ idPrefix: 'calendar' })}
<div id="calendar-week-wrap" class="px-3 overflow-x-hidden bg-[rgb(var(--app-bg-rgb))]" style="overflow: hidden; max-height: 10rem; opacity: 1; padding-bottom: 0.75rem">
${createCalendarWeekdayHeaderHTML()}
<div id="calendar-week-grid" class="grid grid-cols-7 gap-1.5 max-w-full overflow-x-hidden"></div>
</div>
<div id="calendar-month-wrap" class="px-3 bg-[rgb(var(--app-bg-rgb))]" style="overflow: hidden; max-height: 0; opacity: 0; padding-bottom: 0">
${createCalendarWeekdayHeaderHTML()}
<div id="calendar-month-grid" class="grid grid-cols-7 gap-1.5"></div>
</div>
<button id="calendar-mode-toggle" type="button" class="w-full flex items-center justify-center py-1 pb-2 pt-0.5 text-[rgb(var(--text-faint-rgb))] hover:text-[rgb(var(--text-body-soft-rgb))] transition-colors" aria-label="Przełącz widok kalendarza">
<i id="calendar-handle-icon" class="fas fa-chevron-down text-[10px]"></i>
</button>
</div>
</div> </div>
<div id="planner-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-24 bg-[rgb(var(--app-bg-rgb))]"> <div id="planner-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-24 bg-[rgb(var(--app-bg-rgb))]">
@@ -192,223 +180,7 @@ function syncModeToggle(mode) {
weekWrapEl: document.getElementById('calendar-week-wrap'), weekWrapEl: document.getElementById('calendar-week-wrap'),
monthWrapEl: document.getElementById('calendar-month-wrap'), monthWrapEl: document.getElementById('calendar-month-wrap'),
}); });
const icon = document.getElementById('calendar-handle-icon'); syncCollapsibleCalendarToggleIcon(document.getElementById('calendar-handle-icon'), mode);
if (icon) icon.className = mode === 'month' ? 'fas fa-chevron-up text-[10px]' : 'fas fa-chevron-down text-[10px]';
}
function bindCalendarSwipeGesture(state, rerender) {
const zone = document.getElementById('calendar-swipe-zone');
if (!zone) return;
const ANIMATION_MS = 260;
let startX = 0;
let startY = 0;
let ptrId = null;
let moved = false;
let axisLocked = null;
let suppressClickUntil = 0;
let animatingNav = false;
let dragWrap = null;
let wrapWidth = 0;
let prevGhost = null;
let nextGhost = null;
let prevWrapPosition = '';
let prevWrapOverflow = '';
const getActiveWrap = () => document.getElementById(
state.mode === 'week' ? 'calendar-week-wrap' : 'calendar-month-wrap',
);
const resolveDayStateForGhost = (day, meta) => {
const today = startOfDay(new Date());
const isSelected = sameDay(day, state.selected);
const isPast = day.getTime() < today.getTime();
return {
disabled: isPast && !isSelected,
dimmed: (isPast || (meta.mode === 'month' && !meta.inCurrentMonth)) && !isSelected,
showIndicator: meta.mode === 'month'
? meta.inCurrentMonth && dayHasAnyMeal(state.plans, day)
: dayHasAnyMeal(state.plans, day),
};
};
const buildGhost = (anchorDate, mode) => {
if (!dragWrap) return null;
const ghost = dragWrap.cloneNode(true);
ghost.removeAttribute('id');
ghost.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
ghost.style.position = 'absolute';
ghost.style.top = '0';
ghost.style.width = '100%';
ghost.style.pointerEvents = 'none';
ghost.setAttribute('aria-hidden', 'true');
let ghostGridEl = null;
ghost.querySelectorAll('.grid-cols-7').forEach((g) => {
if (!g.classList.contains('text-center')) ghostGridEl = g;
});
if (ghostGridEl) {
renderCalendarGrid({
gridEl: ghostGridEl,
mode,
anchorDate,
selectedDate: state.selected,
resolveDayState: resolveDayStateForGhost,
});
}
return ghost;
};
const activateCarousel = () => {
dragWrap = getActiveWrap();
if (!dragWrap) return;
wrapWidth = dragWrap.getBoundingClientRect().width || zone.getBoundingClientRect().width;
prevWrapPosition = dragWrap.style.position;
prevWrapOverflow = dragWrap.style.overflow;
dragWrap.style.position = 'relative';
dragWrap.style.overflow = 'visible';
const prevAnchor = state.mode === 'week'
? addWeeks(state.weekStart, -1)
: addMonths(state.monthAnchor, -1);
const nextAnchor = state.mode === 'week'
? addWeeks(state.weekStart, 1)
: addMonths(state.monthAnchor, 1);
prevGhost = buildGhost(prevAnchor, state.mode);
nextGhost = buildGhost(nextAnchor, state.mode);
if (prevGhost) {
prevGhost.style.left = `-${wrapWidth}px`;
dragWrap.appendChild(prevGhost);
}
if (nextGhost) {
nextGhost.style.left = `${wrapWidth}px`;
dragWrap.appendChild(nextGhost);
}
dragWrap.style.willChange = 'transform';
dragWrap.style.transition = 'none';
};
const clearCarousel = () => {
if (prevGhost?.parentNode) prevGhost.parentNode.removeChild(prevGhost);
if (nextGhost?.parentNode) nextGhost.parentNode.removeChild(nextGhost);
prevGhost = null;
nextGhost = null;
if (dragWrap) {
dragWrap.style.transition = '';
dragWrap.style.transform = '';
dragWrap.style.willChange = '';
dragWrap.style.position = prevWrapPosition;
dragWrap.style.overflow = prevWrapOverflow;
}
dragWrap = null;
};
const setTranslate = (x, ms) => {
if (!dragWrap) return;
dragWrap.style.transition = ms ? `transform ${ms}ms ease` : 'none';
dragWrap.style.transform = `translate3d(${x}px, 0, 0)`;
};
zone.addEventListener('pointerdown', (e) => {
if (ptrId !== null || animatingNav) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
startX = e.clientX;
startY = e.clientY;
ptrId = e.pointerId;
moved = false;
axisLocked = null;
try { zone.setPointerCapture(e.pointerId); } catch (_) {}
});
zone.addEventListener('pointermove', (e) => {
if (e.pointerId !== ptrId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && (Math.abs(dx) > 6 || Math.abs(dy) > 6)) {
moved = true;
axisLocked = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
if (axisLocked === 'x') activateCarousel();
}
if (axisLocked === 'x' && dragWrap) {
setTranslate(dx, 0);
}
});
zone.addEventListener('pointerup', (e) => {
if (e.pointerId !== ptrId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
ptrId = null;
if (!moved) return;
const horizontal = axisLocked === 'x';
if (horizontal && dragWrap) {
if (Math.abs(dx) < 40) {
setTranslate(0, ANIMATION_MS);
setTimeout(clearCarousel, ANIMATION_MS + 20);
return;
}
suppressClickUntil = performance.now() + 500;
animatingNav = true;
const targetX = dx > 0 ? wrapWidth : -wrapWidth;
setTranslate(targetX, ANIMATION_MS);
setTimeout(() => {
if (state.mode === 'week') {
state.weekStart = addWeeks(state.weekStart, dx > 0 ? -1 : 1);
if (!weekContains(state.weekStart, state.selected)) {
state.selected = new Date(state.weekStart);
}
} else {
state.monthAnchor = addMonths(state.monthAnchor, dx > 0 ? -1 : 1);
if (!sameMonth(state.monthAnchor, state.selected)) {
state.selected = startOfMonth(state.monthAnchor);
}
}
clearCarousel();
rerender();
animatingNav = false;
}, ANIMATION_MS);
return;
}
if (!horizontal && Math.abs(dy) >= 30) {
let triggered = false;
if (state.mode === 'week' && dy > 0) {
state.mode = 'month';
state.monthAnchor = startOfMonth(state.selected);
triggered = true;
} else if (state.mode === 'month' && dy < 0) {
state.mode = 'week';
state.weekStart = startOfWeekMonday(state.selected);
triggered = true;
}
if (triggered) {
suppressClickUntil = performance.now() + 350;
rerender();
}
}
});
zone.addEventListener('click', (ev) => {
if (performance.now() < suppressClickUntil) {
ev.stopPropagation();
ev.preventDefault();
suppressClickUntil = 0;
}
}, { capture: true });
zone.addEventListener('pointercancel', () => {
ptrId = null;
moved = false;
if (dragWrap) {
setTranslate(0, ANIMATION_MS);
setTimeout(clearCarousel, ANIMATION_MS + 20);
}
});
} }
function showPlannerToast(message) { function showPlannerToast(message) {
@@ -1291,17 +1063,8 @@ export function setupMealPlanner() {
maxMinutes: PICKER_FILTER_MAX_MINUTES, maxMinutes: PICKER_FILTER_MAX_MINUTES,
}; };
const rerender = () => { const resolveCalendarDayState = (day, meta) => {
syncModeToggle(state.mode);
syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected);
const today = startOfDay(new Date()); const today = startOfDay(new Date());
renderCollapsibleCalendar({
weekGridEl: weekGrid,
monthGridEl: monthGrid,
weekAnchorDate: state.weekStart,
monthAnchorDate: state.monthAnchor,
selectedDate: state.selected,
resolveDayState: (day, meta) => {
const isSelected = sameDay(day, state.selected); const isSelected = sameDay(day, state.selected);
const isPast = day.getTime() < today.getTime(); const isPast = day.getTime() < today.getTime();
return { return {
@@ -1311,7 +1074,18 @@ export function setupMealPlanner() {
? meta.inCurrentMonth && dayHasAnyMeal(state.plans, day) ? meta.inCurrentMonth && dayHasAnyMeal(state.plans, day)
: dayHasAnyMeal(state.plans, day), : dayHasAnyMeal(state.plans, day),
}; };
}, };
const rerender = () => {
syncModeToggle(state.mode);
syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected);
renderCollapsibleCalendar({
weekGridEl: weekGrid,
monthGridEl: monthGrid,
weekAnchorDate: state.weekStart,
monthAnchorDate: state.monthAnchor,
selectedDate: state.selected,
resolveDayState: resolveCalendarDayState,
}); });
renderDayContent(state, persist); renderDayContent(state, persist);
}; };
@@ -1559,7 +1333,30 @@ export function setupMealPlanner() {
rerender(); rerender();
}; };
bindCalendarSwipeGesture(state, rerender); bindCollapsibleCalendarSwipeGesture({
zoneEl: document.getElementById('calendar-swipe-zone'),
weekWrapEl: document.getElementById('calendar-week-wrap'),
monthWrapEl: document.getElementById('calendar-month-wrap'),
getMode: () => state.mode,
setMode: (mode) => {
state.mode = mode;
},
getWeekAnchor: () => state.weekStart,
setWeekAnchor: (date) => {
state.weekStart = startOfWeekMonday(date);
},
getMonthAnchor: () => state.monthAnchor,
setMonthAnchor: (date) => {
state.monthAnchor = startOfMonth(date);
},
getSelectedDate: () => state.selected,
setSelectedDate: (date) => {
state.selected = startOfDay(date);
},
rerender,
resolveDayState: resolveCalendarDayState,
selectOnNavigateOutside: false,
});
document.getElementById('calendar-mode-toggle')?.addEventListener('click', () => { document.getElementById('calendar-mode-toggle')?.addEventListener('click', () => {
if (state.mode === 'week') { if (state.mode === 'week') {

View File

@@ -10,6 +10,11 @@ import {
createSwipePopoverCalendarHTML, createSwipePopoverCalendarHTML,
initSwipePopoverCalendar, initSwipePopoverCalendar,
} from '../ui/swipePopoverCalendar.js'; } from '../ui/swipePopoverCalendar.js';
import {
createCalendarPopoverHTML,
stabilizeSwipeCalendarLayout,
syncCalendarPopoverVisibility,
} from '../ui/calendarPopover.js';
import { createIngredientCardController, getIngredientCardHTML } from '../ui/ingredientCard.js?v=20260417-116'; import { createIngredientCardController, getIngredientCardHTML } from '../ui/ingredientCard.js?v=20260417-116';
/* ── helpers ── */ /* ── helpers ── */
@@ -86,10 +91,10 @@ const PANTRY_CALENDAR_THEME = {
dimmedBg: 'transparent', dimmedBg: 'transparent',
dimmedBorder: 'transparent', dimmedBorder: 'transparent',
dot: 'rgb(var(--text-faint-rgb))', dot: 'rgb(var(--text-faint-rgb))',
selectedBg: 'rgb(var(--card-rgb))', selectedBorder: 'rgba(var(--text-emphasis-rgb),0.34)',
selectedBorder: 'rgb(var(--border-input-rgb))',
selectedText: 'rgb(var(--text-emphasis-rgb))', selectedText: 'rgb(var(--text-emphasis-rgb))',
selectedDot: 'rgb(var(--text-emphasis-rgb))', selectedDot: 'rgb(var(--text-emphasis-rgb))',
selectedShadow: '0 0 0 1px rgba(var(--text-emphasis-rgb),0.10)',
}; };
/* ── state ── */ /* ── state ── */
@@ -202,9 +207,10 @@ export function getPantryHTML() {
</div> </div>
<div id="pantry-calendar-popover" class="absolute left-0 right-0 top-full mt-2 rounded-[1.35rem] py-3 transition-all duration-200 pointer-events-none" style="background:rgb(var(--sunken-rgb)) !important; border:1px solid rgb(var(--border-input-rgb)) !important; box-shadow:var(--shadow-shell) !important; opacity:0; transform:translateY(-6px) scale(0.98);"> ${createCalendarPopoverHTML({
${createSwipePopoverCalendarHTML({ idPrefix: 'pantry-cal' })} id: 'pantry-calendar-popover',
</div> calendarHTML: createSwipePopoverCalendarHTML({ idPrefix: 'pantry-cal' }),
})}
<div id="pantry-search-shell" class="absolute inset-0 flex items-center gap-2 rounded-full px-3 transition-all duration-200 pointer-events-none" style="background:rgb(var(--sunken-rgb)) !important; border:1px solid rgb(var(--border-input-rgb)) !important; box-shadow:var(--shadow-shell) !important; opacity:0; transform:translateY(-2px) scale(0.98);"> <div id="pantry-search-shell" class="absolute inset-0 flex items-center gap-2 rounded-full px-3 transition-all duration-200 pointer-events-none" style="background:rgb(var(--sunken-rgb)) !important; border:1px solid rgb(var(--border-input-rgb)) !important; box-shadow:var(--shadow-shell) !important; opacity:0; transform:translateY(-2px) scale(0.98);">
<i class="fas fa-search text-[13px] shrink-0" style="color:rgb(var(--text-dim-rgb));"></i> <i class="fas fa-search text-[13px] shrink-0" style="color:rgb(var(--text-dim-rgb));"></i>
@@ -254,19 +260,13 @@ function syncHorizonUI() {
defaultRow.style.pointerEvents = showDefault ? 'auto' : 'none'; defaultRow.style.pointerEvents = showDefault ? 'auto' : 'none';
} }
if (compactPill) { syncCalendarPopoverVisibility({
compactPill.style.setProperty('background', showCalendar ? 'rgb(var(--sunken-rgb))' : 'rgb(var(--card-rgb))', 'important'); popup: popover,
compactPill.style.setProperty('border-color', showCalendar ? 'rgb(var(--border-input-rgb))' : 'rgb(var(--border-card-rgb))', 'important'); isOpen: showCalendar,
} chevron,
trigger: compactPill,
if (popover) { triggerImportant: true,
popover.style.opacity = showCalendar ? '1' : '0'; });
popover.style.transform = showCalendar ? 'translateY(0) scale(1)' : 'translateY(-6px) scale(0.98)';
popover.style.pointerEvents = showCalendar ? 'auto' : 'none';
}
if (chevron) {
chevron.style.transform = showCalendar ? 'rotate(180deg)' : 'rotate(0deg)';
}
if (filterPopover) { if (filterPopover) {
filterPopover.style.opacity = showFilter ? '1' : '0'; filterPopover.style.opacity = showFilter ? '1' : '0';
@@ -313,6 +313,11 @@ function bindPantryCalendarInteractions() {
if (nextAnchor.getTime() < minAnchor.getTime()) return; if (nextAnchor.getTime() < minAnchor.getTime()) return;
calendarMonthAnchor = nextAnchor; calendarMonthAnchor = nextAnchor;
}, },
canNavigateToMonth: (nextMonth) => {
const nextAnchor = startOfMonth(nextMonth);
const minAnchor = startOfMonth(getToday());
return nextAnchor.getTime() >= minAnchor.getTime();
},
getSelectionKeys: () => dateKey(horizonEndDate), getSelectionKeys: () => dateKey(horizonEndDate),
onSelectionCommit: (selectedKey) => { onSelectionCommit: (selectedKey) => {
selectHorizonDate(new Date(`${selectedKey}T00:00:00`)); selectHorizonDate(new Date(`${selectedKey}T00:00:00`));
@@ -410,9 +415,9 @@ function openCalendar() {
isFilterOpen = false; isFilterOpen = false;
isCalendarOpen = true; isCalendarOpen = true;
syncHorizonUI(); syncHorizonUI();
requestAnimationFrame(() => { stabilizeSwipeCalendarLayout({
pantryCalendar?.reapplyLayout(); calendar: pantryCalendar,
pantryCalendar?.resetTrackPosition(); viewport: 'pantry-cal-viewport',
}); });
} }

View File

@@ -16,6 +16,7 @@ import { aggregateSelectedDaysIngredientNeed } from '../services/planIngredients
import { loadPlans, dateKey } from '../services/planStore.js?v=2'; import { loadPlans, dateKey } from '../services/planStore.js?v=2';
import { addDays, startOfDay, startOfMonth } from '../services/dateUtils.js'; import { addDays, startOfDay, startOfMonth } from '../services/dateUtils.js';
import { createSwipePopoverCalendarHTML, initSwipePopoverCalendar } from '../ui/swipePopoverCalendar.js'; import { createSwipePopoverCalendarHTML, initSwipePopoverCalendar } from '../ui/swipePopoverCalendar.js';
import { createCalendarPopoverController, createCalendarPopoverHTML } from '../ui/calendarPopover.js';
import { showAppToast } from '../ui/toast.js'; import { showAppToast } from '../ui/toast.js';
/* ── helpers ── */ /* ── helpers ── */
@@ -64,6 +65,7 @@ let expandedAmount = 0;
let calendarOpen = false; let calendarOpen = false;
let calendarMonth = startOfMonth(new Date()); let calendarMonth = startOfMonth(new Date());
let shoppingCalendar = null; let shoppingCalendar = null;
let shoppingCalendarPopover = null;
/* ── day helpers ── */ /* ── day helpers ── */
@@ -133,14 +135,13 @@ export function getShoppingListHTML() {
</button> </button>
<!-- popup calendar (absolute, overlays content below) --> <!-- 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;"> ${createCalendarPopoverHTML({
<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);"> id: 'sl-calendar-popup',
${createSwipePopoverCalendarHTML({ calendarHTML: createSwipePopoverCalendarHTML({
idPrefix: 'sl-cal', idPrefix: 'sl-cal',
weekdays: WEEKDAY_SHORT, weekdays: WEEKDAY_SHORT,
}),
})} })}
</div>
</div>
<!-- popup bought (absolute, overlays content below) --> <!-- popup bought (absolute, overlays content below) -->
<div id="sl-bought-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 id="sl-bought-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;">
@@ -188,12 +189,12 @@ function initShoppingCalendar() {
showDot: dateKey(day) === todayKey() && !isSelected, showDot: dateKey(day) === todayKey() && !isSelected,
}), }),
theme: { theme: {
selectedBg: 'rgb(var(--card-rgb))', selectedBorder: 'rgba(var(--text-emphasis-rgb),0.34)',
selectedBorder: 'rgb(var(--border-input-rgb))',
selectedText: 'rgb(var(--text-emphasis-rgb))', selectedText: 'rgb(var(--text-emphasis-rgb))',
selectedDot: 'rgb(var(--text-emphasis-rgb))', selectedDot: 'rgb(var(--text-emphasis-rgb))',
selectedShadow: '0 0 0 1px rgba(var(--text-emphasis-rgb),0.10)',
bg: 'rgb(var(--app-bg-rgb))', bg: 'rgb(var(--app-bg-rgb))',
border: 'rgb(var(--card-raised-rgb))', border: 'transparent',
text: 'rgb(var(--text-body-soft-rgb))', text: 'rgb(var(--text-body-soft-rgb))',
dimmedBg: 'transparent', dimmedBg: 'transparent',
dimText: CALENDAR_DIM_TEXT, dimText: CALENDAR_DIM_TEXT,
@@ -201,6 +202,14 @@ function initShoppingCalendar() {
dot: 'rgb(var(--text-faint-rgb))', dot: 'rgb(var(--text-faint-rgb))',
}, },
}); });
shoppingCalendarPopover = createCalendarPopoverController({
popupId: 'sl-calendar-popup',
viewportId: 'sl-cal-viewport',
triggerId: 'sl-range-pill',
chevronId: 'sl-range-chevron',
getCalendar: () => shoppingCalendar,
hideViewportDuringLayout: true,
});
} }
function updatePillLabel() { function updatePillLabel() {
@@ -212,63 +221,12 @@ function openCalendar() {
if (boughtPopupOpen) closeBoughtPopup(); if (boughtPopupOpen) closeBoughtPopup();
calendarOpen = true; calendarOpen = true;
calendarMonth = startOfMonth(new Date()); calendarMonth = startOfMonth(new Date());
const popup = document.getElementById('sl-calendar-popup'); shoppingCalendarPopover?.open();
const viewport = document.getElementById('sl-cal-viewport');
const chevron = document.getElementById('sl-range-chevron');
const pill = document.getElementById('sl-range-pill');
if (popup) {
popup.style.pointerEvents = 'auto';
popup.style.opacity = '1';
popup.style.transform = 'translateY(0) scale(1)';
}
if (chevron) chevron.style.transform = 'rotate(180deg)';
if (pill) {
pill.style.background = 'rgb(var(--sunken-rgb))';
pill.style.borderColor = 'rgb(var(--border-input-rgb))';
}
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() { function closeCalendar() {
calendarOpen = false; calendarOpen = false;
const popup = document.getElementById('sl-calendar-popup'); shoppingCalendarPopover?.close({ clearPendingRange: true });
const chevron = document.getElementById('sl-range-chevron');
const pill = document.getElementById('sl-range-pill');
if (popup) {
popup.style.pointerEvents = 'none';
popup.style.opacity = '0';
popup.style.transform = 'translateY(-6px) scale(0.98)';
}
if (chevron) chevron.style.transform = '';
if (pill) {
pill.style.background = 'rgb(var(--card-rgb))';
pill.style.borderColor = 'rgb(var(--border-card-rgb))';
}
shoppingCalendar?.resetTrackPosition();
} }
function toggleCalendar() { function toggleCalendar() {
@@ -326,11 +284,11 @@ function activeItemHtml(item) {
const stepAmt = isExpanded ? expandedAmount : Math.max(step, Math.round(item.shortfall / step) * step); const stepAmt = isExpanded ? expandedAmount : Math.max(step, Math.round(item.shortfall / step) * step);
return ` return `
<div class="sl-swipe-wrap relative rounded-xl mb-1.5 overflow-hidden" style="background:rgb(var(--success-rgb)); box-shadow:var(--shadow-card);"> <div class="sl-swipe-wrap relative rounded-xl mb-1.5 overflow-hidden" style="box-shadow:var(--shadow-card);">
<div class="sl-swipe-bg-buy absolute inset-0 flex items-center pr-5 justify-end"> <div class="sl-swipe-bg-buy absolute inset-0 flex items-center pr-5 justify-end" style="background:rgb(var(--success-rgb)); opacity:0;">
<i class="fas fa-check text-white text-lg"></i> <i class="fas fa-check text-white text-lg"></i>
</div> </div>
<div class="sl-swipe-inner rounded-xl" style="background:rgb(var(--card-rgb)); position:relative;" <div class="sl-swipe-inner" style="background:rgb(var(--card-rgb)); position:relative;"
data-id="${esc(item.ingredientId)}" data-unit="${esc(item.unit)}" data-shortfall="${item.shortfall}"> data-id="${esc(item.ingredientId)}" data-unit="${esc(item.unit)}" data-shortfall="${item.shortfall}">
<div class="sl-item-main flex items-center gap-3 py-1.5 px-3 cursor-pointer select-none"> <div class="sl-item-main flex items-center gap-3 py-1.5 px-3 cursor-pointer select-none">
${mediaHtml} ${mediaHtml}
@@ -370,11 +328,11 @@ function boughtItemHtml(entry) {
: `<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style="background:rgb(var(--card-soft-rgb));"><i class="fas ${icon} text-xs" style="color:rgb(var(--text-faint-rgb));"></i></div>`; : `<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style="background:rgb(var(--card-soft-rgb));"><i class="fas ${icon} text-xs" style="color:rgb(var(--text-faint-rgb));"></i></div>`;
return ` return `
<div class="sl-swipe-wrap relative rounded-xl mb-1.5 overflow-hidden" style="background:rgb(var(--danger-rgb)); box-shadow:var(--shadow-card);"> <div class="sl-swipe-wrap relative rounded-xl mb-1.5 overflow-hidden" style="box-shadow:var(--shadow-card);">
<div class="sl-swipe-bg-undo absolute inset-0 flex items-center pl-5 justify-start"> <div class="sl-swipe-bg-undo absolute inset-0 flex items-center pl-5 justify-start" style="background:rgb(var(--danger-rgb)); opacity:0;">
<i class="fas fa-rotate-left text-white text-lg"></i> <i class="fas fa-rotate-left text-white text-lg"></i>
</div> </div>
<div class="sl-swipe-inner flex items-center gap-3 py-1.5 px-3 rounded-xl" style="background:rgb(var(--card-rgb)); position:relative;" data-entry-id="${esc(entry.id)}"> <div class="sl-swipe-inner flex items-center gap-3 py-1.5 px-3" style="background:rgb(var(--card-rgb)); position:relative;" data-entry-id="${esc(entry.id)}">
${mediaHtml} ${mediaHtml}
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<span class="block text-[13px] font-medium leading-tight truncate" style="color:rgb(var(--text-body-rgb));">${esc(entry.name)}</span> <span class="block text-[13px] font-medium leading-tight truncate" style="color:rgb(var(--text-body-rgb));">${esc(entry.name)}</span>
@@ -389,6 +347,10 @@ function boughtItemHtml(entry) {
function attachSwipe(container, opts) { function attachSwipe(container, opts) {
const inner = container.querySelector('.sl-swipe-inner'); const inner = container.querySelector('.sl-swipe-inner');
if (!inner) return; if (!inner) return;
const bgBuy = container.querySelector('.sl-swipe-bg-buy');
const bgUndo = container.querySelector('.sl-swipe-bg-undo');
const showBg = (el) => { if (el) el.style.opacity = '1'; };
const hideBgs = () => { if (bgBuy) bgBuy.style.opacity = '0'; if (bgUndo) bgUndo.style.opacity = '0'; };
let startX = 0, startY = 0, dx = 0, tracking = false, decided = false, goingH = false; let startX = 0, startY = 0, dx = 0, tracking = false, decided = false, goingH = false;
container.addEventListener('pointerdown', (e) => { container.addEventListener('pointerdown', (e) => {
@@ -407,10 +369,10 @@ function attachSwipe(container, opts) {
decided = true; decided = true;
goingH = Math.abs(ddx) > Math.abs(ddy); goingH = Math.abs(ddx) > Math.abs(ddy);
} }
if (!goingH) { tracking = false; inner.style.transform = ''; return; } if (!goingH) { tracking = false; inner.style.transform = ''; hideBgs(); return; }
dx = ddx; dx = ddx;
if (dx > 0 && opts.onRight) inner.style.transform = `translateX(${Math.min(dx, 90)}px)`; if (dx > 0 && opts.onRight) { inner.style.transform = `translateX(${Math.min(dx, 90)}px)`; showBg(bgBuy); }
else if (dx < 0 && opts.onLeft) inner.style.transform = `translateX(${Math.max(dx, -90)}px)`; else if (dx < 0 && opts.onLeft) { inner.style.transform = `translateX(${Math.max(dx, -90)}px)`; showBg(bgUndo); }
}); });
const finish = () => { const finish = () => {
@@ -426,6 +388,7 @@ function attachSwipe(container, opts) {
setTimeout(opts.onLeft, 180); setTimeout(opts.onLeft, 180);
} else { } else {
inner.style.transform = ''; inner.style.transform = '';
hideBgs();
} }
dx = 0; dx = 0;
}; };
@@ -435,6 +398,7 @@ function attachSwipe(container, opts) {
tracking = false; tracking = false;
inner.style.transition = 'transform 0.2s ease'; inner.style.transition = 'transform 0.2s ease';
inner.style.transform = ''; inner.style.transform = '';
hideBgs();
dx = 0; dx = 0;
}); });
} }