diff --git a/.changeset/shiny-pigs-talk.md b/.changeset/shiny-pigs-talk.md new file mode 100644 index 0000000..892677f --- /dev/null +++ b/.changeset/shiny-pigs-talk.md @@ -0,0 +1,24 @@ +--- +"@marceloterreiro/flash-calendar": major +--- + +# Flash Calendar 1.0.0 🚢 🎉 + +This release officially marks the package as ready for production use (`1.0.0`). +While it's been stable since the first release, bumping to `1.0.0` was something +I had in mind for a while. + +- New: Add `.scrollToMonth` and `.scrollToDate`, increasing the options available for imperative scrolling. + +## Breaking changes + +This release introduces one slightly change in behavior if you're app uses +imperative scrolling. Previously, `.scrollToDate` would scroll to the month +containing the date instead of the exact date. Now, `.scrollToDate` will scroll +to the exact date as the name suggests. + +If you intentionally want to scroll to the month instead, a new `.scrollToMonth` +method was added (same signature). + +I don't expect this to cause any issues for existing apps, but worth mentioned +nonetheless. diff --git a/apps/docs/docs/fundamentals/tips-and-tricks.mdx b/apps/docs/docs/fundamentals/tips-and-tricks.mdx index 7c41c13..4229c67 100644 --- a/apps/docs/docs/fundamentals/tips-and-tricks.mdx +++ b/apps/docs/docs/fundamentals/tips-and-tricks.mdx @@ -48,7 +48,9 @@ These two convertions functions were [battle-tested](/~https://github.com/MarceloP ## Programmatically scrolling to a date -Flash Calendar exposes a `ref` that allows imperative scrolling to a desired date. +Flash Calendar exposes a `ref` that allows imperative scrolling to a desired +date (`.scrollToDate`), a month (`.scrollToMonth`), or an offset +(`.scrollToOffset`). @@ -63,9 +65,12 @@ import { Button, Text, View } from "react-native"; export function ImperativeScrolling() { const [currentMonth, setCurrentMonth] = useState(startOfMonth(new Date())); - const ref = useRef(null); + const onCalendarDayPress = useCallback((dateId: string) => { + ref.current?.scrollToDate(fromDateId(dateId), true); + }, []); + return ( @@ -73,7 +78,7 @@ export function ImperativeScrolling() { onPress={() => { const pastMonth = subMonths(currentMonth, 1); setCurrentMonth(pastMonth); - ref.current?.scrollToDate(pastMonth, true); + ref.current?.scrollToMonth(pastMonth, true); }} title="Past month" /> @@ -82,7 +87,7 @@ export function ImperativeScrolling() { onPress={() => { const nextMonth = addMonths(currentMonth, 1); setCurrentMonth(nextMonth); - ref.current?.scrollToDate(nextMonth, true); + ref.current?.scrollToMonth(nextMonth, true); }} title="Next month" /> @@ -90,7 +95,7 @@ export function ImperativeScrolling() { console.log(`Pressed ${dateId}`)} + onCalendarDayPress={onCalendarDayPress} ref={ref} /> @@ -107,6 +112,12 @@ export function ImperativeScrolling() { +You can also pass an `additionalOffset` to fine-tune the scroll position: + +```tsx +.scrollToDate(fromDateId("2024-07-01"), true, { additionalOffset: 4 }) +``` + ## Setting Border Radius to `Calendar.Item.Day` To apply a border radius to the `Calendar.Item.Day` component, it's necessary to diff --git a/apps/docs/static/videos/imperative-scroll.mp4 b/apps/docs/static/videos/imperative-scroll.mp4 index 1577311..af8e86d 100644 Binary files a/apps/docs/static/videos/imperative-scroll.mp4 and b/apps/docs/static/videos/imperative-scroll.mp4 differ diff --git a/kitchen-sink/expo/src/App.tsx b/kitchen-sink/expo/src/App.tsx index f8011af..6729657 100644 --- a/kitchen-sink/expo/src/App.tsx +++ b/kitchen-sink/expo/src/App.tsx @@ -9,7 +9,7 @@ import { CalendarListDemo } from "./CalendarList"; import { BottomSheetCalendar } from "./BottomSheetCalendar"; import { CalendarCustomFormatting } from "./CalendarCustomFormatting"; import { ImperativeScrolling } from "./ImperativeScroll"; -import { SlowExampleAddressed } from "./SlowExampleAddressed"; +// import { SlowExampleAddressed } from "./SlowExampleAddressed"; export default function App() { const [demo, setDemo] = useState<"calendar" | "calendarList">("calendar"); @@ -33,10 +33,9 @@ export default function App() { {demo === "calendar" ? : } */} - {/* */} - {/* */} + {/* */} - + {/* */} ); diff --git a/kitchen-sink/expo/src/ImperativeScroll.tsx b/kitchen-sink/expo/src/ImperativeScroll.tsx index 00a9588..01db84a 100644 --- a/kitchen-sink/expo/src/ImperativeScroll.tsx +++ b/kitchen-sink/expo/src/ImperativeScroll.tsx @@ -1,14 +1,21 @@ import { addMonths, subMonths, startOfMonth } from "date-fns"; import type { CalendarListRef } from "@marceloterreiro/flash-calendar"; -import { Calendar, toDateId } from "@marceloterreiro/flash-calendar"; -import { useRef, useState } from "react"; +import { + Calendar, + toDateId, + fromDateId, +} from "@marceloterreiro/flash-calendar"; +import { useCallback, useRef, useState } from "react"; import { Button, Text, View } from "react-native"; export function ImperativeScrolling() { const [currentMonth, setCurrentMonth] = useState(startOfMonth(new Date())); - const ref = useRef(null); + const onCalendarDayPress = useCallback((dateId: string) => { + ref.current?.scrollToDate(fromDateId(dateId), true); + }, []); + return ( @@ -16,7 +23,7 @@ export function ImperativeScrolling() { onPress={() => { const pastMonth = subMonths(currentMonth, 1); setCurrentMonth(pastMonth); - ref.current?.scrollToDate(pastMonth, true); + ref.current?.scrollToMonth(pastMonth, true); }} title="Past month" /> @@ -25,7 +32,7 @@ export function ImperativeScrolling() { onPress={() => { const nextMonth = addMonths(currentMonth, 1); setCurrentMonth(nextMonth); - ref.current?.scrollToDate(nextMonth, true); + ref.current?.scrollToMonth(nextMonth, true); }} title="Next month" /> @@ -33,7 +40,7 @@ export function ImperativeScrolling() { console.log(`Pressed ${dateId}`)} + onCalendarDayPress={onCalendarDayPress} ref={ref} /> diff --git a/packages/flash-calendar/src/components/CalendarList.stories.tsx b/packages/flash-calendar/src/components/CalendarList.stories.tsx index a0f78a9..a626402 100644 --- a/packages/flash-calendar/src/components/CalendarList.stories.tsx +++ b/packages/flash-calendar/src/components/CalendarList.stories.tsx @@ -7,7 +7,7 @@ import { startOfYear, subMonths, } from "date-fns"; -import { useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { Button, Text, View } from "react-native"; import type { CalendarListProps, CalendarListRef } from "@/components"; @@ -16,7 +16,7 @@ import { HStack } from "@/components/HStack"; import { VStack } from "@/components/VStack"; import { paddingDecorator } from "@/developer/decorators"; import { loggingHandler } from "@/developer/loggginHandler"; -import { toDateId } from "@/helpers/dates"; +import { fromDateId, toDateId } from "@/helpers/dates"; import type { CalendarActiveDateRange } from "@/hooks/useCalendar"; import { useDateRange } from "@/hooks/useDateRange"; import { useTheme } from "@/hooks/useTheme"; @@ -110,8 +110,23 @@ export function SpacingSparse() { export function ImperativeScrolling() { const [currentMonth, setCurrentMonth] = useState(startOfMonth(new Date())); + const [activeDateId, setActiveDateId] = useState( + toDateId(addDays(currentMonth, 3)) + ); + + const calendarActiveDateRanges = useMemo(() => { + if (!activeDateId) return []; + + return [{ startId: activeDateId, endId: activeDateId }]; + }, [activeDateId]); + const ref = useRef(null); + const onCalendarDayPress = useCallback((dateId: string) => { + ref.current?.scrollToDate(fromDateId(dateId), true); + setActiveDateId(dateId); + }, []); + return ( @@ -120,7 +135,7 @@ export function ImperativeScrolling() { onPress={() => { const pastMonth = subMonths(currentMonth, 1); setCurrentMonth(pastMonth); - ref.current?.scrollToDate(pastMonth, true); + ref.current?.scrollToMonth(pastMonth, true); }} title="Past month" /> @@ -129,7 +144,7 @@ export function ImperativeScrolling() { onPress={() => { const nextMonth = addMonths(currentMonth, 1); setCurrentMonth(nextMonth); - ref.current?.scrollToDate(nextMonth, true); + ref.current?.scrollToMonth(nextMonth, true); }} title="Next month" /> @@ -138,14 +153,15 @@ export function ImperativeScrolling() { onPress={() => { const thisMonth = startOfMonth(new Date()); setCurrentMonth(thisMonth); - ref.current?.scrollToDate(thisMonth, true); + ref.current?.scrollToMonth(thisMonth, true); }} title="Today" /> diff --git a/packages/flash-calendar/src/components/CalendarList.tsx b/packages/flash-calendar/src/components/CalendarList.tsx index 294f308..9b94a8d 100644 --- a/packages/flash-calendar/src/components/CalendarList.tsx +++ b/packages/flash-calendar/src/components/CalendarList.tsx @@ -13,7 +13,7 @@ import { View } from "react-native"; import type { CalendarProps } from "@/components/Calendar"; import { Calendar } from "@/components/Calendar"; -import { startOfMonth, toDateId } from "@/helpers/dates"; +import { getWeekOfMonth, startOfMonth, toDateId } from "@/helpers/dates"; import type { CalendarMonth } from "@/hooks/useCalendarList"; import { getHeightForMonth, useCalendarList } from "@/hooks/useCalendarList"; @@ -89,8 +89,25 @@ export interface CalendarListProps renderItem?: FlashListProps["renderItem"]; } +interface ImperativeScrollParams { + /** + * An additional offset to add to the final scroll position. Useful when + * you need to slightly change the final scroll position. + */ + additionalOffset?: number; +} export interface CalendarListRef { - scrollToDate: (date: Date, animated: boolean) => void; + scrollToMonth: ( + date: Date, + animated: boolean, + params?: ImperativeScrollParams + ) => void; + scrollToDate: ( + date: Date, + animated: boolean, + params?: ImperativeScrollParams + ) => void; + scrollToOffset: (offset: number, animated: boolean) => void; } export const CalendarList = memo( @@ -224,10 +241,12 @@ export const CalendarList = memo( ] ); - const flashListRef = useRef>(null); - - useImperativeHandle(ref, () => ({ - scrollToDate(date, animated) { + /** + * Returns the offset for the given month (how much the user needs to + * scroll to reach the month). + */ + const getScrollOffsetForMonth = useCallback( + (date: Date) => { const monthId = toDateId(startOfMonth(date)); let baseMonthList = monthList; @@ -238,31 +257,79 @@ export const CalendarList = memo( index = baseMonthList.findIndex((month) => month.id === monthId); } - const currentOffset = baseMonthList - .slice(0, index) - .reduce((acc, month) => { - const currentHeight = getHeightForMonth({ - calendarMonth: month, - calendarSpacing, - calendarDayHeight, - calendarMonthHeaderHeight, - calendarRowVerticalSpacing, - calendarWeekHeaderHeight, - calendarAdditionalHeight, - }); - - return acc + currentHeight; - }, 0); + return baseMonthList.slice(0, index).reduce((acc, month) => { + const currentHeight = getHeightForMonth({ + calendarMonth: month, + calendarSpacing, + calendarDayHeight, + calendarMonthHeaderHeight, + calendarRowVerticalSpacing, + calendarWeekHeaderHeight, + calendarAdditionalHeight, + }); + return acc + currentHeight; + }, 0); + }, + [ + addMissingMonths, + calendarAdditionalHeight, + calendarDayHeight, + calendarMonthHeaderHeight, + calendarRowVerticalSpacing, + calendarSpacing, + calendarWeekHeaderHeight, + monthList, + ] + ); + + const flashListRef = useRef>(null); + + useImperativeHandle(ref, () => ({ + scrollToMonth( + date, + animated, + { additionalOffset = 0 } = { additionalOffset: 0 } + ) { // Wait for the next render cycle to ensure the list has been // updated with the new months. setTimeout(() => { flashListRef.current?.scrollToOffset({ - offset: currentOffset, + offset: getScrollOffsetForMonth(date) + additionalOffset, animated, }); }, 0); }, + scrollToDate( + date, + animated, + { additionalOffset = 0 } = { + additionalOffset: 0, + } + ) { + const currentMonthOffset = getScrollOffsetForMonth(date); + const weekOfMonthIndex = getWeekOfMonth(date, calendarFirstDayOfWeek); + const rowHeight = calendarDayHeight + calendarRowVerticalSpacing; + + let weekOffset = + calendarWeekHeaderHeight + rowHeight * weekOfMonthIndex; + + /** + * We need to subtract one vertical spacing to avoid cutting off the + * desired date. A simple way of understanding why is imagining we + * want to scroll exactly to the given date, but leave a little bit of + * breathing room (`calendarRowVerticalSpacing`) above it. + */ + weekOffset = weekOffset - calendarRowVerticalSpacing; + + flashListRef.current?.scrollToOffset({ + offset: currentMonthOffset + weekOffset + additionalOffset, + animated, + }); + }, + scrollToOffset(offset, animated) { + flashListRef.current?.scrollToOffset({ offset, animated }); + }, })); const calendarContainerStyle = useMemo(() => { diff --git a/packages/flash-calendar/src/helpers/dates.test.ts b/packages/flash-calendar/src/helpers/dates.test.ts index 2a8b73f..5ba210d 100644 --- a/packages/flash-calendar/src/helpers/dates.test.ts +++ b/packages/flash-calendar/src/helpers/dates.test.ts @@ -8,6 +8,7 @@ import { addMonths as addMonthsDateFns, subMonths as subMonthsDateFns, getWeeksInMonth as getWeeksInMonthDateFns, + getWeekOfMonth as getWeekOfMonthDateFns, } from "date-fns"; import { @@ -23,6 +24,7 @@ import { isWeekend, differenceInMonths, getWeeksInMonth, + getWeekOfMonth, } from "@/helpers/dates"; import { range } from "@/helpers/numbers"; import { pipe } from "@/helpers/functions"; @@ -528,3 +530,38 @@ describe("getWeeksInMonth", () => { }); }); }); + +describe("getWeekOfMonth", () => { + const getWeekOfMonth_sunday = (date: Date) => getWeekOfMonth(date, "sunday"); + const getWeekOfMonth_monday = (date: Date) => getWeekOfMonth(date, "monday"); + + it("sunday: June", () => { + expect(pipe(fromDateId("2024-06-01"), getWeekOfMonth_sunday)).toBe(1); + expect(pipe(fromDateId("2024-06-02"), getWeekOfMonth_sunday)).toBe(2); + expect(pipe(fromDateId("2024-06-03"), getWeekOfMonth_monday)).toBe(2); + expect(pipe(fromDateId("2024-06-12"), getWeekOfMonth_sunday)).toBe(3); + expect(pipe(fromDateId("2024-06-22"), getWeekOfMonth_sunday)).toBe(4); + expect(pipe(fromDateId("2024-06-28"), getWeekOfMonth_sunday)).toBe(5); + expect(pipe(fromDateId("2024-06-30"), getWeekOfMonth_sunday)).toBe(6); + }); + it("monday: June", () => { + expect(pipe(fromDateId("2024-06-01"), getWeekOfMonth_monday)).toBe(1); + expect(pipe(fromDateId("2024-06-02"), getWeekOfMonth_monday)).toBe(1); + expect(pipe(fromDateId("2024-06-03"), getWeekOfMonth_monday)).toBe(2); + expect(pipe(fromDateId("2024-06-12"), getWeekOfMonth_monday)).toBe(3); + expect(pipe(fromDateId("2024-06-22"), getWeekOfMonth_monday)).toBe(4); + expect(pipe(fromDateId("2024-06-28"), getWeekOfMonth_monday)).toBe(5); + expect(pipe(fromDateId("2024-06-30"), getWeekOfMonth_monday)).toBe(5); + }); + it("matches date-fns", () => { + const baseDate = fromDateId("2024-06-01"); + range(1, 500).forEach((i) => { + const date = addDays(baseDate, i); + const countMonday = getWeekOfMonthDateFns(date, { weekStartsOn: 1 }); + const countSunday = getWeekOfMonthDateFns(date, { weekStartsOn: 0 }); + + expect(getWeekOfMonth(date, "monday")).toBe(countMonday); + expect(getWeekOfMonth(date, "sunday")).toBe(countSunday); + }); + }); +}); diff --git a/packages/flash-calendar/src/helpers/dates.ts b/packages/flash-calendar/src/helpers/dates.ts index eaac029..785c7c5 100644 --- a/packages/flash-calendar/src/helpers/dates.ts +++ b/packages/flash-calendar/src/helpers/dates.ts @@ -141,3 +141,23 @@ export function getWeeksInMonth( return Math.ceil((dayOfWeek + totalDays) / 7); } + +/** + * Get the week of the month of the given date. The week index is 1-based. + */ +export function getWeekOfMonth( + date: Date, + firstDayOfWeek: "monday" | "sunday" +) { + const firstDay = new Date(date.getFullYear(), date.getMonth(), 1); + let dayOfWeek = firstDay.getDay(); + + // Adjust the first day of the week + if (firstDayOfWeek === "monday") { + dayOfWeek = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + } + + const dayOfMonth = date.getDate(); + + return Math.floor((dayOfWeek + dayOfMonth - 1) / 7) + 1; +}