import { addDays, startOfMonth, startOfWeekMonday } from '../services/dateUtils.js';
const DEFAULT_WEEKDAYS = ['pn', 'wt', 'śr', 'cz', 'pt', 'sb', 'nd'];
const DEFAULT_MONTHS_LONG = [
'Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec',
'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień',
];
const DEFAULT_THEME = {
selectedBorder: 'rgba(var(--text-emphasis-rgb),0.34)',
selectedText: '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))',
border: 'transparent',
text: 'rgb(var(--text-body-soft-rgb))',
dimmedBg: 'transparent',
dimText: 'rgb(var(--text-faint-rgb))',
dimOpacity: 0.58,
dot: 'rgb(var(--text-faint-rgb))',
};
function dateKeyLocal(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function keyToDate(key) {
return new Date(`${key}T00:00:00`);
}
function normalizeMonth(date) {
return startOfMonth(date instanceof Date ? date : new Date());
}
function monthLabel(date, monthsLong) {
return `${monthsLong[date.getMonth()]} ${date.getFullYear()}`;
}
function dayRange(aKey, bKey) {
const a = keyToDate(aKey);
const b = keyToDate(bKey);
const [from, to] = a <= b ? [a, b] : [b, a];
const out = [];
for (let d = new Date(from); d <= to; d = addDays(d, 1)) out.push(dateKeyLocal(d));
return out;
}
export function createSwipePopoverCalendarHTML({
idPrefix,
weekdays = DEFAULT_WEEKDAYS,
monthLabelTextClass = 'text-[10px] font-semibold leading-none tabular-nums whitespace-nowrap',
}) {
const weekdayHeader = `
${weekdays.map((d) => `
${d}
`).join('')}
`;
return `
`;
}
export function initSwipePopoverCalendar({
idPrefix,
selectionMode = 'single',
monthsLong = DEFAULT_MONTHS_LONG,
theme = DEFAULT_THEME,
getMonthAnchor,
setMonthAnchor,
canNavigateToMonth,
getSelectionKeys,
onSelectionCommit,
resolveDayState,
panelHandlePx = null,
panelHandleRatio = 0.045,
panelHandleMin = 10,
panelHandleMax = 16,
}) {
const monthLabelEl = document.getElementById(`${idPrefix}-month-label`);
const gridEl = document.getElementById(`${idPrefix}-grid`);
const prevGridEl = document.getElementById(`${idPrefix}-prev-grid`);
const nextGridEl = document.getElementById(`${idPrefix}-next-grid`);
const track = document.getElementById(`${idPrefix}-track`);
const viewport = document.getElementById(`${idPrefix}-viewport`);
if (!gridEl || !track || !viewport) {
return {
render: () => {},
reapplyLayout: () => {},
resetTrackPosition: () => {},
};
}
const MOVE_THRESHOLD = 6;
const SWIPE_THRESHOLD = 40;
const ANIMATION_MS = 260;
let panelWidth = 0;
let panelInset = 0;
let restOffset = 0;
let animatingNav = false;
let pendingRangeStart = null;
let suppressClickUntil = 0;
const panels = Array.from(track.querySelectorAll('.swc-panel'));
const applyLayout = () => {
const vw = viewport.clientWidth || viewport.getBoundingClientRect().width;
if (!vw) return;
const computedInset = panelHandlePx == null
? Math.round(vw * panelHandleRatio)
: panelHandlePx;
panelInset = Math.max(panelHandleMin, Math.min(panelHandleMax, computedInset));
panelWidth = vw;
restOffset = -panelWidth;
panels.forEach((panel) => {
panel.style.width = `${panelWidth}px`;
panel.style.boxSizing = 'border-box';
panel.style.paddingLeft = `${panelInset}px`;
panel.style.paddingRight = `${panelInset}px`;
});
track.style.transition = 'none';
track.style.transform = `translate3d(${restOffset}px, 0, 0)`;
};
const resetTrackPosition = () => {
track.style.transition = 'none';
track.style.transform = `translate3d(${restOffset}px, 0, 0)`;
};
const setDragTranslate = (dx, ms) => {
track.style.transition = ms ? `transform ${ms}ms ease` : 'none';
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 raw = previewSelection ?? getSelectionKeys();
if (Array.isArray(raw)) return new Set(raw);
if (typeof raw === 'string' && raw) return new Set([raw]);
return new Set();
};
const renderMonthGrid = (targetGrid, monthAnchor, selectedSet) => {
if (!targetGrid) return;
const first = startOfMonth(monthAnchor);
const gridStart = startOfWeekMonday(first);
const cells = Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
targetGrid.innerHTML = cells.map((day) => {
const dk = dateKeyLocal(day);
const inCurrentMonth = day.getMonth() === first.getMonth();
const isSelected = selectedSet.has(dk);
const resolved = (typeof resolveDayState === 'function'
? resolveDayState(day, { inCurrentMonth, isSelected })
: {}) || {};
const disabled = !!resolved.disabled;
const dimmed = !!resolved.dimmed;
const showDot = !!resolved.showDot;
let bg;
let borderColor;
let text;
let shadow = 'none';
let borderClass = 'border-0';
if (dimmed) {
bg = theme.dimmedBg ?? DEFAULT_THEME.dimmedBg;
borderColor = 'transparent';
text = theme.dimText || DEFAULT_THEME.dimText;
} else {
bg = theme.bg || DEFAULT_THEME.bg;
borderColor = theme.border || DEFAULT_THEME.border;
text = theme.text || DEFAULT_THEME.text;
}
if (isSelected) {
borderColor = theme.selectedBorder || DEFAULT_THEME.selectedBorder;
text = theme.selectedText || DEFAULT_THEME.selectedText;
shadow = theme.selectedShadow || DEFAULT_THEME.selectedShadow;
borderClass = 'border';
}
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}; box-shadow:${shadow}; touch-action:pan-y;">
${day.getDate()}
${showDot ? `` : ''}
${tag}>
`;
}).join('');
};
const render = (previewSelection = null) => {
const anchor = normalizeMonth(getMonthAnchor());
const rangePreview = selectionMode === 'range' && pendingRangeStart ? [pendingRangeStart] : null;
const selectedSet = getSelectedSet(previewSelection ?? rangePreview);
if (monthLabelEl) monthLabelEl.textContent = monthLabel(anchor, monthsLong);
renderMonthGrid(gridEl, anchor, selectedSet);
renderMonthGrid(prevGridEl, new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1), selectedSet);
renderMonthGrid(nextGridEl, new Date(anchor.getFullYear(), anchor.getMonth() + 1, 1), selectedSet);
applyLayout();
};
const commitNavigation = (monthDelta) => {
if (!canNavigate(monthDelta)) {
snapBack();
return;
}
animatingNav = true;
const targetDx = monthDelta < 0 ? panelWidth : -panelWidth;
setDragTranslate(targetDx, ANIMATION_MS);
setTimeout(() => {
setMonthAnchor(getNavigationTarget(monthDelta));
render();
resetTrackPosition();
animatingNav = false;
}, ANIMATION_MS);
};
if (selectionMode === 'range') {
gridEl.addEventListener('click', (e) => {
const btn = e.target.closest('.swc-day');
if (!btn) return;
e.stopPropagation();
const selectedKey = btn.dataset.dk;
if (!pendingRangeStart) {
pendingRangeStart = selectedKey;
if (typeof onSelectionCommit === 'function') onSelectionCommit([selectedKey]);
render([selectedKey]);
return;
}
const range = dayRange(pendingRangeStart, selectedKey);
pendingRangeStart = null;
if (typeof onSelectionCommit === 'function') onSelectionCommit(range);
render();
});
} else {
gridEl.addEventListener('click', (e) => {
const btn = e.target.closest('.swc-day');
if (!btn) return;
e.stopPropagation();
if (typeof onSelectionCommit === 'function') onSelectionCommit(btn.dataset.dk);
render();
});
}
let ptrId = null;
let startX = 0;
let startY = 0;
let moved = false;
let axis = null;
let hasPointerCapture = false;
viewport.addEventListener('pointerdown', (e) => {
if (animatingNav || ptrId !== null) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
if (!panelWidth) applyLayout();
ptrId = e.pointerId;
startX = e.clientX;
startY = e.clientY;
moved = false;
axis = null;
hasPointerCapture = false;
});
viewport.addEventListener('pointermove', (e) => {
if (e.pointerId !== ptrId || animatingNav) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && (Math.abs(dx) > MOVE_THRESHOLD || Math.abs(dy) > MOVE_THRESHOLD)) {
moved = true;
axis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
}
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) => {
if (e && e.pointerId !== ptrId) return;
if (e && hasPointerCapture) {
try { viewport.releasePointerCapture(e.pointerId); } catch (_) {}
}
hasPointerCapture = false;
ptrId = null;
if (!moved || axis !== 'x') return;
const dx = e ? e.clientX - startX : 0;
const monthDelta = dx > 0 ? -1 : 1;
if (Math.abs(dx) >= SWIPE_THRESHOLD && canNavigate(monthDelta)) commitNavigation(monthDelta);
else {
snapBack();
}
moved = false;
axis = null;
};
viewport.addEventListener('click', (e) => {
if (Date.now() > suppressClickUntil) return;
suppressClickUntil = 0;
e.preventDefault();
e.stopPropagation();
}, true);
window.addEventListener('pointerup', endGesture);
window.addEventListener('pointercancel', endGesture);
window.addEventListener('resize', applyLayout);
const clearPendingRange = () => {
pendingRangeStart = null;
render();
};
return { render, reapplyLayout: applyLayout, resetTrackPosition, clearPendingRange };
}