Swipeable calendar
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m15s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m15s
This commit is contained in:
@@ -114,29 +114,20 @@ export function createCalendarWeekdayHeaderHTML(labels = CALENDAR_WEEKDAYS_SHORT
|
||||
}
|
||||
|
||||
export function createCalendarTopbarHTML({
|
||||
prevId,
|
||||
todayId,
|
||||
nextId,
|
||||
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));',
|
||||
navButtonClass = 'shrink-0 w-7 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[rgb(var(--text-body-soft-rgb))] transition-colors',
|
||||
todayButtonActiveClass = 'h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center rounded-full bg-transparent px-1.5 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-2 text-[10px] font-semibold leading-none text-[rgb(var(--text-faint-rgb))] cursor-default',
|
||||
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',
|
||||
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 gap-px rounded-full border px-0.5" style="${controlsStyle}">
|
||||
<button type="button" id="${prevId}" class="${navButtonClass}" aria-label="Poprzedni okres">
|
||||
<i class="fas fa-chevron-left text-[10px]" aria-hidden="true"></i>
|
||||
</button>
|
||||
<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>
|
||||
<button type="button" id="${nextId}" class="${navButtonClass}" aria-label="Następny okres">
|
||||
<i class="fas fa-chevron-right text-[10px]" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -168,7 +159,7 @@ export function syncCalendarTodayButton(buttonEl, isOnToday, selectedDate, optio
|
||||
ariaLabelCurrent = 'Widok jest ustawiony na bieżący okres',
|
||||
} = options;
|
||||
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-1.5 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-[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';
|
||||
if (selectedDate != null) {
|
||||
buttonEl.textContent = formatCalendarSelectedDate(selectedDate);
|
||||
}
|
||||
@@ -275,3 +266,208 @@ export function bindCalendarDayClicks(containerEl, onSelect, dayAttr = CALENDAR_
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,12 +18,13 @@ import { dayHasAnyMeal, autoSelectProducts, saveLastProductSelection } from '../
|
||||
import { loadPantry } from '../services/pantryShopping.js?v=2';
|
||||
import {
|
||||
bindCalendarDayClicks,
|
||||
bindCalendarHorizontalSwipe,
|
||||
createCalendarTopbarHTML,
|
||||
createCalendarWeekdayHeaderHTML,
|
||||
isCalendarOnToday,
|
||||
renderCalendarGrid,
|
||||
syncCalendarTodayButton,
|
||||
} from './mealCalendar.js?v=11';
|
||||
} from './mealCalendar.js?v=14';
|
||||
import { createIngredientCardController, getIngredientCardHTML } from './ingredientCard.js?v=20260417-116';
|
||||
|
||||
function esc(s) {
|
||||
@@ -54,9 +55,7 @@ export function getMealPlanEditorHTML() {
|
||||
<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-section" class="hidden">
|
||||
${createCalendarTopbarHTML({
|
||||
prevId: 'mpe-cal-prev',
|
||||
todayId: 'mpe-cal-today',
|
||||
nextId: 'mpe-cal-next',
|
||||
wrapperClass: 'mb-2 flex items-center justify-end gap-3',
|
||||
})}
|
||||
${createCalendarWeekdayHeaderHTML()}
|
||||
@@ -618,15 +617,44 @@ export function setupMealPlanEditor() {
|
||||
renderCal();
|
||||
});
|
||||
|
||||
document.getElementById('mpe-cal-prev')?.addEventListener('click', () => {
|
||||
if (!S.showCal) return;
|
||||
S.calDate = S.calExpanded ? addMonths(S.calDate, -1) : addDays(S.calDate, -7);
|
||||
renderCal();
|
||||
});
|
||||
document.getElementById('mpe-cal-next')?.addEventListener('click', () => {
|
||||
if (!S.showCal) return;
|
||||
S.calDate = S.calExpanded ? addMonths(S.calDate, 1) : addDays(S.calDate, 7);
|
||||
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),
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
document.getElementById('mpe-cal-today')?.addEventListener('click', () => {
|
||||
const today = startOfDay(new Date());
|
||||
|
||||
377
js/ui/swipePopoverCalendar.js
Normal file
377
js/ui/swipePopoverCalendar.js
Normal file
@@ -0,0 +1,377 @@
|
||||
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 = {
|
||||
selectedBg: 'rgb(var(--card-rgb))',
|
||||
selectedBorder: 'rgb(var(--border-input-rgb))',
|
||||
selectedText: 'rgb(var(--text-emphasis-rgb))',
|
||||
selectedDot: 'rgb(var(--text-emphasis-rgb))',
|
||||
bg: 'rgb(var(--app-bg-rgb))',
|
||||
border: 'rgb(var(--card-raised-rgb))',
|
||||
text: 'rgb(var(--text-body-soft-rgb))',
|
||||
dimmedBg: 'transparent',
|
||||
dimText: '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 leading-none" style="color:rgb(var(--text-dim-rgb));">
|
||||
${weekdays.map((d) => `<div>${d}</div>`).join('')}
|
||||
</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 `
|
||||
<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));">
|
||||
<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;">
|
||||
<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>
|
||||
${gripLeft}
|
||||
${gripRight}
|
||||
</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>
|
||||
${gripLeft}
|
||||
${gripRight}
|
||||
</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>
|
||||
${gripLeft}
|
||||
${gripRight}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function initSwipePopoverCalendar({
|
||||
idPrefix,
|
||||
selectionMode = 'single',
|
||||
monthsLong = DEFAULT_MONTHS_LONG,
|
||||
theme = DEFAULT_THEME,
|
||||
getMonthAnchor,
|
||||
setMonthAnchor,
|
||||
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 viewportWidth = 0;
|
||||
let panelWidth = 0;
|
||||
let panelHandle = 0;
|
||||
let dragHandleWidth = 0;
|
||||
let restOffset = 0;
|
||||
let animatingNav = false;
|
||||
|
||||
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 vw = viewport.clientWidth || viewport.getBoundingClientRect().width;
|
||||
if (!vw) return;
|
||||
viewportWidth = vw;
|
||||
const computedHandle = panelHandlePx == null
|
||||
? Math.round(vw * panelHandleRatio)
|
||||
: panelHandlePx;
|
||||
panelHandle = Math.max(panelHandleMin, Math.min(panelHandleMax, computedHandle));
|
||||
dragHandleWidth = panelHandle;
|
||||
panelWidth = vw;
|
||||
restOffset = -panelWidth;
|
||||
panels.forEach((panel) => {
|
||||
panel.style.width = `${panelWidth}px`;
|
||||
panel.style.boxSizing = 'border-box';
|
||||
panel.style.paddingLeft = `${panelHandle}px`;
|
||||
panel.style.paddingRight = `${panelHandle}px`;
|
||||
});
|
||||
track.style.transition = 'none';
|
||||
track.style.transform = `translate3d(${restOffset}px, 0, 0)`;
|
||||
syncGripVisibility(false);
|
||||
};
|
||||
|
||||
const resetTrackPosition = () => {
|
||||
track.style.transition = 'none';
|
||||
track.style.transform = `translate3d(${restOffset}px, 0, 0)`;
|
||||
syncGripVisibility(false);
|
||||
};
|
||||
|
||||
const setDragTranslate = (dx, ms) => {
|
||||
track.style.transition = ms ? `transform ${ms}ms ease` : 'none';
|
||||
track.style.transform = `translate3d(${restOffset + dx}px, 0, 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 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 anchor = normalizeMonth(getMonthAnchor());
|
||||
const selectedSet = getSelectedSet(previewSelection);
|
||||
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) => {
|
||||
animatingNav = true;
|
||||
const targetDx = monthDelta < 0 ? panelWidth : -panelWidth;
|
||||
setDragTranslate(targetDx, ANIMATION_MS);
|
||||
setTimeout(() => {
|
||||
const anchor = normalizeMonth(getMonthAnchor());
|
||||
setMonthAnchor(startOfMonth(new Date(anchor.getFullYear(), anchor.getMonth() + monthDelta, 1)));
|
||||
render();
|
||||
resetTrackPosition();
|
||||
animatingNav = false;
|
||||
}, ANIMATION_MS);
|
||||
};
|
||||
|
||||
if (selectionMode === 'range') {
|
||||
let dragStart = null;
|
||||
let dragCurrent = null;
|
||||
let dragging = false;
|
||||
gridEl.addEventListener('pointerdown', (e) => {
|
||||
if (animatingNav) return;
|
||||
const btn = e.target.closest('.swc-day');
|
||||
if (!btn) return;
|
||||
e.stopPropagation();
|
||||
dragStart = btn.dataset.dk;
|
||||
dragCurrent = btn.dataset.dk;
|
||||
dragging = true;
|
||||
gridEl.setPointerCapture(e.pointerId);
|
||||
render([dragStart]);
|
||||
});
|
||||
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', () => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
const range = dayRange(dragStart, dragCurrent);
|
||||
dragStart = null;
|
||||
dragCurrent = null;
|
||||
if (typeof onSelectionCommit === 'function') onSelectionCommit(range);
|
||||
render();
|
||||
});
|
||||
gridEl.addEventListener('pointercancel', () => {
|
||||
dragging = false;
|
||||
dragStart = null;
|
||||
dragCurrent = null;
|
||||
render();
|
||||
});
|
||||
} else {
|
||||
gridEl.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.swc-day');
|
||||
if (!btn) return;
|
||||
if (typeof onSelectionCommit === 'function') onSelectionCommit(btn.dataset.dk);
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
let ptrId = null;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let moved = false;
|
||||
let axis = null;
|
||||
|
||||
viewport.addEventListener('pointerdown', (e) => {
|
||||
if (animatingNav || ptrId !== null) return;
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
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;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
moved = false;
|
||||
axis = null;
|
||||
try { viewport.setPointerCapture(e.pointerId); } catch (_) {}
|
||||
});
|
||||
|
||||
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') syncGripVisibility(true);
|
||||
}
|
||||
if (axis === 'x') setDragTranslate(dx, 0);
|
||||
});
|
||||
|
||||
const endGesture = (e) => {
|
||||
if (e && e.pointerId !== ptrId) return;
|
||||
ptrId = null;
|
||||
if (!moved || axis !== 'x') return;
|
||||
const dx = e ? e.clientX - startX : 0;
|
||||
if (Math.abs(dx) >= SWIPE_THRESHOLD) commitNavigation(dx > 0 ? -1 : 1);
|
||||
else {
|
||||
setDragTranslate(0, ANIMATION_MS);
|
||||
setTimeout(() => syncGripVisibility(false), ANIMATION_MS + 20);
|
||||
}
|
||||
moved = false;
|
||||
axis = null;
|
||||
};
|
||||
|
||||
viewport.addEventListener('pointerup', endGesture);
|
||||
viewport.addEventListener('pointercancel', endGesture);
|
||||
window.addEventListener('resize', applyLayout);
|
||||
|
||||
return { render, reapplyLayout: applyLayout, resetTrackPosition };
|
||||
}
|
||||
Reference in New Issue
Block a user