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;
+}