scrolling finally works

This commit is contained in:
Michael Zhang 2023-08-23 21:11:12 -05:00
parent ba89cb6972
commit f276a6276e
4 changed files with 166 additions and 39 deletions

View file

@ -1,9 +1,10 @@
import { import {
addDays, addDays,
endOfMonth, endOfMonth,
getWeek,
isSameWeek, isSameWeek,
startOfMonth, startOfMonth,
startOfWeek,
subDays,
} from "date-fns"; } from "date-fns";
/** /**
@ -11,14 +12,19 @@ import {
* The last week is not returned if it contains the next month's start date * The last week is not returned if it contains the next month's start date
* @param date * @param date
*/ */
export function weekBoundsOfMonth(date: Date): [number, number] { export function weekBoundsOfMonth(date: Date): [Date, Date] {
const firstDayOfMonth = startOfMonth(date); const firstDayOfMonth = startOfMonth(date);
const startWeek = getWeek(firstDayOfMonth); const startWeek = startOfWeek(firstDayOfMonth);
const lastDayOfMonth = endOfMonth(date); const lastDayOfMonth = endOfMonth(date);
const firstDayOfNextMonth = addDays(lastDayOfMonth, 1); const firstDayOfNextMonth = addDays(lastDayOfMonth, 1);
let endWeek = getWeek(lastDayOfMonth); let endWeek = startOfWeek(lastDayOfMonth);
if (isSameWeek(lastDayOfMonth, firstDayOfNextMonth)) endWeek -= 1; if (isSameWeek(lastDayOfMonth, firstDayOfNextMonth))
endWeek = subDays(endWeek, 7);
return [startWeek, endWeek]; return [startWeek, endWeek];
} }
export function monthNameOf(date: Date): string {
return date.toLocaleDateString("default", { month: "long" });
}

View file

@ -25,6 +25,7 @@
text-align: right; text-align: right;
font-weight: 100; font-weight: 100;
border-bottom: 1px solid gray; border-bottom: 1px solid gray;
padding: 4px;
} }
} }

View file

@ -1,18 +1,53 @@
import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
import styles from "./Calendar.module.scss"; import styles from "./Calendar.module.scss";
import { addMonths, subMonths } from "date-fns"; import {
addDays,
addMonths,
differenceInWeeks,
startOfMonth,
subMonths,
} from "date-fns";
import Month from "./Month"; import Month from "./Month";
import { weekBoundsOfMonth } from "lib/month"; import { monthNameOf, weekBoundsOfMonth } from "lib/month";
const daysOfWeek = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const daysOfWeek = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const NUM_MONTHS_SHOWN = 5;
interface CalendarProps { interface CalendarProps {
onDateClick?: (_: Date) => void; onDateClick?: (_: Date) => void;
} }
export default function Calendar({ onDateClick }: CalendarProps) { export default function Calendar({ onDateClick }: CalendarProps) {
const now = new Date();
const [viewportHeight, setViewportHeight] = useState(0); const [viewportHeight, setViewportHeight] = useState(0);
const [scrollyEl, setScrollyEl] = useState<HTMLDivElement>(null); const [scrollyEl, setScrollyEl] = useState<HTMLDivElement | null>(null);
const [centerMonth, setCenterMonth] = useState(startOfMonth(now));
const [resetScrollToEl, setResetScrollToEl] = useState<
[HTMLElement, number] | null
>(null);
const range = [-2, -1, 0, 1, 2];
const [monthsShown, setMonthsShown] = useState(
range.map((x) => addMonths(centerMonth, x))
);
const [firstLoad, setFirstLoad] = useState(true);
useEffect(() => {
if (firstLoad && scrollyEl && viewportHeight > 0) {
// Identify the middle month
const el = scrollyEl.querySelector(
`[data-isodate="${centerMonth.toISOString()}"]`
);
if (el) {
console.log("first run, scrolling into view", el);
el.scrollIntoView();
setFirstLoad(false);
}
}
}, [firstLoad, scrollyEl, viewportHeight]);
const scrollyRef = useCallback((node: HTMLDivElement) => { const scrollyRef = useCallback((node: HTMLDivElement) => {
if (node) { if (node) {
setScrollyEl(node); setScrollyEl(node);
@ -20,11 +55,10 @@ export default function Calendar({ onDateClick }: CalendarProps) {
} }
}, []); }, []);
// Add resize observer on the viewport
useEffect(() => { useEffect(() => {
const observer = new ResizeObserver((entries) => { const observer = new ResizeObserver((entries) => {
for (const entry of entries) { if (entries && scrollyEl) setViewportHeight(scrollyEl.clientHeight);
setViewportHeight(scrollyEl.clientHeight);
}
}); });
if (scrollyEl) observer.observe(scrollyEl); if (scrollyEl) observer.observe(scrollyEl);
@ -34,40 +68,90 @@ export default function Calendar({ onDateClick }: CalendarProps) {
}; };
}, [scrollyEl]); }, [scrollyEl]);
// Add intersection observer to tell when we scrolled too far up or down
useEffect(() => {
if (!scrollyEl) return;
const children = [...scrollyEl.children];
if (children.length !== NUM_MONTHS_SHOWN) throw new Error("wtf");
const firstChild = children[0];
const lastChild = children[NUM_MONTHS_SHOWN - 1];
const observer = new IntersectionObserver((entries) => {
let newMonthsShown = monthsShown;
let scrollUp = false;
for (const entry of entries) {
if (!entry.isIntersecting) continue;
if (entry.target === firstChild) {
console.log("intersected");
const firstChildDate = new Date(firstChild.dataset.isodate);
const prevMonth = subMonths(firstChildDate, 1);
newMonthsShown = [prevMonth, ...monthsShown];
newMonthsShown = newMonthsShown.slice(0, NUM_MONTHS_SHOWN);
setResetScrollToEl([
firstChild,
scrollyEl.scrollTop - firstChild.offsetTop,
]);
} else if (entry.target === lastChild) {
const lastChildDate = new Date(lastChild.dataset.isodate);
const nextMonth = addMonths(lastChildDate, 1);
newMonthsShown = [...monthsShown, nextMonth];
newMonthsShown = newMonthsShown.slice(-NUM_MONTHS_SHOWN);
}
}
setMonthsShown(newMonthsShown);
});
console.log("observing", firstChild, lastChild);
observer.observe(firstChild);
observer.observe(lastChild);
return () => {
observer.unobserve(firstChild);
observer.unobserve(lastChild);
};
}, [monthsShown, scrollyEl]);
useEffect(() => {
if (!resetScrollToEl) return;
if (!scrollyEl) return;
console.log("current scroll top", scrollyEl.scrollTop);
console.log("params", resetScrollToEl);
const [el, height] = resetScrollToEl;
scrollyEl.scrollTop = el.offsetTop + height;
console.log("new scroll top", scrollyEl.scrollTop, el.offsetTop + height);
setResetScrollToEl(null);
}, [resetScrollToEl, scrollyEl]);
console.log("SCROLLY REF IS", scrollyRef); console.log("SCROLLY REF IS", scrollyRef);
const [middleMonthEl, setMiddleMonthEl] = useState<HTMLDivElement>(null);
const middleMonthRef = useCallback((node: HTMLDivElement) => {
if (node) setMiddleMonthEl(node);
}, []);
// The calendar should always show 6 rows // The calendar should always show 6 rows
const dateCellHeight = viewportHeight / 6; const dateCellHeight = viewportHeight / 6;
const now = new Date(); const monthName = monthNameOf(centerMonth);
const [currentlyShownMonth, setCurrentlyShownMonth] = useState(now);
const monthName = currentlyShownMonth.toLocaleDateString("default", {
month: "long",
});
const prevMonth = subMonths(currentlyShownMonth, 1); // useEffect(() => {
const nextMonth = addMonths(currentlyShownMonth, 1); // middleMonthEl?.scrollIntoView();
const [monthsShown, setMonthsShown] = useState([ // }, [middleMonthRef]);
prevMonth,
currentlyShownMonth,
nextMonth,
]);
useEffect(() => {
middleMonthEl?.scrollIntoView();
}, [middleMonthRef]);
return ( return (
<div className={styles.calendar}> <div className={styles.calendar}>
<div className={styles.header}> <div className={styles.header}>
<div className={styles.title}> <div className={styles.title}>
<b>{monthName}</b> <b>{monthName}</b>
{centerMonth.getFullYear()}
{currentlyShownMonth.getFullYear()} (months shown:{" "}
{JSON.stringify(
monthsShown.map((x) =>
x.toLocaleDateString("default", { month: "long" })
)
)}
)
</div> </div>
<div className={styles.daysOfWeek}> <div className={styles.daysOfWeek}>
@ -83,15 +167,15 @@ export default function Calendar({ onDateClick }: CalendarProps) {
{monthsShown.map((month) => { {monthsShown.map((month) => {
// How many rows will this month take up? // How many rows will this month take up?
const [startWeek, endWeek] = weekBoundsOfMonth(month); const [startWeek, endWeek] = weekBoundsOfMonth(month);
const numWeeks = endWeek - startWeek + 1; const numWeeks = differenceInWeeks(endWeek, startWeek);
const dateGridHeight = dateCellHeight * numWeeks; const dateGridHeight = dateCellHeight * numWeeks;
const props = { const props = {
month, month,
dateGridHeight, dateGridHeight,
}; };
const ref = const ref = undefined;
(month === currentlyShownMonth && middleMonthRef) || undefined; // (month === currentlyShownMonth && middleMonthRef) || undefined;
return ( return (
<Month <Month
key={month.toString()} key={month.toString()}

View file

@ -2,6 +2,7 @@ import { forwardRef } from "react";
import styles from "./Month.module.scss"; import styles from "./Month.module.scss";
import { import {
addDays, addDays,
differenceInWeeks,
endOfMonth, endOfMonth,
getWeek, getWeek,
isSameDay, isSameDay,
@ -17,12 +18,35 @@ interface MonthProps {
onDateClick?: (_: Date) => void; onDateClick?: (_: Date) => void;
} }
const monthColors = [
"#AA3939",
"#FFAAAA",
"#D46A6A",
"#801515",
"#550000",
"#AA6C39",
"#FFD1AA",
"#D49A6A",
"#804515",
"#552700",
"#226666",
"#669999",
"#407F7F",
"#0D4D4D",
"#003333",
"#2D882D",
"#88CC88",
"#55AA55",
"#116611",
"#004400",
];
function Month({ month, dateGridHeight, onDateClick }: MonthProps, ref) { function Month({ month, dateGridHeight, onDateClick }: MonthProps, ref) {
const now = new Date(); const now = new Date();
const monthName = month.toLocaleString("default", { month: "long" }); const monthName = month.toLocaleString("default", { month: "long" });
const [startWeek, endWeek] = weekBoundsOfMonth(month); const [startWeek, endWeek] = weekBoundsOfMonth(month);
const numWeeks = endWeek - startWeek + 1; const numWeeks = differenceInWeeks(endWeek, startWeek) + 1;
const weeksArr = Array(numWeeks) const weeksArr = Array(numWeeks)
.fill(0) .fill(0)
.map((_, i) => addDays(startOfMonth(month), 7 * i)); .map((_, i) => addDays(startOfMonth(month), 7 * i));
@ -39,17 +63,29 @@ function Month({ month, dateGridHeight, onDateClick }: MonthProps, ref) {
className={styles.dateGrid} className={styles.dateGrid}
style={{ height: `${dateGridHeight}px` }} style={{ height: `${dateGridHeight}px` }}
ref={ref} ref={ref}
data-isodate={month}
> >
{datesArr.map((date) => { {datesArr.map((date) => {
const isFirst = isSameDay(date, startOfMonth(date)); const isFirst = isSameDay(date, startOfMonth(date));
const isToday = isSameDay(date, now); const isToday = isSameDay(date, now);
return ( return (
<div key={date.toString()} className={styles.dateCell}> <div
key={date.toString()}
className={styles.dateCell}
data-isodate={date.toISOString()}
>
<div <div
className={styles.dateNumber} className={styles.dateNumber}
onClick={() => onDateClick?.(date)} onClick={() => onDateClick?.(date)}
> >
<span className={classNames(isToday && styles.today)}> <span
className={classNames(isFirst && styles.today)}
style={
isFirst
? { backgroundColor: monthColors[date.getMonth()] }
: {}
}
>
{isFirst && monthName} {isFirst && monthName}
{date.getDate()} {date.getDate()}
</span> </span>