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();
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user