From a40b9c85ade623ed9fbf3194d78df122c7a2b85b Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:56:50 +0300 Subject: [PATCH] Calendar drag by mouse scroll --- .../calendar/routes/calendar.module.css | 1 + app/features/calendar/routes/calendar.tsx | 10 ++- app/hooks/useDragToScroll.browser.test.ts | 80 ++++++++++++++++++- app/hooks/useDragToScroll.ts | 40 ++++++++++ changelog/2026-08-31-calendar-drag-scroll.md | 5 ++ 5 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 changelog/2026-08-31-calendar-drag-scroll.md diff --git a/app/features/calendar/routes/calendar.module.css b/app/features/calendar/routes/calendar.module.css index d35dc6403..465eab581 100644 --- a/app/features/calendar/routes/calendar.module.css +++ b/app/features/calendar/routes/calendar.module.css @@ -107,6 +107,7 @@ justify-content: safe center; overflow-x: auto; scroll-snap-type: x mandatory; + user-select: none; & > * { scroll-snap-align: start; diff --git a/app/features/calendar/routes/calendar.tsx b/app/features/calendar/routes/calendar.tsx index 4ef503f8e..04a0504c1 100644 --- a/app/features/calendar/routes/calendar.tsx +++ b/app/features/calendar/routes/calendar.tsx @@ -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() {
{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; diff --git a/app/hooks/useDragToScroll.browser.test.ts b/app/hooks/useDragToScroll.browser.test.ts index 502a80dc5..1152bf3ff 100644 --- a/app/hooks/useDragToScroll.browser.test.ts +++ b/app/hooks/useDragToScroll.browser.test.ts @@ -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(""); + }); }); diff --git a/app/hooks/useDragToScroll.ts b/app/hooks/useDragToScroll.ts index c221fb1a0..85ebe2654 100644 --- a/app/hooks/useDragToScroll.ts +++ b/app/hooks/useDragToScroll.ts @@ -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 = ""; }; } diff --git a/changelog/2026-08-31-calendar-drag-scroll.md b/changelog/2026-08-31-calendar-drag-scroll.md new file mode 100644 index 000000000..0a61633e5 --- /dev/null +++ b/changelog/2026-08-31-calendar-drag-scroll.md @@ -0,0 +1,5 @@ +--- +navItem: calendar +type: feature +--- +Calendar can now be scrolled by dragging with the mouse