Calendar drag by mouse scroll

This commit is contained in:
Kalle
2026-08-31 19:56:50 +03:00
parent 2600f0b9be
commit a40b9c85ad
5 changed files with 134 additions and 2 deletions

View File

@@ -107,6 +107,7 @@
justify-content: safe center;
overflow-x: auto;
scroll-snap-type: x mandatory;
user-select: none;
& > * {
scroll-snap-align: start;

View File

@@ -26,6 +26,7 @@ import { Main } from "~/components/Main";
import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants";
import { useCollapsableEvents } from "~/features/calendar/calendar-hooks";
import { calendarSearchParams } from "~/features/calendar/calendar-search-params";
import { dragToScroll } from "~/hooks/useDragToScroll";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import { dayMonthYearToDateValue } from "~/utils/dates";
import { metaTags, ogPageImage } from "~/utils/remix";
@@ -106,7 +107,7 @@ export default function CalendarPage() {
</div>
<div
key={`${shown[0].year}-${shown[0].month}-${shown[0].day}`}
ref={scrollTodayToCenter}
ref={setUpColumnsContainer}
className={clsx(styles.columnsContainer, "scrollbar")}
>
{shown.map((date) => (
@@ -206,6 +207,13 @@ function useCalendarDayHref() {
calendarSearchParams.href(CALENDAR_PAGE, { ...params, ...dayMonthYear });
}
function setUpColumnsContainer(container: HTMLDivElement | null) {
scrollTodayToCenter(container);
if (!container) return;
return dragToScroll(container);
}
/** Centers today's column, leaving weeks that don't contain today scrolled to their first day. */
function scrollTodayToCenter(container: HTMLDivElement | null) {
if (!container) return;

View File

@@ -10,12 +10,21 @@ afterEach(() => {
cleanupFns = [];
});
function setUpScrollableElement() {
function setUpScrollableElement({ scrollSnap = false } = {}) {
const element = document.createElement("div");
element.style.width = "100px";
element.style.height = "100px";
element.style.overflow = "scroll";
if (scrollSnap) {
const snapStyle = document.createElement("style");
snapStyle.textContent =
".test-scroll-snap { scroll-snap-type: x mandatory; }";
document.head.appendChild(snapStyle);
element.classList.add("test-scroll-snap");
cleanupFns.push(() => snapStyle.remove());
}
const child = document.createElement("div");
child.style.width = "1000px";
child.style.height = "1000px";
@@ -138,4 +147,73 @@ describe("dragToScroll", () => {
expect(element.scrollLeft).toBe(0);
});
test("suppresses scroll snap while dragging a snap container", () => {
const { element } = setUpScrollableElement({ scrollSnap: true });
mouseDownOn(element, 50, 50);
mouseMoveTo(20, 50);
expect(getComputedStyle(element).scrollSnapType).toBe("none");
mouseUp();
});
test("restores scroll snap smoothly after a drag without momentum", () => {
const { element } = setUpScrollableElement({ scrollSnap: true });
mouseDownOn(element, 50, 50);
mouseMoveTo(45, 50);
mouseUp();
expect(getComputedStyle(element).scrollSnapType).toBe("x mandatory");
expect(element.style.scrollBehavior).toBe("smooth");
element.dispatchEvent(new Event("scrollend"));
expect(element.style.scrollBehavior).toBe("");
});
test("restores scroll snap after momentum scrolling ends", async () => {
const { element } = setUpScrollableElement({ scrollSnap: true });
mouseDownOn(element, 90, 50);
mouseMoveTo(82, 50);
mouseMoveTo(74, 50);
mouseUp();
expect(getComputedStyle(element).scrollSnapType).toBe("none");
await vi.waitFor(
() => {
expect(getComputedStyle(element).scrollSnapType).toBe("x mandatory");
},
{ timeout: 3000 },
);
});
test("clears the smooth scroll-behavior via the fallback timeout when no scrollend fires", () => {
vi.useFakeTimers();
try {
const { element } = setUpScrollableElement({ scrollSnap: true });
mouseDownOn(element, 50, 50);
mouseMoveTo(45, 50);
mouseUp();
expect(element.style.scrollBehavior).toBe("smooth");
vi.advanceTimersByTime(750);
expect(element.style.scrollBehavior).toBe("");
} finally {
vi.useRealTimers();
}
});
test("clears snap suppression on cleanup mid-drag", () => {
const { element, detach } = setUpScrollableElement({ scrollSnap: true });
mouseDownOn(element, 50, 50);
mouseMoveTo(20, 50);
detach();
expect(getComputedStyle(element).scrollSnapType).toBe("x mandatory");
expect(element.style.scrollBehavior).toBe("");
});
});

View File

@@ -5,6 +5,7 @@ const MOMENTUM_DECAY_PER_FRAME = 0.95;
const MOMENTUM_MIN_SPEED_PX_PER_MS = 0.05;
const MOMENTUM_FRAME_MS = 1000 / 60;
const VELOCITY_IDLE_TIMEOUT_MS = 100;
const SNAP_RESTORE_SMOOTH_TIMEOUT_MS = 750;
/**
* Makes an element with overflowing content scrollable by dragging with the
@@ -31,7 +32,10 @@ export function useDragToScroll<
* Framework-agnostic on purpose (usable as a Svelte attachment as is).
*/
export function dragToScroll(element: HTMLElement): () => void {
const hasScrollSnap = getComputedStyle(element).scrollSnapType !== "none";
let grabbingCursorStyle: HTMLStyleElement | null = null;
let isSnapSuppressed = false;
let snapRestoreTimeout = 0;
let isMouseDown = false;
let isDragging = false;
let suppressNextClick = false;
@@ -44,6 +48,35 @@ export function dragToScroll(element: HTMLElement): () => void {
let velocityY = 0;
let momentumFrame = 0;
const clearSmoothSnapRestore = () => {
window.clearTimeout(snapRestoreTimeout);
element.removeEventListener("scrollend", clearSmoothSnapRestore);
element.style.scrollBehavior = "";
};
// mandatory scroll snap re-snaps on every programmatic scrollLeft/scrollTop
// write, so it has to be off for the duration of the drag
const suppressScrollSnap = () => {
if (!hasScrollSnap || isSnapSuppressed) return;
isSnapSuppressed = true;
clearSmoothSnapRestore();
element.style.scrollSnapType = "none";
};
const restoreScrollSnap = () => {
if (!isSnapSuppressed) return;
isSnapSuppressed = false;
element.style.scrollBehavior = "smooth";
element.style.scrollSnapType = "";
element.addEventListener("scrollend", clearSmoothSnapRestore);
snapRestoreTimeout = window.setTimeout(
clearSmoothSnapRestore,
SNAP_RESTORE_SMOOTH_TIMEOUT_MS,
);
};
const onMouseDown = (event: MouseEvent) => {
suppressNextClick = false;
if (event.buttons !== 1) return;
@@ -66,6 +99,7 @@ export function dragToScroll(element: HTMLElement): () => void {
event.preventDefault();
grabbingCursorStyle ??= createGrabbingCursorStyle();
suppressScrollSnap();
const now = performance.now();
const elapsedMs = Math.max(now - lastMoveAt, 1);
@@ -104,6 +138,8 @@ export function dragToScroll(element: HTMLElement): () => void {
if (isDragging && !idledSinceLastMove) {
previousFrameAt = performance.now();
momentumFrame = requestAnimationFrame(momentumScrollStep);
} else {
restoreScrollSnap();
}
isDragging = false;
};
@@ -132,6 +168,8 @@ export function dragToScroll(element: HTMLElement): () => void {
Math.abs(velocityY) > MOMENTUM_MIN_SPEED_PX_PER_MS
) {
momentumFrame = requestAnimationFrame(momentumScrollStep);
} else {
restoreScrollSnap();
}
};
@@ -147,6 +185,8 @@ export function dragToScroll(element: HTMLElement): () => void {
window.removeEventListener("mouseup", onMouseUp);
cancelAnimationFrame(momentumFrame);
grabbingCursorStyle?.remove();
clearSmoothSnapRestore();
element.style.scrollSnapType = "";
};
}

View File

@@ -0,0 +1,5 @@
---
navItem: calendar
type: feature
---
Calendar can now be scrolled by dragging with the mouse