356 lines
13 KiB
JavaScript
356 lines
13 KiB
JavaScript
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_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)',
|
|
selectedBorderClass: 'border',
|
|
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',
|
|
borderClass: 'border-0',
|
|
text: 'rgb(var(--text-body-soft-rgb))',
|
|
dimmedBg: 'transparent',
|
|
dimmedBorderClass: 'border-0',
|
|
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 = `
|
|
<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('')}
|
|
</div>
|
|
`;
|
|
|
|
return `
|
|
<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: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>
|
|
</div>
|
|
</div>
|
|
<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 class="swc-panel" data-panel="prev" style="flex-shrink:0; pointer-events:none; position:relative; overflow:hidden;">
|
|
${weekdayHeader}
|
|
<div id="${idPrefix}-prev-grid" class="grid grid-cols-7 gap-1.5"></div>
|
|
</div>
|
|
<div class="swc-panel" data-panel="current" style="flex-shrink:0; position:relative; overflow:hidden;">
|
|
${weekdayHeader}
|
|
<div id="${idPrefix}-grid" class="grid grid-cols-7 gap-1.5"></div>
|
|
</div>
|
|
<div class="swc-panel" data-panel="next" style="flex-shrink:0; pointer-events:none; position:relative; overflow:hidden;">
|
|
${weekdayHeader}
|
|
<div id="${idPrefix}-next-grid" class="grid grid-cols-7 gap-1.5"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 calendarTheme = {
|
|
...DEFAULT_THEME,
|
|
...theme,
|
|
borderClass: theme.borderClass || DEFAULT_THEME.borderClass,
|
|
dimmedBorderClass: theme.dimmedBorderClass || DEFAULT_THEME.dimmedBorderClass,
|
|
selectedBorderClass: theme.selectedBorderClass || DEFAULT_THEME.selectedBorderClass,
|
|
};
|
|
|
|
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'
|
|
? resolveDayState(day, { inCurrentMonth, isSelected })
|
|
: {}) || {};
|
|
return {
|
|
disabled: !!resolved.disabled,
|
|
dimmed: !!resolved.dimmed,
|
|
showIndicator: !!resolved.showDot,
|
|
};
|
|
},
|
|
});
|
|
};
|
|
|
|
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 };
|
|
}
|