mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 05:38:09 -05:00
Calendar drag by mouse scroll
This commit is contained in:
@@ -107,6 +107,7 @@
|
||||
justify-content: safe center;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
user-select: none;
|
||||
|
||||
& > * {
|
||||
scroll-snap-align: start;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = "";
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
5
changelog/2026-08-31-calendar-drag-scroll.md
Normal file
5
changelog/2026-08-31-calendar-drag-scroll.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: calendar
|
||||
type: feature
|
||||
---
|
||||
Calendar can now be scrolled by dragging with the mouse
|
||||
Reference in New Issue
Block a user