diff --git a/.changeset/olive-pears-rhyme.md b/.changeset/olive-pears-rhyme.md new file mode 100644 index 0000000..fb3e096 --- /dev/null +++ b/.changeset/olive-pears-rhyme.md @@ -0,0 +1,5 @@ +--- +"@marceloterreiro/flash-calendar": minor +--- + +Add the ability to mount more than one calendar at once diff --git a/apps/docs/docs/fundamentals/assets/two-calendars-mounted.png b/apps/docs/docs/fundamentals/assets/two-calendars-mounted.png new file mode 100644 index 0000000..254a185 Binary files /dev/null and b/apps/docs/docs/fundamentals/assets/two-calendars-mounted.png differ diff --git a/apps/docs/docs/fundamentals/usage.mdx b/apps/docs/docs/fundamentals/usage.mdx index 06d9927..5aaca4e 100644 --- a/apps/docs/docs/fundamentals/usage.mdx +++ b/apps/docs/docs/fundamentals/usage.mdx @@ -7,6 +7,7 @@ import { HStack } from "@site/src/components/HStack"; import basicCalendar from "./assets/basic-calendar.png"; import basicCalendarList from "./assets/basic-calendar-list.png"; import dateRangeCalendarList from "./assets/date-range-calendar-list.png"; +import twoCalendarsMounted from "./assets/two-calendars-mounted.png"; import customFormatting from "./assets/custom-formatting.png"; import customLocale from "./assets/custom-locale.png"; import disabledDates from "./assets/disabled-dates.png"; @@ -360,3 +361,47 @@ the [source](https://github.com/marceloprado/flash-calendar/blob/d832d72867d40c9b375c30aec9985b181c0a80f3/kitchen-sink/expo/src/components/BottomSheetFlashList/BottomSheetFlashList.tsx). Bear in mind this isn't a very performant implementation. Contributions welcomed! + +## Two calendars in the same screen + +To render more than one calendar in the same screen, use the +`calendarInstanceId` prop. This works for both `Calendar` and `Calendar.List`: + + + +
+ +```tsx +import { Calendar, useDateRange } from "@marceloterreiro/flash-calendar"; + +export const TwoCalendarsMounted = () => { + const dateRangeOne = useDateRange(); + const dateRangeTwo = useDateRange(); + return ( + + + First calendar + + + + Second calendar + + + + ); +}; +``` + +
+ + + +
diff --git a/apps/example/src/components/PerfTestCalendar/PerfTestCalendar.tsx b/apps/example/src/components/PerfTestCalendar/PerfTestCalendar.tsx index 18589a9..ea3fc9d 100644 --- a/apps/example/src/components/PerfTestCalendar/PerfTestCalendar.tsx +++ b/apps/example/src/components/PerfTestCalendar/PerfTestCalendar.tsx @@ -96,10 +96,9 @@ BasePerfTestCalendar.displayName = "BasePerfTestCalendar"; export const PerfTestCalendar = memo( ({ calendarActiveDateRanges, calendarMonthId, ...props }: CalendarProps) => { useEffect(() => { - activeDateRangesEmitter.emit( - "onSetActiveDateRanges", - calendarActiveDateRanges ?? [] - ); + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + ranges: calendarActiveDateRanges ?? [], + }); /** * While `calendarMonthId` is not used by the effect, we still need it in * the dependency array since [FlashList uses recycling diff --git a/packages/flash-calendar/src/components/Calendar.stories.tsx b/packages/flash-calendar/src/components/Calendar.stories.tsx index 5328c55..7331223 100644 --- a/packages/flash-calendar/src/components/Calendar.stories.tsx +++ b/packages/flash-calendar/src/components/Calendar.stories.tsx @@ -1,12 +1,14 @@ import type { Meta, StoryObj } from "@storybook/react"; import { addDays, subDays } from "date-fns"; import { format } from "date-fns/fp/format"; -import { useState } from "react"; +import { useRef, useState } from "react"; +import { Text } from "react-native"; import { paddingDecorator } from "@/developer/decorators"; import { loggingHandler } from "@/developer/loggginHandler"; import { endOfMonth, startOfMonth, toDateId } from "@/helpers/dates"; import { useDateRange } from "@/hooks/useDateRange"; +import { VStack } from "@/components/VStack"; import { Calendar } from "./Calendar"; @@ -128,7 +130,7 @@ export const WithCustomFormatting = (args: typeof KichenSink.args) => { }; export const DatePicker = (args: typeof KichenSink.args) => { - const [activeDateId, setActiveDateId] = useState( + const [activeDateId, setActiveDateId] = useState( toDateId(addDays(startOfThisMonth, 3)) ); @@ -181,3 +183,44 @@ export const LightModeOnly = () => { /> ); }; + +export const TwoCalendarsMounted = () => { + return ( + + + + + ); +}; + +function CalendarInstanceDemo({ + instanceId, + startingIndex, +}: { + instanceId: string; + startingIndex: number; +}) { + const [date, setDate] = useState( + toDateId(addDays(startOfThisMonth, startingIndex)) + ); + const rerender = useRef(0); + rerender.current += 1; + return ( + + + + {instanceId} date: {date} (re-renders: {rerender.current}⚡) + + + ); +} diff --git a/packages/flash-calendar/src/components/Calendar.tsx b/packages/flash-calendar/src/components/Calendar.tsx index 28a83c8..3f6bcee 100644 --- a/packages/flash-calendar/src/components/Calendar.tsx +++ b/packages/flash-calendar/src/components/Calendar.tsx @@ -41,7 +41,18 @@ export interface CalendarTheme { export type CalendarOnDayPress = (dateId: string) => void; export interface CalendarProps extends UseCalendarParams { - onCalendarDayPress: CalendarOnDayPress; + /** + * A unique identifier for this calendar instance. This is useful if you + * need to render more than one calendar at once. This allows Flash Calendar + * to scope its state to the given instance. + * + * No need to get fancy with `uuid` or anything like that - a simple static + * string is enough. + * + * If not provided, Flash Calendar will use a default value which will hoist + * the state in a global scope. + */ + calendarInstanceId?: string; /** * The spacing between each calendar row (the month header, the week days row, * and the weeks row) @@ -78,119 +89,126 @@ export interface CalendarProps extends UseCalendarParams { * @defaultValue undefined */ calendarColorScheme?: ColorSchemeName; + /** + * The callback to be called when a day is pressed. + */ + onCalendarDayPress: CalendarOnDayPress; /** Theme to customize the calendar component. */ theme?: CalendarTheme; } -const BaseCalendar = memo( - ({ - onCalendarDayPress, +const BaseCalendar = memo(function BaseCalendar(props: CalendarProps) { + const { + calendarInstanceId, calendarRowVerticalSpacing = 8, calendarRowHorizontalSpacing = 8, - theme, calendarDayHeight = 32, calendarMonthHeaderHeight = 20, calendarWeekHeaderHeight = calendarDayHeight, + onCalendarDayPress, + theme, ...buildCalendarParams - }: CalendarProps) => { - const { calendarRowMonth, weeksList, weekDaysList } = - useCalendar(buildCalendarParams); + } = props; - return ( - - - {uppercaseFirstLetter(calendarRowMonth)} - - - {weekDaysList.map((weekDay, i) => ( - - {weekDay} - - ))} - - {weeksList.map((week, index) => ( - - {week.map((dayProps) => { - if (dayProps.isDifferentMonth) { - return ( - - - - ); - } + const { calendarRowMonth, weeksList, weekDaysList } = + useCalendar(buildCalendarParams); + return ( + + + {uppercaseFirstLetter(calendarRowMonth)} + + + {weekDaysList.map((weekDay, i) => ( + + {weekDay} + + ))} + + {weeksList.map((week, index) => ( + + {week.map((dayProps) => { + if (dayProps.isDifferentMonth) { return ( - - {dayProps.displayLabel} - + + ); - })} - - ))} - - ); - } -); -BaseCalendar.displayName = "BaseCalendar"; + } -export const Calendar = memo( - ({ + return ( + + {dayProps.displayLabel} + + ); + })} + + ))} + + ); +}); + +export const Calendar = memo(function Calendar(props: CalendarProps) { + const { + calendarInstanceId, calendarActiveDateRanges, calendarMonthId, calendarColorScheme, - ...props - }: CalendarProps) => { - useEffect(() => { - activeDateRangesEmitter.emit( - "onSetActiveDateRanges", - calendarActiveDateRanges ?? [] - ); - /** - * While `calendarMonthId` is not used by the effect, we still need it in - * the dependency array since [FlashList uses recycling - * internally](https://shopify.github.io/flash-list/docs/recycling). - * - * This means `Calendar` can re-render with different props instead of - * getting re-mounted. Without it, we would see staled/invalid data, as - * reported by - * [#11](https://github.com/MarceloPrado/flash-calendar/issues/11). - */ - }, [calendarActiveDateRanges, calendarMonthId]); - - return ( - - - - ); - } -); + ...otherProps + } = props; + useEffect(() => { + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + instanceId: calendarInstanceId, + ranges: calendarActiveDateRanges ?? [], + }); + /** + * While `calendarMonthId` is not used by the effect, we still need it in + * the dependency array since [FlashList uses recycling + * internally](https://shopify.github.io/flash-list/docs/recycling). + * + * This means `Calendar` can re-render with different props instead of + * getting re-mounted. Without it, we would see staled/invalid data, as + * reported by + * [#11](https://github.com/MarceloPrado/flash-calendar/issues/11). + */ + }, [calendarActiveDateRanges, calendarInstanceId, calendarMonthId]); -Calendar.displayName = "Calendar"; + return ( + + + + ); +}); diff --git a/packages/flash-calendar/src/components/CalendarItemDay.tsx b/packages/flash-calendar/src/components/CalendarItemDay.tsx index d28ef55..073dc00 100644 --- a/packages/flash-calendar/src/components/CalendarItemDay.tsx +++ b/packages/flash-calendar/src/components/CalendarItemDay.tsx @@ -288,6 +288,18 @@ export interface CalendarItemDayWithContainerProps extends Omit, Pick { containerTheme?: CalendarItemDayContainerTheme; + /** + * A unique identifier for this calendar instance. This is useful if you + * need to render more than one calendar at once. This allows Flash Calendar + * to scope its state to the given instance. + * + * No need to get fancy with `uuid` or anything like that - a simple static + * string is enough. + * + * If not provided, Flash Calendar will use a default value which will hoist + * the state in a global scope. + */ + calendarInstanceId?: string; } export const CalendarItemDayWithContainer = ({ @@ -298,8 +310,9 @@ export const CalendarItemDayWithContainer = ({ dayHeight, daySpacing, containerTheme, + calendarInstanceId, }: CalendarItemDayWithContainerProps) => { - const metadata = useOptimizedDayMetadata(baseMetadata); + const metadata = useOptimizedDayMetadata(baseMetadata, calendarInstanceId); return ( { - const containerStyles = useMemo(() => { - return [{ ...styles.container, height }, theme?.container]; - }, [height, theme?.container]); +export const CalendarItemEmpty = memo(function CalendarItemEmpty( + props: CalendarItemEmptyProps +) { + const { height, theme } = props; + const containerStyles = useMemo(() => { + return [{ ...styles.container, height }, theme?.container]; + }, [height, theme?.container]); - return ; - } -); -CalendarItemEmpty.displayName = "CalendarItemEmpty"; + return ; +}); diff --git a/packages/flash-calendar/src/components/CalendarList.stories.tsx b/packages/flash-calendar/src/components/CalendarList.stories.tsx index a626402..f25a1f8 100644 --- a/packages/flash-calendar/src/components/CalendarList.stories.tsx +++ b/packages/flash-calendar/src/components/CalendarList.stories.tsx @@ -265,3 +265,62 @@ export const ScrollingBackwardsWorkaround = () => { ); }; + +export const TwoCalendarListsMounted = () => { + return ( + + + + + ); +}; + +function CalendarInstanceDemo({ + instanceId, + startingIndex, +}: { + instanceId: string; + startingIndex: number; +}) { + const calendarDateRangeProps = useDateRange(); + const rerender = useRef(0); + rerender.current += 1; + return ( + + + + {instanceId}: {calendarDateRangeProps.dateRange.startId} -{" "} + {calendarDateRangeProps.dateRange.endId} (re-renders: {rerender.current} + ⚡) + + + ); +} + +export const Demo = () => { + const dateRangeOne = useDateRange(); + const dateRangeTwo = useDateRange(); + return ( + + + First calendar + + + + Second calendar + + + + ); +}; diff --git a/packages/flash-calendar/src/components/CalendarList.tsx b/packages/flash-calendar/src/components/CalendarList.tsx index 9b94a8d..54878fe 100644 --- a/packages/flash-calendar/src/components/CalendarList.tsx +++ b/packages/flash-calendar/src/components/CalendarList.tsx @@ -111,251 +111,252 @@ export interface CalendarListRef { } export const CalendarList = memo( - forwardRef( - ( - { - // List-related props - calendarInitialMonthId, - calendarPastScrollRangeInMonths = 12, - calendarFutureScrollRangeInMonths = 12, - calendarFirstDayOfWeek = "sunday", - CalendarScrollComponent = FlashList, + forwardRef(function CalendarList( + props: CalendarListProps, + ref: Ref + ) { + const { + // List-related props + calendarInitialMonthId, + calendarPastScrollRangeInMonths = 12, + calendarFutureScrollRangeInMonths = 12, + calendarFirstDayOfWeek = "sunday", + CalendarScrollComponent = FlashList, + calendarFormatLocale, + + // Spacings + calendarSpacing = 20, + calendarRowHorizontalSpacing, + calendarRowVerticalSpacing = 8, + + // Heights + calendarMonthHeaderHeight = 20, + calendarDayHeight = 32, + calendarWeekHeaderHeight = calendarDayHeight, + calendarAdditionalHeight = 0, + + // Other props + calendarColorScheme, + theme, + onEndReached, + ...otherProps + } = props; + + const { + calendarActiveDateRanges, + calendarDisabledDateIds, + calendarInstanceId, + calendarMaxDateId, + calendarMinDateId, + getCalendarDayFormat, + getCalendarMonthFormat, + getCalendarWeekDayFormat, + onCalendarDayPress, + ...flatListProps + } = otherProps; + + const calendarProps = useMemo( + (): CalendarMonthEnhanced["calendarProps"] => ({ + calendarActiveDateRanges, + calendarColorScheme, + calendarDayHeight, + calendarDisabledDateIds, + calendarFirstDayOfWeek, calendarFormatLocale, - - // Spacings - calendarSpacing = 20, + calendarInstanceId, + calendarMaxDateId, + calendarMinDateId, + calendarMonthHeaderHeight, calendarRowHorizontalSpacing, - calendarRowVerticalSpacing = 8, - - // Heights - calendarMonthHeaderHeight = 20, - calendarDayHeight = 32, - calendarWeekHeaderHeight = calendarDayHeight, - calendarAdditionalHeight = 0, - - // Other props - calendarColorScheme, - theme, - onEndReached, - ...props - }: CalendarListProps, - ref: Ref - ) => { - const { + calendarRowVerticalSpacing, + calendarWeekHeaderHeight, + getCalendarDayFormat, + getCalendarMonthFormat, + getCalendarWeekDayFormat, onCalendarDayPress, + theme, + }), + [ + calendarColorScheme, calendarActiveDateRanges, + calendarDayHeight, calendarDisabledDateIds, + calendarFirstDayOfWeek, + calendarFormatLocale, + calendarMaxDateId, + calendarMinDateId, + calendarMonthHeaderHeight, + calendarRowHorizontalSpacing, + calendarRowVerticalSpacing, + calendarWeekHeaderHeight, getCalendarDayFormat, - getCalendarWeekDayFormat, getCalendarMonthFormat, + getCalendarWeekDayFormat, + calendarInstanceId, + onCalendarDayPress, + theme, + ] + ); + + const { initialMonthIndex, monthList, appendMonths, addMissingMonths } = + useCalendarList({ + calendarFirstDayOfWeek, + calendarFutureScrollRangeInMonths, + calendarPastScrollRangeInMonths, + calendarInitialMonthId, calendarMaxDateId, calendarMinDateId, - ...flatListProps - } = props; + }); - const calendarProps = useMemo( - (): CalendarMonthEnhanced["calendarProps"] => ({ - calendarColorScheme, - calendarActiveDateRanges, - calendarDayHeight, - calendarDisabledDateIds, - calendarFirstDayOfWeek, - calendarFormatLocale, - calendarMaxDateId, - calendarMinDateId, - calendarMonthHeaderHeight, - calendarRowHorizontalSpacing, - calendarRowVerticalSpacing, - calendarWeekHeaderHeight, - getCalendarDayFormat, - getCalendarMonthFormat, - getCalendarWeekDayFormat, - onCalendarDayPress, - theme, - }), - [ - calendarActiveDateRanges, + const monthListWithCalendarProps = useMemo(() => { + return monthList.map((month) => ({ + ...month, + calendarProps, + })); + }, [calendarProps, monthList]); + + const handleOnEndReached = useCallback(() => { + appendMonths(calendarFutureScrollRangeInMonths); + onEndReached?.(); + }, [appendMonths, calendarFutureScrollRangeInMonths, onEndReached]); + + const handleOverrideItemLayout = useCallback< + NonNullable["overrideItemLayout"]> + >( + (layout, item) => { + const monthHeight = getHeightForMonth({ + calendarMonth: item, + calendarSpacing, calendarDayHeight, - calendarFirstDayOfWeek, - getCalendarDayFormat, - getCalendarWeekDayFormat, - calendarMaxDateId, - calendarMinDateId, - calendarFormatLocale, calendarMonthHeaderHeight, - calendarRowHorizontalSpacing, - getCalendarMonthFormat, calendarRowVerticalSpacing, + calendarAdditionalHeight, calendarWeekHeaderHeight, - calendarDisabledDateIds, - calendarColorScheme, - onCalendarDayPress, - theme, - ] - ); - - const { initialMonthIndex, monthList, appendMonths, addMissingMonths } = - useCalendarList({ - calendarFirstDayOfWeek, - calendarFutureScrollRangeInMonths, - calendarPastScrollRangeInMonths, - calendarInitialMonthId, - calendarMaxDateId, - calendarMinDateId, }); - - const monthListWithCalendarProps = useMemo(() => { - return monthList.map((month) => ({ - ...month, - calendarProps, - })); - }, [calendarProps, monthList]); - - const handleOnEndReached = useCallback(() => { - appendMonths(calendarFutureScrollRangeInMonths); - onEndReached?.(); - }, [appendMonths, calendarFutureScrollRangeInMonths, onEndReached]); - - const handleOverrideItemLayout = useCallback< - NonNullable["overrideItemLayout"]> - >( - (layout, item) => { - const monthHeight = getHeightForMonth({ - calendarMonth: item, + layout.size = monthHeight; + }, + [ + calendarAdditionalHeight, + calendarDayHeight, + calendarMonthHeaderHeight, + calendarRowVerticalSpacing, + calendarSpacing, + calendarWeekHeaderHeight, + ] + ); + + /** + * 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; + let index = baseMonthList.findIndex((month) => month.id === monthId); + + if (index === -1) { + baseMonthList = addMissingMonths(monthId); + index = baseMonthList.findIndex((month) => month.id === monthId); + } + + return baseMonthList.slice(0, index).reduce((acc, month) => { + const currentHeight = getHeightForMonth({ + calendarMonth: month, calendarSpacing, calendarDayHeight, calendarMonthHeaderHeight, calendarRowVerticalSpacing, - calendarAdditionalHeight, calendarWeekHeaderHeight, + calendarAdditionalHeight, }); - layout.size = monthHeight; - }, - [ - calendarAdditionalHeight, - calendarDayHeight, - calendarMonthHeaderHeight, - calendarRowVerticalSpacing, - calendarSpacing, - calendarWeekHeaderHeight, - ] - ); - - /** - * 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; - let index = baseMonthList.findIndex((month) => month.id === monthId); - - if (index === -1) { - baseMonthList = addMissingMonths(monthId); - index = baseMonthList.findIndex((month) => month.id === monthId); - } - - 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: 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; + 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: currentMonthOffset + weekOffset + additionalOffset, + offset: getScrollOffsetForMonth(date) + additionalOffset, animated, }); - }, - scrollToOffset(offset, animated) { - flashListRef.current?.scrollToOffset({ offset, animated }); - }, - })); - - const calendarContainerStyle = useMemo(() => { - return { paddingBottom: calendarSpacing }; - }, [calendarSpacing]); - - return ( - ( - - - - )} - showsVerticalScrollIndicator={false} - {...flatListProps} - /> - ); - } - ) + }, 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(() => { + return { paddingBottom: calendarSpacing }; + }, [calendarSpacing]); + + return ( + ( + + + + )} + showsVerticalScrollIndicator={false} + {...flatListProps} + /> + ); + }) ); - -CalendarList.displayName = "CalendarList"; diff --git a/packages/flash-calendar/src/hooks/useOptimizedDayMetadata.test.ts b/packages/flash-calendar/src/hooks/useOptimizedDayMetadata.test.ts index dead4d9..0eb1670 100644 --- a/packages/flash-calendar/src/hooks/useOptimizedDayMetadata.test.ts +++ b/packages/flash-calendar/src/hooks/useOptimizedDayMetadata.test.ts @@ -105,12 +105,14 @@ describe("useOptimizedDayMetadata", () => { // Emit event act(() => { - activeDateRangesEmitter.emit("onSetActiveDateRanges", [ - { - startId: "2024-02-01", - endId: "2024-02-16", - }, - ]); + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + ranges: [ + { + startId: "2024-02-01", + endId: "2024-02-16", + }, + ], + }); }); expect(result.current).toEqual({ @@ -152,12 +154,14 @@ describe("useOptimizedDayMetadata", () => { // Emit event act(() => { - activeDateRangesEmitter.emit("onSetActiveDateRanges", [ - { - startId: "2024-02-01", - endId: "2024-02-16", - }, - ]); + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + ranges: [ + { + startId: "2024-02-01", + endId: "2024-02-16", + }, + ], + }); }); expect(result.current).toEqual({ @@ -199,12 +203,14 @@ describe("useOptimizedDayMetadata", () => { // Emit event act(() => { - activeDateRangesEmitter.emit("onSetActiveDateRanges", [ - { - startId: "2024-02-01", - endId: "2024-02-01", - }, - ]); + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + ranges: [ + { + startId: "2024-02-01", + endId: "2024-02-01", + }, + ], + }); }); expect(result.current).toEqual({ @@ -247,12 +253,14 @@ describe("useOptimizedDayMetadata", () => { // Emit event act(() => { - activeDateRangesEmitter.emit("onSetActiveDateRanges", [ - { - startId: "2024-02-01", - endId: "2024-02-17", - }, - ]); + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + ranges: [ + { + startId: "2024-02-01", + endId: "2024-02-17", + }, + ], + }); }); expect(result.current).toEqual(baseMetadata); @@ -260,12 +268,14 @@ describe("useOptimizedDayMetadata", () => { // Emit another event act(() => { - activeDateRangesEmitter.emit("onSetActiveDateRanges", [ - { - startId: "2024-02-19", - endId: "2024-02-23", - }, - ]); + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + ranges: [ + { + startId: "2024-02-19", + endId: "2024-02-23", + }, + ], + }); }); expect(result.current).toEqual(baseMetadata); @@ -273,12 +283,14 @@ describe("useOptimizedDayMetadata", () => { // Emit an incomplete range act(() => { - activeDateRangesEmitter.emit("onSetActiveDateRanges", [ - { - startId: "2024-02-01", - endId: undefined, - }, - ]); + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + ranges: [ + { + startId: "2024-02-01", + endId: undefined, + }, + ], + }); }); expect(result.current).toEqual(baseMetadata); @@ -286,12 +298,14 @@ describe("useOptimizedDayMetadata", () => { // Check if the metadata is updated when the range is complete act(() => { - activeDateRangesEmitter.emit("onSetActiveDateRanges", [ - { - startId: "2024-02-01", - endId: "2024-02-20", - }, - ]); + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + ranges: [ + { + startId: "2024-02-01", + endId: "2024-02-20", + }, + ], + }); }); expect(result.current).toEqual({ ...baseMetadata, @@ -331,12 +345,14 @@ describe("useOptimizedDayMetadata", () => { // Emit event act(() => { - activeDateRangesEmitter.emit("onSetActiveDateRanges", [ - { - startId: "2024-02-01", - endId: "2024-02-01", - }, - ]); + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + ranges: [ + { + startId: "2024-02-01", + endId: "2024-02-01", + }, + ], + }); }); expect(result.current).toEqual({ @@ -350,12 +366,14 @@ describe("useOptimizedDayMetadata", () => { // Emit another range act(() => { - activeDateRangesEmitter.emit("onSetActiveDateRanges", [ - { - startId: "2024-02-03", - endId: "2024-02-03", - }, - ]); + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + ranges: [ + { + startId: "2024-02-03", + endId: "2024-02-03", + }, + ], + }); }); // Back to the initial state @@ -363,3 +381,202 @@ describe("useOptimizedDayMetadata", () => { expect(result.all).toHaveLength(3); }); }); + +describe("useOptimizedDayMetadata with calendarInstanceId", () => { + const getBaseMetadata = (date: string): CalendarDayMetadata => ({ + date: fromDateId(date), + displayLabel: date.split("-")[2], + id: date, + isDifferentMonth: false, + isDisabled: false, + isEndOfMonth: false, + isEndOfRange: false, + isEndOfWeek: false, + isRangeValid: false, + isStartOfMonth: false, + isStartOfRange: false, + isStartOfWeek: false, + isToday: false, + isWeekend: false, + state: "idle", + }); + + it("uses the default instance ID when not provided", () => { + const baseMetadata = getBaseMetadata("2024-02-16"); + const { result } = renderHook(() => useOptimizedDayMetadata(baseMetadata)); + + act(() => { + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + ranges: [ + { + startId: "2024-02-16", + endId: "2024-02-16", + }, + ], + }); + }); + + expect(result.current).toEqual({ + ...baseMetadata, + state: "active", + isStartOfRange: true, + isEndOfRange: true, + isRangeValid: true, + }); + }); + + it("responds to events for the correct instance ID", () => { + const instanceId = "test-calendar-1"; + const baseMetadata = getBaseMetadata("2024-02-16"); + const { result } = renderHook(() => + useOptimizedDayMetadata(baseMetadata, instanceId) + ); + + act(() => { + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + instanceId, + ranges: [ + { + startId: "2024-02-16", + endId: "2024-02-16", + }, + ], + }); + }); + + expect(result.current).toEqual({ + ...baseMetadata, + state: "active", + isStartOfRange: true, + isEndOfRange: true, + isRangeValid: true, + }); + }); + + it("ignores events for different instance IDs", () => { + const instanceId = "test-calendar-2"; + const baseMetadata = getBaseMetadata("2024-02-16"); + const { result } = renderHook(() => + useOptimizedDayMetadata(baseMetadata, instanceId) + ); + + act(() => { + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + instanceId: "different-calendar", + ranges: [ + { + startId: "2024-02-16", + endId: "2024-02-16", + }, + ], + }); + }); + + // The metadata should not change + expect(result.current).toEqual(baseMetadata); + }); + + it("handles multiple instances correctly", () => { + const instanceId1 = "test-calendar-3"; + const instanceId2 = "test-calendar-4"; + + const baseMetadata1 = getBaseMetadata("2024-02-16"); + const baseMetadata2 = getBaseMetadata("2024-02-16"); + + const { result: result1 } = renderHook(() => + useOptimizedDayMetadata(baseMetadata1, instanceId1) + ); + const { result: result2 } = renderHook(() => + useOptimizedDayMetadata(baseMetadata2, instanceId2) + ); + + act(() => { + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + instanceId: instanceId1, + ranges: [ + { + startId: "2024-02-16", + endId: "2024-02-16", + }, + ], + }); + }); + + expect(result1.current).toEqual({ + ...baseMetadata1, + state: "active", + isStartOfRange: true, + isEndOfRange: true, + isRangeValid: true, + }); + expect(result2.current).toEqual(baseMetadata2); + + act(() => { + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + instanceId: instanceId2, + ranges: [ + { + startId: "2024-02-15", + endId: "2024-02-17", + }, + ], + }); + }); + + expect(result1.current).toEqual({ + ...baseMetadata1, + state: "active", + isStartOfRange: true, + isEndOfRange: true, + isRangeValid: true, + }); + expect(result2.current).toEqual({ + ...baseMetadata2, + state: "active", + isRangeValid: true, + }); + }); + + it("resets state for the correct instance when a new range is selected", () => { + const instanceId = "test-calendar-5"; + const baseMetadata = getBaseMetadata("2024-02-16"); + const { result } = renderHook(() => + useOptimizedDayMetadata(baseMetadata, instanceId) + ); + + act(() => { + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + instanceId, + ranges: [ + { + startId: "2024-02-16", + endId: "2024-02-16", + }, + ], + }); + }); + + expect(result.current).toEqual({ + ...baseMetadata, + state: "active", + isStartOfRange: true, + isEndOfRange: true, + isRangeValid: true, + }); + + act(() => { + activeDateRangesEmitter.emit("onSetActiveDateRanges", { + instanceId, + ranges: [ + { + startId: "2024-02-18", + endId: "2024-02-20", + }, + ], + }); + }); + + // Should reset to base state + expect(result.current).toEqual(baseMetadata); + }); +}); diff --git a/packages/flash-calendar/src/hooks/useOptimizedDayMetadata.ts b/packages/flash-calendar/src/hooks/useOptimizedDayMetadata.ts index f53f2a3..200e49a 100644 --- a/packages/flash-calendar/src/hooks/useOptimizedDayMetadata.ts +++ b/packages/flash-calendar/src/hooks/useOptimizedDayMetadata.ts @@ -7,6 +7,11 @@ import { type CalendarDayMetadata, } from "@/hooks/useCalendar"; +interface OnSetActiveDateRangesPayload { + instanceId?: string; + ranges: CalendarActiveDateRange[]; +} + /** * An event emitter for the active date ranges. This notifies the calendar items * when their state changes, allowing just the affected items to re-render. @@ -16,9 +21,14 @@ import { * for a reference implementation. */ export const activeDateRangesEmitter = mitt<{ - onSetActiveDateRanges: CalendarActiveDateRange[]; + onSetActiveDateRanges: OnSetActiveDateRangesPayload; }>(); +/** + * The default calendar instance ID. This is used when no instance ID is provided. + */ +const DEFAULT_CALENDAR_INSTANCE_ID = "flash-calendar-default-instance"; + /** * Returns an optimized metadata for a particular day. This hook listens to the * `activeDateRanges` emitter, enabling only the affected calendar items to @@ -28,22 +38,34 @@ export const activeDateRangesEmitter = mitt<{ * exported in case you need to build your own calendar. Check the source code * for a reference implementation. */ -export const useOptimizedDayMetadata = (baseMetadata: CalendarDayMetadata) => { +export const useOptimizedDayMetadata = ( + baseMetadata: CalendarDayMetadata, + calendarInstanceId?: string +) => { const [metadata, setMetadata] = useState(baseMetadata); + const safeCalendarInstanceId = + calendarInstanceId ?? DEFAULT_CALENDAR_INSTANCE_ID; + // Ensure the metadata is updated when the base changes. useEffect(() => { setMetadata(baseMetadata); }, [baseMetadata]); useEffect(() => { - const handler = (activeDateRanges: CalendarActiveDateRange[]) => { + const handler = (payload: OnSetActiveDateRangesPayload) => { + const { ranges, instanceId = DEFAULT_CALENDAR_INSTANCE_ID } = payload; + if (instanceId !== safeCalendarInstanceId) { + // This event is not for this instance, ignore it. + return; + } + // We're only interested in the active date ranges, no need to worry about // disabled states. These are already covered by the base metadata. const { isStartOfRange, isEndOfRange, isRangeValid, state } = getStateFields({ id: metadata.id, - calendarActiveDateRanges: activeDateRanges, + calendarActiveDateRanges: ranges, }); if (state === "active") { @@ -65,7 +87,7 @@ export const useOptimizedDayMetadata = (baseMetadata: CalendarDayMetadata) => { return () => { activeDateRangesEmitter.off("onSetActiveDateRanges", handler); }; - }, [baseMetadata, metadata]); + }, [baseMetadata, safeCalendarInstanceId, metadata]); return metadata; };