Files
recipe-mockup/js/ui/mealCalendar.js
ulfrxdev 08a275093c
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m16s
Unify calendar code
2026-04-20 23:44:18 +02:00

842 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
addDays,
addMonths,
addWeeks,
sameDay,
sameMonth,
startOfDay,
startOfMonth,
startOfWeekMonday,
weekContains,
} from '../services/dateUtils.js';
export const CALENDAR_MONTHS_LONG = [
'Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec',
'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień',
];
export const CALENDAR_MONTHS_SHORT = [
'sty', 'lut', 'mar', 'kwi', 'maj', 'cze',
'lip', 'sie', 'wrz', 'paź', 'lis', 'gru',
];
export const CALENDAR_WEEKDAYS_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'so', 'nd'];
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';
function escapeAttrValue(value) {
return String(value)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '&lt;');
}
function getCalendarDayHTML(day, meta, dayState, dayAttr, theme = {}, options = {}) {
const { mode, selectedDate } = meta;
const isSelected = typeof meta.isSelected === 'boolean'
? meta.isSelected
: !!(selectedDate && sameDay(day, selectedDate));
const showIndicator = !!(dayState.showIndicator || dayState.showDot);
const isDisabled = !!dayState.disabled;
const isDimmed = !!dayState.dimmed && !isSelected;
const defaultBg = 'rgb(var(--card-soft-rgb))';
const defaultBorder = 'rgb(var(--card-strong-rgb))';
const defaultText = 'rgb(var(--text-body-soft-rgb))';
let bg;
let borderColor;
let text;
let borderClass = theme.borderClass || 'border';
let shadow = theme.shadow || 'none';
if (isSelected) {
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))';
text = theme.selectedText || 'rgb(var(--text-emphasis-rgb))';
shadow = theme.selectedShadow || shadow;
borderClass = theme.selectedBorderClass || 'border';
} else if (isDimmed) {
bg = theme.dimmedBg ?? 'transparent';
text = theme.dimText || 'rgb(var(--text-faint-rgb))';
borderClass = theme.dimmedBorderClass || 'border-0';
} else {
bg = theme.bg || defaultBg;
borderColor = theme.border || defaultBorder;
text = theme.text || defaultText;
}
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 borderStyle = borderColor ? `border-color:${borderColor};` : 'border:none;';
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'
? 'relative flex h-full w-full flex-col items-center justify-center'
: 'relative flex h-full w-full items-center justify-center';
const dotBottom = mode === 'month' ? '0.24rem' : '0.2rem';
const tagName = isDisabled ? 'div' : 'button';
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 `
<${tagName}${buttonAttrs}
class="${outerClass}"
style="background:${bg};${borderStyle}color:${text};opacity:${opacity};box-shadow:${shadow};${dayStyle}">
<span class="${innerClass}">
<span class="text-[13px] font-semibold leading-none ${showIndicator ? '-translate-y-[0.18rem]' : ''}">${day.getDate()}</span>
${showIndicator
? `<span class="absolute left-1/2 w-1 h-1 -translate-x-1/2 rounded-full opacity-75" style="bottom:${dotBottom};background:${dot};" aria-hidden="true"></span>`
: ''}
</span>
</${tagName}>
`;
}
export function getCalendarMonthCells(monthAnchor, { fixedWeekCount = null } = {}) {
const first = startOfMonth(monthAnchor);
const startGrid = startOfWeekMonday(first);
const cells = [];
const maxCells = fixedWeekCount ? fixedWeekCount * 7 : 42;
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);
}
return { cells, month: first.getMonth() };
}
function getDayState(day, meta, resolveDayState) {
if (typeof resolveDayState !== 'function') {
return {
disabled: false,
dimmed: meta.mode === 'month' && !meta.inCurrentMonth,
showIndicator: false,
};
}
const resolved = resolveDayState(day, meta) || {};
return {
disabled: !!resolved.disabled,
dimmed: !!resolved.dimmed,
showIndicator: !!(resolved.showIndicator || resolved.showDot),
};
}
export function createCalendarWeekdayHeaderHTML(labels = CALENDAR_WEEKDAYS_SHORT, {
wrapperClass = 'grid grid-cols-7 gap-1.5 text-center text-[8px] font-medium text-gray-400 uppercase tracking-wide mb-1 leading-none',
} = {}) {
return `
<div class="${wrapperClass}">
${labels.map((label) => `<div>${label}</div>`).join('')}
</div>
`;
}
export function createCalendarTopbarHTML({
todayId,
wrapperClass = 'px-4 pt-4 pb-3 flex items-center justify-end',
controlsStyle = 'background:transparent;border-color:rgb(var(--card-strong-rgb));',
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',
}) {
return `
<div class="${wrapperClass}">
<div class="flex h-[2.05rem] min-w-0 max-w-[min(100%,20rem)] items-center justify-center rounded-full border" style="${controlsStyle}">
<button type="button" id="${todayId}"
class="${todayButtonActiveClass}"
data-cal-active-class="${todayButtonActiveClass}"
data-cal-dim-class="${todayButtonDimClass}">
</button>
</div>
</div>
`;
}
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) {
return `${CALENDAR_MONTHS_LONG[date.getMonth()]} ${date.getFullYear()}`;
}
export function formatCalendarSelectedDate(date) {
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) {
const today = startOfDay(new Date());
if (!sameDay(selectedDate, today)) return false;
if (mode === 'week') return weekContains(weekStart, today);
return startOfMonth(monthAnchor).getTime() === startOfMonth(today).getTime();
}
/**
* Środkowy przycisk pokazuje wybraną datę; działa jak „Dziś” (skok do bieżącego okresu).
* Styl pozostaje jak aktywny — bez wyciszania przy isOnToday.
*/
export function syncCalendarTodayButton(buttonEl, isOnToday, selectedDate, options = {}) {
if (!buttonEl) return;
const {
ariaLabelGo = 'Przejdź do dzisiejszego dnia',
ariaLabelCurrent = 'Widok jest ustawiony na bieżący okres',
labelText,
} = options;
const active = buttonEl.dataset.calActiveClass
|| '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 (labelText != null) {
buttonEl.textContent = labelText;
} else if (selectedDate != null) {
buttonEl.textContent = formatCalendarSelectedDate(selectedDate);
}
buttonEl.className = active;
buttonEl.removeAttribute('disabled');
buttonEl.setAttribute('aria-disabled', isOnToday ? 'true' : 'false');
buttonEl.setAttribute('aria-label', isOnToday ? ariaLabelCurrent : ariaLabelGo);
}
export function renderCalendarGrid({
gridEl,
mode,
anchorDate,
selectedDate,
isSelectedDate,
resolveDayState,
dayAttr = CALENDAR_DAY_ATTR,
getDayAttrValue,
dayClassName = '',
dayStyle = '',
fixedWeekCount = null,
theme,
}) {
if (!gridEl) return;
if (mode === 'week') {
const weekStart = startOfWeekMonday(anchorDate);
const cells = [];
for (let i = 0; i < 7; i++) {
const day = addDays(weekStart, i);
const selected = typeof isSelectedDate === 'function'
? !!isSelectedDate(day, { mode, selectedDate, inCurrentMonth: true })
: !!(selectedDate && sameDay(day, selectedDate));
const meta = {
mode,
selectedDate,
inCurrentMonth: true,
isSelected: selected,
};
cells.push(getCalendarDayHTML(
day,
meta,
getDayState(day, meta, resolveDayState),
dayAttr,
theme,
{ getDayAttrValue, dayClassName, dayStyle },
));
}
gridEl.innerHTML = cells.join('');
return;
}
const { cells, month } = getCalendarMonthCells(anchorDate, { fixedWeekCount });
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 = {
mode,
selectedDate,
inCurrentMonth,
isSelected: selected,
};
return getCalendarDayHTML(
day,
meta,
getDayState(day, meta, resolveDayState),
dayAttr,
theme,
{ getDayAttrValue, dayClassName, dayStyle },
);
}).join('');
}
export function renderCollapsibleCalendar({
weekGridEl,
monthGridEl,
weekAnchorDate,
monthAnchorDate,
selectedDate,
resolveDayState,
dayAttr = CALENDAR_DAY_ATTR,
theme,
}) {
renderCalendarGrid({
gridEl: weekGridEl,
mode: 'week',
anchorDate: weekAnchorDate,
selectedDate,
resolveDayState,
dayAttr,
theme,
});
renderCalendarGrid({
gridEl: monthGridEl,
mode: 'month',
anchorDate: monthAnchorDate,
selectedDate,
resolveDayState,
dayAttr,
theme,
});
}
export function syncCollapsibleCalendarMode({
mode,
weekWrapEl,
monthWrapEl,
handleEl,
activePaddingBottom = '0.75rem',
weekMaxHeight = '10rem',
monthMaxHeight = '17.25rem',
}) {
if (weekWrapEl) {
weekWrapEl.style.maxHeight = mode === 'week' ? weekMaxHeight : '0';
weekWrapEl.style.opacity = mode === 'week' ? '1' : '0';
weekWrapEl.style.paddingBottom = mode === 'week' ? activePaddingBottom : '0';
}
if (monthWrapEl) {
monthWrapEl.style.maxHeight = mode === 'month' ? monthMaxHeight : '0';
monthWrapEl.style.opacity = mode === 'month' ? '1' : '0';
monthWrapEl.style.paddingBottom = mode === 'month' ? activePaddingBottom : '0';
}
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) {
if (!containerEl || typeof onSelect !== 'function') return;
containerEl.addEventListener('click', (event) => {
const button = event.target.closest(`[${dayAttr}]`);
if (!button || !containerEl.contains(button)) return;
const ts = Number(button.getAttribute(dayAttr));
if (!Number.isFinite(ts)) return;
onSelect(new Date(ts), button, event);
});
}
/**
* Binds a carousel-style horizontal swipe on zoneEl. Swipe right → onPrev,
* swipe left → onNext. Pass `renderGhost(ghostGridEl, direction)` to render
* adjacent periods that appear alongside the zone during the gesture. The
* callback can return `false` to block that direction (ghost not added).
* Returns an unbind function.
*/
export function bindCalendarHorizontalSwipe(zoneEl, {
onPrev,
onNext,
renderGhost,
threshold = 40,
animationMs = 260,
} = {}) {
if (!zoneEl) return () => {};
let ptrId = null;
let startX = 0;
let startY = 0;
let moved = false;
let axisLocked = null;
let suppressClickUntil = 0;
let animatingNav = false;
let wrapWidth = 0;
let prevGhost = null;
let nextGhost = null;
let savedStyles = null;
let carouselActive = false;
const prevTouchAction = zoneEl.style.touchAction;
const prevUserSelect = zoneEl.style.userSelect;
zoneEl.style.touchAction = 'pan-y';
zoneEl.style.userSelect = 'none';
const buildGhost = (direction) => {
if (typeof renderGhost !== 'function') return null;
const ghost = zoneEl.cloneNode(false);
ghost.removeAttribute('id');
ghost.style.position = 'absolute';
ghost.style.top = '0';
ghost.style.width = '100%';
ghost.style.pointerEvents = 'none';
ghost.setAttribute('aria-hidden', 'true');
let ok = true;
try {
const res = renderGhost(ghost, direction);
if (res === false) ok = false;
} catch (_) { ok = false; }
return ok ? ghost : null;
};
const activateCarousel = () => {
if (typeof renderGhost !== 'function') return;
wrapWidth = zoneEl.getBoundingClientRect().width;
if (wrapWidth <= 0) return;
const parentEl = zoneEl.parentElement;
savedStyles = {
position: zoneEl.style.position,
overflow: zoneEl.style.overflow,
parentEl,
parentOverflow: parentEl ? parentEl.style.overflow : '',
};
zoneEl.style.position = 'relative';
zoneEl.style.overflow = 'visible';
if (parentEl) parentEl.style.overflow = 'hidden';
prevGhost = buildGhost('prev');
if (prevGhost) {
prevGhost.style.left = `-${wrapWidth}px`;
zoneEl.appendChild(prevGhost);
}
nextGhost = buildGhost('next');
if (nextGhost) {
nextGhost.style.left = `${wrapWidth}px`;
zoneEl.appendChild(nextGhost);
}
zoneEl.style.willChange = 'transform';
zoneEl.style.transition = 'none';
carouselActive = true;
};
const clearCarousel = () => {
if (prevGhost?.parentNode) prevGhost.parentNode.removeChild(prevGhost);
if (nextGhost?.parentNode) nextGhost.parentNode.removeChild(nextGhost);
prevGhost = null;
nextGhost = null;
if (savedStyles) {
zoneEl.style.position = savedStyles.position;
zoneEl.style.overflow = savedStyles.overflow;
if (savedStyles.parentEl) savedStyles.parentEl.style.overflow = savedStyles.parentOverflow;
savedStyles = null;
}
zoneEl.style.transition = '';
zoneEl.style.transform = '';
zoneEl.style.willChange = '';
carouselActive = false;
};
const setTranslate = (x, ms) => {
zoneEl.style.transition = ms ? `transform ${ms}ms ease` : 'none';
zoneEl.style.transform = `translate3d(${x}px, 0, 0)`;
};
const onDown = (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 { zoneEl.setPointerCapture(e.pointerId); } catch (_) {}
if (e.pointerType === 'mouse') e.preventDefault();
};
const onMove = (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' && carouselActive) {
let tx = dx;
if (dx > 0 && !prevGhost) tx = dx * 0.15;
if (dx < 0 && !nextGhost) tx = dx * 0.15;
setTranslate(tx, 0);
}
};
const onUp = (e) => {
if (e.pointerId !== ptrId) return;
const dx = e.clientX - startX;
ptrId = null;
if (!moved || axisLocked !== 'x' || !carouselActive) {
if (carouselActive) {
setTranslate(0, animationMs);
setTimeout(clearCarousel, animationMs + 20);
}
return;
}
const directionGhost = dx > 0 ? prevGhost : nextGhost;
const handler = dx > 0 ? onPrev : onNext;
const passes = Math.abs(dx) >= threshold
&& directionGhost
&& typeof handler === 'function';
if (!passes) {
setTranslate(0, animationMs);
setTimeout(clearCarousel, animationMs + 20);
return;
}
suppressClickUntil = performance.now() + 500;
animatingNav = true;
const targetX = dx > 0 ? wrapWidth : -wrapWidth;
setTranslate(targetX, animationMs);
setTimeout(() => {
clearCarousel();
handler();
animatingNav = false;
}, animationMs);
};
const onClickCapture = (ev) => {
if (performance.now() < suppressClickUntil) {
ev.stopPropagation();
ev.preventDefault();
suppressClickUntil = 0;
}
};
const onCancel = () => {
ptrId = null;
moved = false;
if (carouselActive) {
setTranslate(0, animationMs);
setTimeout(clearCarousel, animationMs + 20);
}
};
zoneEl.addEventListener('pointerdown', onDown);
zoneEl.addEventListener('pointermove', onMove);
zoneEl.addEventListener('pointerup', onUp);
zoneEl.addEventListener('pointercancel', onCancel);
zoneEl.addEventListener('click', onClickCapture, { capture: true });
return () => {
zoneEl.removeEventListener('pointerdown', onDown);
zoneEl.removeEventListener('pointermove', onMove);
zoneEl.removeEventListener('pointerup', onUp);
zoneEl.removeEventListener('pointercancel', onCancel);
zoneEl.removeEventListener('click', onClickCapture, { capture: true });
zoneEl.style.touchAction = prevTouchAction;
zoneEl.style.userSelect = prevUserSelect;
if (carouselActive) clearCarousel();
};
}