@lazerlen/legend-calendar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.eslintrc.js +29 -0
  2. package/.turbo/turbo-build.log +19 -0
  3. package/.turbo/turbo-lint.log +14 -0
  4. package/.turbo/turbo-test.log +261 -0
  5. package/.turbo/turbo-typecheck.log +1 -0
  6. package/CHANGELOG.md +127 -0
  7. package/dist/index.d.mts +679 -0
  8. package/dist/index.d.ts +679 -0
  9. package/dist/index.js +2 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/index.mjs +2 -0
  12. package/dist/index.mjs.map +1 -0
  13. package/package.json +47 -0
  14. package/src/components/Calendar.stories.tsx +226 -0
  15. package/src/components/Calendar.tsx +224 -0
  16. package/src/components/CalendarItemDay.tsx +385 -0
  17. package/src/components/CalendarItemEmpty.tsx +30 -0
  18. package/src/components/CalendarItemWeekName.tsx +67 -0
  19. package/src/components/CalendarList.stories.tsx +326 -0
  20. package/src/components/CalendarList.tsx +373 -0
  21. package/src/components/CalendarRowMonth.tsx +62 -0
  22. package/src/components/CalendarRowWeek.tsx +46 -0
  23. package/src/components/CalendarThemeProvider.tsx +43 -0
  24. package/src/components/HStack.tsx +67 -0
  25. package/src/components/VStack.tsx +67 -0
  26. package/src/components/index.ts +108 -0
  27. package/src/developer/decorators.tsx +54 -0
  28. package/src/developer/loggginHandler.tsx +6 -0
  29. package/src/developer/useRenderCount.ts +7 -0
  30. package/src/helpers/dates.test.ts +567 -0
  31. package/src/helpers/dates.ts +163 -0
  32. package/src/helpers/functions.ts +327 -0
  33. package/src/helpers/numbers.ts +11 -0
  34. package/src/helpers/strings.ts +2 -0
  35. package/src/helpers/tokens.ts +71 -0
  36. package/src/helpers/types.ts +3 -0
  37. package/src/hooks/useCalendar.test.ts +381 -0
  38. package/src/hooks/useCalendar.ts +351 -0
  39. package/src/hooks/useCalendarList.test.ts +382 -0
  40. package/src/hooks/useCalendarList.tsx +291 -0
  41. package/src/hooks/useDateRange.test.ts +128 -0
  42. package/src/hooks/useDateRange.ts +94 -0
  43. package/src/hooks/useOptimizedDayMetadata.test.ts +582 -0
  44. package/src/hooks/useOptimizedDayMetadata.ts +93 -0
  45. package/src/hooks/useTheme.ts +14 -0
  46. package/src/index.ts +24 -0
  47. package/tsconfig.json +13 -0
  48. package/tsup.config.ts +15 -0
@@ -0,0 +1,326 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-native";
2
+ import {
3
+ addDays,
4
+ addMonths,
5
+ endOfYear,
6
+ startOfMonth,
7
+ startOfYear,
8
+ subMonths,
9
+ } from "date-fns";
10
+ import { useCallback, useMemo, useRef, useState } from "react";
11
+ import { Button, Text, View } from "react-native";
12
+
13
+ import type { CalendarListProps, CalendarListRef } from "@/components";
14
+ import { Calendar } from "@/components";
15
+ import { HStack } from "@/components/HStack";
16
+ import { VStack } from "@/components/VStack";
17
+ import { paddingDecorator } from "@/developer/decorators";
18
+ import { loggingHandler } from "@/developer/loggginHandler";
19
+ import { fromDateId, toDateId } from "@/helpers/dates";
20
+ import type { CalendarActiveDateRange } from "@/hooks/useCalendar";
21
+ import { useDateRange } from "@/hooks/useDateRange";
22
+ import { useTheme } from "@/hooks/useTheme";
23
+
24
+ const today = new Date();
25
+
26
+ const startOfThisMonth = startOfMonth(today);
27
+
28
+ const CalendarListMeta: Meta<typeof Calendar.List> = {
29
+ title: "Calendar.List",
30
+ component: Calendar.List,
31
+ argTypes: {},
32
+ args: {
33
+ onCalendarDayPress: loggingHandler("onCalendarDayPress"),
34
+ calendarRowVerticalSpacing: 8,
35
+ calendarRowHorizontalSpacing: 8,
36
+ calendarFutureScrollRangeInMonths: 12,
37
+ calendarPastScrollRangeInMonths: 12,
38
+
39
+ calendarFirstDayOfWeek: "sunday",
40
+ calendarInitialMonthId: toDateId(startOfThisMonth),
41
+ },
42
+ decorators: [paddingDecorator],
43
+ };
44
+
45
+ export default CalendarListMeta;
46
+
47
+ export const Default: StoryObj<typeof Calendar.List> = {};
48
+
49
+ export const SpacingDense: StoryObj<typeof Calendar.List> = {
50
+ args: {
51
+ calendarRowVerticalSpacing: 0,
52
+ calendarRowHorizontalSpacing: 4,
53
+ },
54
+ };
55
+
56
+ export const WithDateRangeAndDisabledDates: StoryObj<typeof Calendar.List> = {
57
+ args: {
58
+ calendarActiveDateRanges: [
59
+ {
60
+ startId: "2024-01-15",
61
+ endId: "2024-01-28",
62
+ },
63
+ ],
64
+ calendarDisabledDateIds: ["2024-01-01", "2024-01-02"],
65
+ calendarInitialMonthId: "2024-01-01",
66
+ },
67
+ };
68
+
69
+ export const WithShortFutureScrollRange: StoryObj<typeof Calendar.List> = {
70
+ args: {
71
+ calendarPastScrollRangeInMonths: 1,
72
+ calendarFutureScrollRangeInMonths: 1,
73
+ calendarInitialMonthId: "2024-02-01",
74
+ },
75
+ };
76
+
77
+ export function SpacingSparse() {
78
+ const [selectedDate, setSelectedDate] = useState<undefined | string>(
79
+ undefined
80
+ );
81
+
82
+ const onCalendarDayPress = (dateId: string) => {
83
+ setSelectedDate(dateId);
84
+ };
85
+
86
+ return (
87
+ <VStack grow spacing={24}>
88
+ <Text>Selected date: {selectedDate}</Text>
89
+
90
+ <Calendar.List
91
+ calendarActiveDateRanges={[
92
+ {
93
+ startId: selectedDate,
94
+ endId: selectedDate,
95
+ },
96
+ ]}
97
+ calendarDayHeight={50}
98
+ calendarInitialMonthId={selectedDate}
99
+ calendarMonthHeaderHeight={20}
100
+ calendarRowHorizontalSpacing={16}
101
+ calendarRowVerticalSpacing={16}
102
+ calendarSpacing={48}
103
+ calendarWeekHeaderHeight={32}
104
+ onCalendarDayPress={onCalendarDayPress}
105
+ />
106
+ </VStack>
107
+ );
108
+ }
109
+
110
+ export function ImperativeScrolling() {
111
+ const [currentMonth, setCurrentMonth] = useState(startOfMonth(new Date()));
112
+
113
+ const [activeDateId, setActiveDateId] = useState<string | undefined>(
114
+ toDateId(addDays(currentMonth, 3))
115
+ );
116
+
117
+ const calendarActiveDateRanges = useMemo<CalendarActiveDateRange[]>(() => {
118
+ if (!activeDateId) return [];
119
+
120
+ return [{ startId: activeDateId, endId: activeDateId }];
121
+ }, [activeDateId]);
122
+
123
+ const ref = useRef<CalendarListRef>(null);
124
+
125
+ const onCalendarDayPress = useCallback((dateId: string) => {
126
+ ref.current?.scrollToDate(fromDateId(dateId), true);
127
+ setActiveDateId(dateId);
128
+ }, []);
129
+
130
+ return (
131
+ <View style={{ paddingTop: 20, flex: 1 }}>
132
+ <VStack alignItems="center" grow spacing={20}>
133
+ <HStack spacing={12}>
134
+ <Button
135
+ onPress={() => {
136
+ const pastMonth = subMonths(currentMonth, 1);
137
+ setCurrentMonth(pastMonth);
138
+ ref.current?.scrollToMonth(pastMonth, true);
139
+ }}
140
+ title="Past month"
141
+ />
142
+ <Text>Current: {toDateId(currentMonth)}</Text>
143
+ <Button
144
+ onPress={() => {
145
+ const nextMonth = addMonths(currentMonth, 1);
146
+ setCurrentMonth(nextMonth);
147
+ ref.current?.scrollToMonth(nextMonth, true);
148
+ }}
149
+ title="Next month"
150
+ />
151
+ </HStack>
152
+ <Button
153
+ onPress={() => {
154
+ const thisMonth = startOfMonth(new Date());
155
+ setCurrentMonth(thisMonth);
156
+ ref.current?.scrollToMonth(thisMonth, true);
157
+ }}
158
+ title="Today"
159
+ />
160
+ <View style={{ flex: 1, width: "100%" }}>
161
+ <Calendar.List
162
+ calendarActiveDateRanges={calendarActiveDateRanges}
163
+ calendarInitialMonthId={toDateId(currentMonth)}
164
+ onCalendarDayPress={onCalendarDayPress}
165
+ ref={ref}
166
+ />
167
+ </View>
168
+ </VStack>
169
+ </View>
170
+ );
171
+ }
172
+
173
+ export function MinAndMaxDates() {
174
+ return (
175
+ <VStack alignItems="center" grow spacing={20}>
176
+ <Text>This calendar list is only available for the 2024 period</Text>
177
+ <View style={{ flex: 1, width: "100%" }}>
178
+ <Calendar.List
179
+ calendarInitialMonthId="2024-02-13"
180
+ calendarMaxDateId="2024-12-31"
181
+ calendarMinDateId="2024-01-01"
182
+ onCalendarDayPress={loggingHandler("onCalendarDayPress")}
183
+ />
184
+ </View>
185
+ </VStack>
186
+ );
187
+ }
188
+
189
+ export function DateRangePicker() {
190
+ const calendarListProps = useMemo<Partial<CalendarListProps>>(() => {
191
+ const today = new Date();
192
+ return {
193
+ calendarInitialMonthId: toDateId(today),
194
+ calendarMinDateId: toDateId(startOfYear(today)),
195
+ calendarMaxDateId: toDateId(endOfYear(today)),
196
+ };
197
+ }, []);
198
+
199
+ const calendarDateRangeProps = useDateRange();
200
+ const { onClearDateRange, dateRange, isDateRangeValid } =
201
+ calendarDateRangeProps;
202
+
203
+ const { colors } = useTheme();
204
+
205
+ const textProps = {
206
+ style: { color: colors.content.primary },
207
+ };
208
+
209
+ return (
210
+ <VStack alignItems="center" grow spacing={20}>
211
+ <Text style={{ ...textProps.style, fontWeight: "bold" }}>
212
+ This shows how to build a date range picker bounded by the current year
213
+ </Text>
214
+ <View style={{ flex: 1, width: "100%" }}>
215
+ <Calendar.List {...calendarListProps} {...calendarDateRangeProps} />
216
+ </View>
217
+ <HStack justifyContent="space-between" width="100%">
218
+ <Button onPress={onClearDateRange} title="Clear range" />
219
+ <VStack spacing={4}>
220
+ <Text {...textProps}>Start: {dateRange.startId ?? "?"}</Text>
221
+ <Text {...textProps}>End: {dateRange.endId ?? "?"}</Text>
222
+ </VStack>
223
+ <VStack alignItems="flex-end" spacing={4}>
224
+ <Text {...textProps}>Is range valid?</Text>
225
+ <Text {...textProps}>{isDateRangeValid ? "✅" : "❌"}</Text>
226
+ </VStack>
227
+ </HStack>
228
+ </VStack>
229
+ );
230
+ }
231
+
232
+ export const DatePicker = () => {
233
+ const [activeDateId, setActiveDateId] = useState<string | undefined>(
234
+ toDateId(addDays(startOfThisMonth, 3))
235
+ );
236
+
237
+ const calendarActiveDateRanges = useMemo<CalendarActiveDateRange[]>(() => {
238
+ if (!activeDateId) return [];
239
+
240
+ return [{ startId: activeDateId, endId: activeDateId }];
241
+ }, [activeDateId]);
242
+
243
+ return (
244
+ <Calendar.List
245
+ calendarActiveDateRanges={calendarActiveDateRanges}
246
+ calendarInitialMonthId={toDateId(startOfThisMonth)}
247
+ onCalendarDayPress={setActiveDateId}
248
+ />
249
+ );
250
+ };
251
+
252
+ export const ScrollingBackwardsWorkaround = () => {
253
+ return (
254
+ <VStack alignItems="stretch" grow spacing={12}>
255
+ <Text>This preloads all past months between Jan 1st 2020 and today</Text>
256
+
257
+ <Calendar.List
258
+ calendarFutureScrollRangeInMonths={1}
259
+ calendarInitialMonthId="2024-02-01"
260
+ calendarMaxDateId="2024-05-01"
261
+ calendarMinDateId="2020-01-01"
262
+ calendarPastScrollRangeInMonths={50}
263
+ onCalendarDayPress={loggingHandler("onCalendarDayPress")}
264
+ />
265
+ </VStack>
266
+ );
267
+ };
268
+
269
+ export const TwoCalendarListsMounted = () => {
270
+ return (
271
+ <VStack grow spacing={48}>
272
+ <CalendarInstanceDemo instanceId="First" startingIndex={3} />
273
+ <CalendarInstanceDemo instanceId="Second" startingIndex={10} />
274
+ </VStack>
275
+ );
276
+ };
277
+
278
+ function CalendarInstanceDemo({
279
+ instanceId,
280
+ startingIndex,
281
+ }: {
282
+ instanceId: string;
283
+ startingIndex: number;
284
+ }) {
285
+ const calendarDateRangeProps = useDateRange();
286
+ const rerender = useRef(0);
287
+ rerender.current += 1;
288
+ return (
289
+ <VStack grow spacing={8}>
290
+ <Calendar.List
291
+ {...calendarDateRangeProps}
292
+ calendarInstanceId={instanceId}
293
+ />
294
+ <Text>
295
+ {instanceId}: {calendarDateRangeProps.dateRange.startId} -{" "}
296
+ {calendarDateRangeProps.dateRange.endId} (re-renders: {rerender.current}
297
+ ⚡)
298
+ </Text>
299
+ </VStack>
300
+ );
301
+ }
302
+
303
+ export const Demo = () => {
304
+ const dateRangeOne = useDateRange();
305
+ const dateRangeTwo = useDateRange();
306
+ return (
307
+ <VStack grow spacing={48}>
308
+ <VStack grow spacing={4}>
309
+ <Text>First calendar</Text>
310
+ <Calendar
311
+ calendarInstanceId="First"
312
+ calendarMonthId="2024-08-01"
313
+ {...dateRangeOne}
314
+ />
315
+ </VStack>
316
+ <VStack grow spacing={4}>
317
+ <Text>Second calendar</Text>
318
+ <Calendar
319
+ calendarInstanceId="Second"
320
+ calendarMonthId="2024-08-01"
321
+ {...dateRangeTwo}
322
+ />
323
+ </VStack>
324
+ </VStack>
325
+ );
326
+ };
@@ -0,0 +1,373 @@
1
+ import {
2
+ LegendList as LegendListBase,
3
+ type LegendListProps,
4
+ type LegendListRef,
5
+ } from "@legendapp/list";
6
+ import { memo, useCallback, useImperativeHandle, useMemo, useRef } from "react";
7
+ import { View } from "react-native";
8
+
9
+ import type { CalendarProps } from "@/components/Calendar";
10
+ import { Calendar } from "@/components/Calendar";
11
+ import { getWeekOfMonth, startOfMonth, toDateId } from "@/helpers/dates";
12
+ import type { CalendarMonth } from "@/hooks/useCalendarList";
13
+ import { getHeightForMonth, useCalendarList } from "@/hooks/useCalendarList";
14
+
15
+ // Type assertion to make LegendList compatible with React 19
16
+ const LegendList = LegendListBase as <T>(
17
+ props: LegendListProps<T> & { ref?: React.Ref<LegendListRef> }
18
+ ) => React.ReactElement;
19
+ /**
20
+ * Represents each `CalendarList` item. It's enhanced with the required
21
+ * `Calendar` props to simplify building custom `Calendar` components.
22
+ */
23
+ export type CalendarMonthEnhanced = CalendarMonth & {
24
+ calendarProps: Omit<CalendarProps, "calendarMonthId">;
25
+ };
26
+
27
+ const keyExtractor = (month: CalendarMonth) => month.id;
28
+
29
+ export interface CalendarListProps
30
+ extends Omit<CalendarProps, "calendarMonthId">,
31
+ Omit<
32
+ LegendListProps<CalendarMonthEnhanced>,
33
+ "renderItem" | "data" | "children"
34
+ > {
35
+ /**
36
+ * How many months to show before the current month. Once the user scrolls
37
+ * past this range and if they haven't exceeded the `calendarMinDateId`, new
38
+ * months are prepended in this increment.
39
+ * @defaultValue 12
40
+ */
41
+ calendarPastScrollRangeInMonths?: number;
42
+ /**
43
+ * How many months to show after the current month. Once the user scrolls
44
+ * past this range and if they haven't exceeded the `calendarMaxDateId`, new
45
+ * months are appended in this increment.
46
+ * @defaultValue 12
47
+ */
48
+ calendarFutureScrollRangeInMonths?: number;
49
+
50
+ /**
51
+ * An additional height to use in the month's height calculation. Useful when
52
+ * providing a custom `Calendar` component with extra content such as a
53
+ * footer.
54
+ */
55
+ calendarAdditionalHeight?: number;
56
+
57
+ /**
58
+ * The vertical spacing between each `<Calendar />` component.
59
+ * @defaultValue 20
60
+ */
61
+ calendarSpacing?: number;
62
+
63
+ /**
64
+ * The initial month to open the calendar to, as a `YYYY-MM-DD` string.
65
+ * Defaults to the current month.
66
+ *
67
+ * **Tip**: To convert to date ID, use `toDateId(date)`.
68
+ */
69
+ calendarInitialMonthId?: string;
70
+
71
+ /**
72
+ * Overwrites the default `Calendar` component.
73
+ *
74
+ * **Important**: when providing a custom implementation, make sure to
75
+ * manually set all the spacing and height props to ensure the list scrolls
76
+ * to the right offset:
77
+ * - calendarDayHeight
78
+ * - calendarMonthHeaderHeight
79
+ * - calendarWeekHeaderHeight
80
+ * - calendarAdditionalHeight
81
+ * - calendarRowVerticalSpacing
82
+ * - calendarSpacing
83
+ */
84
+ renderItem?: LegendListProps<CalendarMonthEnhanced>["renderItem"];
85
+ }
86
+
87
+ interface ImperativeScrollParams {
88
+ /**
89
+ * An additional offset to add to the final scroll position. Useful when
90
+ * you need to slightly change the final scroll position.
91
+ */
92
+ additionalOffset?: number;
93
+ }
94
+ export interface CalendarListRef {
95
+ scrollToMonth: (
96
+ date: Date,
97
+ animated: boolean,
98
+ params?: ImperativeScrollParams
99
+ ) => void;
100
+ scrollToDate: (
101
+ date: Date,
102
+ animated: boolean,
103
+ params?: ImperativeScrollParams
104
+ ) => void;
105
+ scrollToOffset: (offset: number, animated: boolean) => void;
106
+ }
107
+
108
+ export const CalendarList = memo(function CalendarList({
109
+ ref,
110
+ ...props
111
+ }: CalendarListProps & { ref?: React.Ref<CalendarListRef> }) {
112
+ const {
113
+ // List-related props
114
+ calendarInitialMonthId,
115
+ calendarPastScrollRangeInMonths = 12,
116
+ calendarFutureScrollRangeInMonths = 12,
117
+ calendarFirstDayOfWeek = "sunday",
118
+ calendarFormatLocale,
119
+
120
+ // Spacings
121
+ calendarSpacing = 20,
122
+ calendarRowHorizontalSpacing,
123
+ calendarRowVerticalSpacing = 8,
124
+
125
+ // Heights
126
+ calendarMonthHeaderHeight = 20,
127
+ calendarDayHeight = 32,
128
+ calendarWeekHeaderHeight = calendarDayHeight,
129
+ calendarAdditionalHeight = 0,
130
+
131
+ // Other props
132
+ calendarColorScheme,
133
+ theme,
134
+ onEndReached,
135
+ onStartReached,
136
+ ...otherProps
137
+ } = props;
138
+
139
+ const {
140
+ calendarActiveDateRanges,
141
+ calendarDisabledDateIds,
142
+ calendarInstanceId,
143
+ calendarMaxDateId,
144
+ calendarMinDateId,
145
+ getCalendarDayFormat,
146
+ getCalendarMonthFormat,
147
+ getCalendarWeekDayFormat,
148
+ onCalendarDayPress,
149
+ CalendarPressableComponent,
150
+ ...flatListProps
151
+ } = otherProps;
152
+
153
+ const calendarProps = useMemo(
154
+ (): CalendarMonthEnhanced["calendarProps"] => ({
155
+ calendarActiveDateRanges,
156
+ calendarColorScheme,
157
+ calendarDayHeight,
158
+ calendarDisabledDateIds,
159
+ calendarFirstDayOfWeek,
160
+ calendarFormatLocale,
161
+ calendarInstanceId,
162
+ calendarMaxDateId,
163
+ calendarMinDateId,
164
+ calendarMonthHeaderHeight,
165
+ calendarRowHorizontalSpacing,
166
+ calendarRowVerticalSpacing,
167
+ calendarWeekHeaderHeight,
168
+ getCalendarDayFormat,
169
+ getCalendarMonthFormat,
170
+ getCalendarWeekDayFormat,
171
+ onCalendarDayPress,
172
+ theme,
173
+ CalendarPressableComponent,
174
+ }),
175
+ [
176
+ calendarColorScheme,
177
+ calendarActiveDateRanges,
178
+ calendarDayHeight,
179
+ calendarDisabledDateIds,
180
+ calendarFirstDayOfWeek,
181
+ calendarFormatLocale,
182
+ calendarMaxDateId,
183
+ calendarMinDateId,
184
+ calendarMonthHeaderHeight,
185
+ calendarRowHorizontalSpacing,
186
+ calendarRowVerticalSpacing,
187
+ calendarWeekHeaderHeight,
188
+ getCalendarDayFormat,
189
+ getCalendarMonthFormat,
190
+ getCalendarWeekDayFormat,
191
+ calendarInstanceId,
192
+ onCalendarDayPress,
193
+ theme,
194
+ CalendarPressableComponent,
195
+ ]
196
+ );
197
+
198
+ const {
199
+ initialMonthIndex,
200
+ monthList,
201
+ appendMonths,
202
+ prependMonths,
203
+ addMissingMonths,
204
+ } = useCalendarList({
205
+ calendarFirstDayOfWeek,
206
+ calendarFutureScrollRangeInMonths,
207
+ calendarPastScrollRangeInMonths,
208
+ calendarInitialMonthId,
209
+ calendarMaxDateId,
210
+ calendarMinDateId,
211
+ });
212
+
213
+ const monthListWithCalendarProps = useMemo(() => {
214
+ return monthList.map((month) => ({
215
+ ...month,
216
+ calendarProps,
217
+ }));
218
+ }, [calendarProps, monthList]);
219
+
220
+ const handleOnEndReached = useCallback(
221
+ (info: { distanceFromEnd: number }) => {
222
+ appendMonths(calendarFutureScrollRangeInMonths);
223
+ onEndReached?.(info);
224
+ },
225
+ [appendMonths, calendarFutureScrollRangeInMonths, onEndReached]
226
+ );
227
+
228
+ const handleOnStartReached = useCallback(
229
+ (info: { distanceFromStart: number }) => {
230
+ prependMonths(calendarPastScrollRangeInMonths);
231
+ onStartReached?.(info);
232
+ },
233
+ [prependMonths, calendarPastScrollRangeInMonths, onStartReached]
234
+ );
235
+
236
+ const handleGetFixedItemSize = useCallback(
237
+ (_index: number, item: CalendarMonth) => {
238
+ return getHeightForMonth({
239
+ calendarMonth: item,
240
+ calendarSpacing,
241
+ calendarDayHeight,
242
+ calendarMonthHeaderHeight,
243
+ calendarRowVerticalSpacing,
244
+ calendarAdditionalHeight,
245
+ calendarWeekHeaderHeight,
246
+ });
247
+ },
248
+ [
249
+ calendarAdditionalHeight,
250
+ calendarDayHeight,
251
+ calendarMonthHeaderHeight,
252
+ calendarRowVerticalSpacing,
253
+ calendarSpacing,
254
+ calendarWeekHeaderHeight,
255
+ ]
256
+ );
257
+
258
+ /**
259
+ * Returns the offset for the given month (how much the user needs to
260
+ * scroll to reach the month).
261
+ */
262
+ const getScrollOffsetForMonth = useCallback(
263
+ (date: Date) => {
264
+ const monthId = toDateId(startOfMonth(date));
265
+
266
+ let baseMonthList = monthList;
267
+ let index = baseMonthList.findIndex((month) => month.id === monthId);
268
+
269
+ if (index === -1) {
270
+ baseMonthList = addMissingMonths(monthId);
271
+ index = baseMonthList.findIndex((month) => month.id === monthId);
272
+ }
273
+
274
+ return baseMonthList.slice(0, index).reduce((acc, month) => {
275
+ const currentHeight = getHeightForMonth({
276
+ calendarMonth: month,
277
+ calendarSpacing,
278
+ calendarDayHeight,
279
+ calendarMonthHeaderHeight,
280
+ calendarRowVerticalSpacing,
281
+ calendarWeekHeaderHeight,
282
+ calendarAdditionalHeight,
283
+ });
284
+
285
+ return acc + currentHeight;
286
+ }, 0);
287
+ },
288
+ [
289
+ addMissingMonths,
290
+ calendarAdditionalHeight,
291
+ calendarDayHeight,
292
+ calendarMonthHeaderHeight,
293
+ calendarRowVerticalSpacing,
294
+ calendarSpacing,
295
+ calendarWeekHeaderHeight,
296
+ monthList,
297
+ ]
298
+ );
299
+
300
+ const legendListRef = useRef<LegendListRef>(null);
301
+
302
+ useImperativeHandle(ref, () => ({
303
+ scrollToMonth(
304
+ date,
305
+ animated,
306
+ { additionalOffset = 0 } = { additionalOffset: 0 }
307
+ ) {
308
+ // Wait for the next render cycle to ensure the list has been
309
+ // updated with the new months.
310
+ setTimeout(() => {
311
+ legendListRef.current?.scrollToOffset({
312
+ offset: getScrollOffsetForMonth(date) + additionalOffset,
313
+ animated,
314
+ });
315
+ }, 0);
316
+ },
317
+ scrollToDate(
318
+ date,
319
+ animated,
320
+ { additionalOffset = 0 } = {
321
+ additionalOffset: 0,
322
+ }
323
+ ) {
324
+ const currentMonthOffset = getScrollOffsetForMonth(date);
325
+ const weekOfMonthIndex = getWeekOfMonth(date, calendarFirstDayOfWeek);
326
+ const rowHeight = calendarDayHeight + calendarRowVerticalSpacing;
327
+
328
+ let weekOffset = calendarWeekHeaderHeight + rowHeight * weekOfMonthIndex;
329
+
330
+ /**
331
+ * We need to subtract one vertical spacing to avoid cutting off the
332
+ * desired date. A simple way of understanding why is imagining we
333
+ * want to scroll exactly to the given date, but leave a little bit of
334
+ * breathing room (`calendarRowVerticalSpacing`) above it.
335
+ */
336
+ weekOffset = weekOffset - calendarRowVerticalSpacing;
337
+
338
+ legendListRef.current?.scrollToOffset({
339
+ offset: currentMonthOffset + weekOffset + additionalOffset,
340
+ animated,
341
+ });
342
+ },
343
+ scrollToOffset(offset, animated) {
344
+ legendListRef.current?.scrollToOffset({ offset, animated });
345
+ },
346
+ }));
347
+
348
+ const calendarContainerStyle = useMemo(() => {
349
+ return { paddingBottom: calendarSpacing };
350
+ }, [calendarSpacing]);
351
+
352
+ return (
353
+ <LegendList
354
+ data={monthListWithCalendarProps}
355
+ estimatedItemSize={273}
356
+ getFixedItemSize={handleGetFixedItemSize}
357
+ initialScrollIndex={initialMonthIndex}
358
+ keyExtractor={keyExtractor}
359
+ onEndReached={handleOnEndReached}
360
+ onStartReached={handleOnStartReached}
361
+ recycleItems
362
+ ref={legendListRef}
363
+ renderItem={({ item }: { item: CalendarMonthEnhanced }) => (
364
+ <View style={calendarContainerStyle}>
365
+ <Calendar calendarMonthId={item.id} {...item.calendarProps} />
366
+ </View>
367
+ )}
368
+ showsVerticalScrollIndicator={false}
369
+ style={{ flex: 1 }}
370
+ {...flatListProps}
371
+ />
372
+ );
373
+ });