Swipeable calendar
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m15s

This commit is contained in:
2026-04-20 11:33:43 +02:00
parent 070a0a61db
commit 570e44257f
7 changed files with 919 additions and 307 deletions

View File

@@ -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();
};
}