react-native-bigger-calendar 0.1.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.
- package/README.md +182 -0
- package/dist/index.d.mts +302 -0
- package/dist/index.d.ts +302 -0
- package/dist/index.js +930 -0
- package/dist/index.mjs +891 -0
- package/package.json +75 -0
- package/src/components/Calendar.tsx +147 -0
- package/src/components/DefaultEvent.tsx +57 -0
- package/src/components/MonthPager.tsx +165 -0
- package/src/components/MonthView.tsx +178 -0
- package/src/components/TimeGrid.tsx +825 -0
- package/src/index.tsx +30 -0
- package/src/theme.ts +94 -0
- package/src/types.ts +56 -0
- package/src/utils/dates.ts +20 -0
- package/src/utils/layout.ts +119 -0
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-native-bigger-calendar",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A generic, themeable month/week/day calendar for React Native with pinch-to-zoom, virtualized paging, and a render-prop event API.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"react-native",
|
|
7
|
+
"calendar",
|
|
8
|
+
"timetable",
|
|
9
|
+
"agenda",
|
|
10
|
+
"month-view",
|
|
11
|
+
"week-view",
|
|
12
|
+
"day-view",
|
|
13
|
+
"reanimated",
|
|
14
|
+
"expo"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/afonsojramos/react-native-bigger-calendar.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/afonsojramos/react-native-bigger-calendar/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/afonsojramos/react-native-bigger-calendar#readme",
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"source": "./src/index.tsx",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"module": "./dist/index.mjs",
|
|
29
|
+
"react-native": "./src/index.tsx",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"dist",
|
|
34
|
+
"README.md",
|
|
35
|
+
"!**/__tests__",
|
|
36
|
+
"!**/__mocks__"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsdown",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"test": "jest",
|
|
42
|
+
"clean": "rm -rf dist",
|
|
43
|
+
"prepublishOnly": "npm run build"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@legendapp/list": ">=3",
|
|
50
|
+
"date-fns": ">=3",
|
|
51
|
+
"react": ">=18",
|
|
52
|
+
"react-native": ">=0.74",
|
|
53
|
+
"react-native-gesture-handler": ">=2.16",
|
|
54
|
+
"react-native-reanimated": ">=4.0.0 <5",
|
|
55
|
+
"react-native-worklets": ">=0.5"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@babel/core": "^7.25.0",
|
|
59
|
+
"@babel/preset-env": "^7.25.0",
|
|
60
|
+
"@babel/preset-react": "^7.25.0",
|
|
61
|
+
"@babel/preset-typescript": "^7.25.0",
|
|
62
|
+
"@legendapp/list": "^3.0.6",
|
|
63
|
+
"@types/jest": "^29.5.0",
|
|
64
|
+
"@types/react": "^19.2.17",
|
|
65
|
+
"babel-jest": "^29.7.0",
|
|
66
|
+
"date-fns": "4.4.0",
|
|
67
|
+
"jest": "^29.7.0",
|
|
68
|
+
"react": "19.2.3",
|
|
69
|
+
"react-native": "0.85.3",
|
|
70
|
+
"react-native-gesture-handler": "~2.31.1",
|
|
71
|
+
"react-native-reanimated": "4.3.1",
|
|
72
|
+
"tsdown": "^0.22.3",
|
|
73
|
+
"typescript": "^5.5.0"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { useSharedValue } from 'react-native-reanimated';
|
|
3
|
+
import { CalendarThemeProvider, mergeTheme, type PartialCalendarTheme } from '../theme';
|
|
4
|
+
import type {
|
|
5
|
+
CalendarEvent,
|
|
6
|
+
CalendarMode,
|
|
7
|
+
EventKeyExtractor,
|
|
8
|
+
RenderEvent,
|
|
9
|
+
WeekStartsOn,
|
|
10
|
+
} from '../types';
|
|
11
|
+
import { DefaultEvent } from './DefaultEvent';
|
|
12
|
+
import { MonthPager } from './MonthPager';
|
|
13
|
+
import { DEFAULT_HOUR_HEIGHT, TimeGrid } from './TimeGrid';
|
|
14
|
+
|
|
15
|
+
export type CalendarProps<T> = {
|
|
16
|
+
events: CalendarEvent<T>[];
|
|
17
|
+
mode: CalendarMode;
|
|
18
|
+
date: Date;
|
|
19
|
+
onChangeDate: (date: Date) => void;
|
|
20
|
+
onPressEvent: (event: CalendarEvent<T>) => void;
|
|
21
|
+
/** Tap a day cell (month mode) — e.g. drill into the day view. */
|
|
22
|
+
onPressDay?: (date: Date) => void;
|
|
23
|
+
/** Tap the "+N more" overflow label in a month cell. */
|
|
24
|
+
onPressMore?: (events: CalendarEvent<T>[], date: Date) => void;
|
|
25
|
+
/** Tap empty space on the week/day grid; receives the date+time pressed. */
|
|
26
|
+
onPressCell?: (date: Date) => void;
|
|
27
|
+
/** Max events shown per month cell before they collapse into "+N more". */
|
|
28
|
+
maxVisibleEventCount?: number;
|
|
29
|
+
/** First day of the week. Sunday = 0 (default) … Saturday = 6. */
|
|
30
|
+
weekStartsOn?: WeekStartsOn;
|
|
31
|
+
/** Replace the built-in event box. Return a `flex: 1` element. */
|
|
32
|
+
renderEvent?: RenderEvent<T>;
|
|
33
|
+
/** Stable key per event. Defaults to start-time + index. */
|
|
34
|
+
keyExtractor?: EventKeyExtractor<T>;
|
|
35
|
+
/** Partial theme merged over the defaults. */
|
|
36
|
+
theme?: PartialCalendarTheme;
|
|
37
|
+
/** Externally-owned per-hour row height (week/day). Created internally if omitted. */
|
|
38
|
+
cellHeight?: ReturnType<typeof useSharedValue<number>>;
|
|
39
|
+
/** Initial per-hour row height in px (week/day). Default 64. */
|
|
40
|
+
hourHeight?: number;
|
|
41
|
+
minHourHeight?: number;
|
|
42
|
+
maxHourHeight?: number;
|
|
43
|
+
hourColumnWidth?: number;
|
|
44
|
+
/** First hour shown on the week/day grid (0–23). Default 0. */
|
|
45
|
+
minHour?: number;
|
|
46
|
+
/** Last hour shown on the week/day grid, exclusive (1–24). Default 24. */
|
|
47
|
+
maxHour?: number;
|
|
48
|
+
/** Show hour labels in 12-hour AM/PM form. Default false (24h). */
|
|
49
|
+
ampm?: boolean;
|
|
50
|
+
/** Initial vertical scroll, in minutes from midnight (week/day). */
|
|
51
|
+
scrollOffsetMinutes?: number;
|
|
52
|
+
/** Show the current-time line on the week/day grid. Default true. */
|
|
53
|
+
showNowIndicator?: boolean;
|
|
54
|
+
/** BCP-47 locale for weekday labels. Defaults to the device locale. */
|
|
55
|
+
locale?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Allow a fling to carry across several pages before snapping. Default false:
|
|
58
|
+
* one day/week/month per swipe.
|
|
59
|
+
*/
|
|
60
|
+
freeSwipe?: boolean;
|
|
61
|
+
/** Custom header above the week/day grid. Receives the visible days. */
|
|
62
|
+
renderTimeGridHeader?: (days: Date[]) => React.ReactNode;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Derive a key purely from event data so identity is stable across reorders and
|
|
66
|
+
// list mutations. Supply your own `keyExtractor` returning a real id when events
|
|
67
|
+
// can share an identical start/end/title.
|
|
68
|
+
const defaultKeyExtractor: EventKeyExtractor<unknown> = (event) =>
|
|
69
|
+
`${event.start.toISOString()}|${event.end.toISOString()}|${event.title ?? ''}`;
|
|
70
|
+
|
|
71
|
+
export function Calendar<T>({
|
|
72
|
+
events,
|
|
73
|
+
mode,
|
|
74
|
+
date,
|
|
75
|
+
onChangeDate,
|
|
76
|
+
onPressEvent,
|
|
77
|
+
onPressDay,
|
|
78
|
+
onPressMore,
|
|
79
|
+
onPressCell,
|
|
80
|
+
maxVisibleEventCount = 2,
|
|
81
|
+
weekStartsOn = 0,
|
|
82
|
+
renderEvent = DefaultEvent,
|
|
83
|
+
keyExtractor = defaultKeyExtractor as EventKeyExtractor<T>,
|
|
84
|
+
theme,
|
|
85
|
+
cellHeight: cellHeightProp,
|
|
86
|
+
hourHeight = DEFAULT_HOUR_HEIGHT,
|
|
87
|
+
minHourHeight,
|
|
88
|
+
maxHourHeight,
|
|
89
|
+
hourColumnWidth,
|
|
90
|
+
minHour,
|
|
91
|
+
maxHour,
|
|
92
|
+
ampm,
|
|
93
|
+
scrollOffsetMinutes,
|
|
94
|
+
showNowIndicator,
|
|
95
|
+
locale,
|
|
96
|
+
freeSwipe,
|
|
97
|
+
renderTimeGridHeader,
|
|
98
|
+
}: CalendarProps<T>) {
|
|
99
|
+
const mergedTheme = useMemo(() => mergeTheme(theme), [theme]);
|
|
100
|
+
const internalCellHeight = useSharedValue(hourHeight);
|
|
101
|
+
const cellHeight = cellHeightProp ?? internalCellHeight;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<CalendarThemeProvider value={mergedTheme}>
|
|
105
|
+
{mode === 'month' ? (
|
|
106
|
+
<MonthPager
|
|
107
|
+
date={date}
|
|
108
|
+
events={events}
|
|
109
|
+
maxVisibleEventCount={maxVisibleEventCount}
|
|
110
|
+
weekStartsOn={weekStartsOn}
|
|
111
|
+
renderEvent={renderEvent}
|
|
112
|
+
keyExtractor={keyExtractor}
|
|
113
|
+
onPressDay={onPressDay}
|
|
114
|
+
onPressEvent={onPressEvent}
|
|
115
|
+
onPressMore={onPressMore}
|
|
116
|
+
onChangeDate={onChangeDate}
|
|
117
|
+
freeSwipe={freeSwipe}
|
|
118
|
+
/>
|
|
119
|
+
) : (
|
|
120
|
+
<TimeGrid
|
|
121
|
+
mode={mode}
|
|
122
|
+
date={date}
|
|
123
|
+
events={events}
|
|
124
|
+
cellHeight={cellHeight}
|
|
125
|
+
hourHeight={hourHeight}
|
|
126
|
+
weekStartsOn={weekStartsOn}
|
|
127
|
+
renderEvent={renderEvent}
|
|
128
|
+
keyExtractor={keyExtractor}
|
|
129
|
+
scrollOffsetMinutes={scrollOffsetMinutes}
|
|
130
|
+
hourColumnWidth={hourColumnWidth}
|
|
131
|
+
minHour={minHour}
|
|
132
|
+
maxHour={maxHour}
|
|
133
|
+
ampm={ampm}
|
|
134
|
+
minHourHeight={minHourHeight}
|
|
135
|
+
maxHourHeight={maxHourHeight}
|
|
136
|
+
showNowIndicator={showNowIndicator}
|
|
137
|
+
locale={locale}
|
|
138
|
+
freeSwipe={freeSwipe}
|
|
139
|
+
onPressEvent={onPressEvent}
|
|
140
|
+
onPressCell={onPressCell}
|
|
141
|
+
onChangeDate={onChangeDate}
|
|
142
|
+
renderHeader={renderTimeGridHeader}
|
|
143
|
+
/>
|
|
144
|
+
)}
|
|
145
|
+
</CalendarThemeProvider>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { format } from 'date-fns';
|
|
2
|
+
import { StyleSheet, Text, TouchableOpacity } from 'react-native';
|
|
3
|
+
import { useCalendarTheme } from '../theme';
|
|
4
|
+
import type { RenderEventArgs } from '../types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The built-in event renderer: a filled, rounded box showing the event title
|
|
8
|
+
* and (on the day/week grid) its time range. Pass your own `renderEvent` to
|
|
9
|
+
* `<Calendar>` to replace it entirely.
|
|
10
|
+
*/
|
|
11
|
+
export function DefaultEvent<T>({ event, mode, onPress }: RenderEventArgs<T>) {
|
|
12
|
+
const theme = useCalendarTheme();
|
|
13
|
+
const showTime = mode !== 'month';
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<TouchableOpacity
|
|
17
|
+
style={[styles.box, { backgroundColor: theme.colors.eventBackground }]}
|
|
18
|
+
onPress={onPress}
|
|
19
|
+
activeOpacity={0.7}
|
|
20
|
+
accessibilityRole="button"
|
|
21
|
+
accessibilityLabel={event.title}
|
|
22
|
+
>
|
|
23
|
+
{event.title ? (
|
|
24
|
+
<Text
|
|
25
|
+
style={[theme.text.eventTitle, { color: theme.colors.eventText }]}
|
|
26
|
+
numberOfLines={mode === 'day' ? undefined : 1}
|
|
27
|
+
ellipsizeMode="tail"
|
|
28
|
+
allowFontScaling={false}
|
|
29
|
+
>
|
|
30
|
+
{event.title}
|
|
31
|
+
</Text>
|
|
32
|
+
) : null}
|
|
33
|
+
{showTime ? (
|
|
34
|
+
<Text
|
|
35
|
+
style={[styles.time, { color: theme.colors.eventText }]}
|
|
36
|
+
numberOfLines={1}
|
|
37
|
+
allowFontScaling={false}
|
|
38
|
+
>
|
|
39
|
+
{`${format(event.start, 'HH:mm')} - ${format(event.end, 'HH:mm')}`}
|
|
40
|
+
</Text>
|
|
41
|
+
) : null}
|
|
42
|
+
</TouchableOpacity>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const styles = StyleSheet.create({
|
|
47
|
+
box: {
|
|
48
|
+
flex: 1,
|
|
49
|
+
borderRadius: 6,
|
|
50
|
+
paddingVertical: 2,
|
|
51
|
+
paddingHorizontal: 4,
|
|
52
|
+
overflow: 'hidden',
|
|
53
|
+
},
|
|
54
|
+
time: {
|
|
55
|
+
fontSize: 11,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LegendList,
|
|
3
|
+
type LegendListRef,
|
|
4
|
+
type LegendListRenderItemProps,
|
|
5
|
+
type OnViewableItemsChangedInfo,
|
|
6
|
+
} from '@legendapp/list/react-native';
|
|
7
|
+
import { addMonths, differenceInCalendarMonths, startOfMonth } from 'date-fns';
|
|
8
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
9
|
+
import { StyleSheet, useWindowDimensions, View } from 'react-native';
|
|
10
|
+
import type { CalendarEvent, EventKeyExtractor, RenderEvent, WeekStartsOn } from '../types';
|
|
11
|
+
import { MonthView } from './MonthView';
|
|
12
|
+
|
|
13
|
+
// Months rendered either side of the current page. LegendList virtualises, so
|
|
14
|
+
// only a few mount at once; a wide window (5 years each way) means the user
|
|
15
|
+
// effectively never runs out of months to swipe. Items are keyed by month and
|
|
16
|
+
// never recycled.
|
|
17
|
+
const PAGE_WINDOW = 60;
|
|
18
|
+
// A page must be ~fully on screen before it becomes the committed month, so
|
|
19
|
+
// paging commits once per settle rather than mid-swipe.
|
|
20
|
+
const PAGE_VIEWABILITY = { itemVisiblePercentThreshold: 90 };
|
|
21
|
+
|
|
22
|
+
export type MonthPagerProps<T> = {
|
|
23
|
+
date: Date;
|
|
24
|
+
events: CalendarEvent<T>[];
|
|
25
|
+
maxVisibleEventCount: number;
|
|
26
|
+
weekStartsOn: WeekStartsOn;
|
|
27
|
+
renderEvent: RenderEvent<T>;
|
|
28
|
+
keyExtractor: EventKeyExtractor<T>;
|
|
29
|
+
onPressDay?: (date: Date) => void;
|
|
30
|
+
onPressEvent: (event: CalendarEvent<T>) => void;
|
|
31
|
+
onPressMore?: (events: CalendarEvent<T>[], date: Date) => void;
|
|
32
|
+
onChangeDate: (date: Date) => void;
|
|
33
|
+
freeSwipe?: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function MonthPagerInner<T>({
|
|
37
|
+
date,
|
|
38
|
+
events,
|
|
39
|
+
maxVisibleEventCount,
|
|
40
|
+
weekStartsOn,
|
|
41
|
+
renderEvent,
|
|
42
|
+
keyExtractor,
|
|
43
|
+
onPressDay,
|
|
44
|
+
onPressEvent,
|
|
45
|
+
onPressMore,
|
|
46
|
+
onChangeDate,
|
|
47
|
+
freeSwipe = false,
|
|
48
|
+
}: MonthPagerProps<T>) {
|
|
49
|
+
const { width, height } = useWindowDimensions();
|
|
50
|
+
const listRef = useRef<LegendListRef>(null);
|
|
51
|
+
// Horizontal list items need an explicit cross-axis height; seed it with the
|
|
52
|
+
// window height (so it renders immediately and in tests) and refine to the
|
|
53
|
+
// exact area on layout. Without this the grid collapses to 0px.
|
|
54
|
+
const [pageHeight, setPageHeight] = useState(height);
|
|
55
|
+
|
|
56
|
+
// A fixed window of months, anchored once and aligned to the month start. The
|
|
57
|
+
// array never shifts as the date changes, so paging never re-renders a page's
|
|
58
|
+
// content — LegendList virtualises and keys by month.
|
|
59
|
+
const [anchorDate] = useState(date);
|
|
60
|
+
const anchor = useMemo(() => startOfMonth(anchorDate), [anchorDate]);
|
|
61
|
+
const monthDates = useMemo(
|
|
62
|
+
() => Array.from({ length: PAGE_WINDOW * 2 + 1 }, (_, i) => addMonths(anchor, i - PAGE_WINDOW)),
|
|
63
|
+
[anchor],
|
|
64
|
+
);
|
|
65
|
+
const indexOfMonth = useCallback(
|
|
66
|
+
(target: Date) => differenceInCalendarMonths(startOfMonth(target), anchor) + PAGE_WINDOW,
|
|
67
|
+
[anchor],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// The committed month's page is the centred/active one. Derived (not stored)
|
|
71
|
+
// so it always reflects the date. `viewedIndexRef` tracks where the list
|
|
72
|
+
// actually sits, letting us tell swipe-driven month changes from external ones.
|
|
73
|
+
const activeIndex = indexOfMonth(date);
|
|
74
|
+
const viewedIndexRef = useRef(activeIndex);
|
|
75
|
+
|
|
76
|
+
const handleViewableItemsChanged = useCallback(
|
|
77
|
+
(info: OnViewableItemsChangedInfo<Date>) => {
|
|
78
|
+
const settled = info.viewableItems.find((token) => token.isViewable);
|
|
79
|
+
if (settled?.index == null || settled.index === viewedIndexRef.current) return;
|
|
80
|
+
viewedIndexRef.current = settled.index;
|
|
81
|
+
if (settled.item) onChangeDate(settled.item);
|
|
82
|
+
},
|
|
83
|
+
[onChangeDate],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Realign the list when the month changes from outside a swipe (e.g. a "today"
|
|
87
|
+
// button). Swipe-driven changes already match.
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (activeIndex === viewedIndexRef.current) return;
|
|
90
|
+
viewedIndexRef.current = activeIndex;
|
|
91
|
+
listRef.current?.scrollToIndex({ index: activeIndex, animated: false });
|
|
92
|
+
}, [activeIndex]);
|
|
93
|
+
|
|
94
|
+
const snapToIndices = useMemo(() => monthDates.map((_, index) => index), [monthDates]);
|
|
95
|
+
const keyExtractorList = useCallback((item: Date) => item.toISOString(), []);
|
|
96
|
+
const getFixedItemSize = useCallback(() => width, [width]);
|
|
97
|
+
const renderItem = useCallback(
|
|
98
|
+
({ item }: LegendListRenderItemProps<Date>) => (
|
|
99
|
+
<View style={{ width, height: pageHeight }}>
|
|
100
|
+
<MonthView
|
|
101
|
+
date={item}
|
|
102
|
+
events={events}
|
|
103
|
+
maxVisibleEventCount={maxVisibleEventCount}
|
|
104
|
+
weekStartsOn={weekStartsOn}
|
|
105
|
+
renderEvent={renderEvent}
|
|
106
|
+
keyExtractor={keyExtractor}
|
|
107
|
+
onPressDay={onPressDay}
|
|
108
|
+
onPressEvent={onPressEvent}
|
|
109
|
+
onPressMore={onPressMore}
|
|
110
|
+
/>
|
|
111
|
+
</View>
|
|
112
|
+
),
|
|
113
|
+
[
|
|
114
|
+
width,
|
|
115
|
+
pageHeight,
|
|
116
|
+
events,
|
|
117
|
+
maxVisibleEventCount,
|
|
118
|
+
weekStartsOn,
|
|
119
|
+
renderEvent,
|
|
120
|
+
keyExtractor,
|
|
121
|
+
onPressDay,
|
|
122
|
+
onPressEvent,
|
|
123
|
+
onPressMore,
|
|
124
|
+
],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<View style={styles.pager} onLayout={(event) => setPageHeight(event.nativeEvent.layout.height)}>
|
|
129
|
+
<LegendList
|
|
130
|
+
// Remount when the measured page height changes so the list adopts the
|
|
131
|
+
// corrected item height. Without this the list can keep the oversized
|
|
132
|
+
// initial (window-height) seed and clip the last week row.
|
|
133
|
+
key={pageHeight}
|
|
134
|
+
ref={listRef}
|
|
135
|
+
style={styles.pagerList}
|
|
136
|
+
data={monthDates}
|
|
137
|
+
horizontal
|
|
138
|
+
recycleItems={false}
|
|
139
|
+
keyExtractor={keyExtractorList}
|
|
140
|
+
getFixedItemSize={getFixedItemSize}
|
|
141
|
+
// Default: native paging — each page is the viewport width, so a swipe
|
|
142
|
+
// hard-stops at the adjacent month and can't fling past it. With
|
|
143
|
+
// `freeSwipe`, momentum carries across months and snaps to a boundary.
|
|
144
|
+
pagingEnabled={!freeSwipe}
|
|
145
|
+
snapToIndices={freeSwipe ? snapToIndices : undefined}
|
|
146
|
+
initialScrollIndex={activeIndex}
|
|
147
|
+
showsHorizontalScrollIndicator={false}
|
|
148
|
+
viewabilityConfig={PAGE_VIEWABILITY}
|
|
149
|
+
onViewableItemsChanged={handleViewableItemsChanged}
|
|
150
|
+
renderItem={renderItem}
|
|
151
|
+
/>
|
|
152
|
+
</View>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const MonthPager = memo(MonthPagerInner) as typeof MonthPagerInner;
|
|
157
|
+
|
|
158
|
+
const styles = StyleSheet.create({
|
|
159
|
+
pager: {
|
|
160
|
+
flex: 1,
|
|
161
|
+
},
|
|
162
|
+
pagerList: {
|
|
163
|
+
flex: 1,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
eachDayOfInterval,
|
|
3
|
+
endOfMonth,
|
|
4
|
+
endOfWeek,
|
|
5
|
+
format,
|
|
6
|
+
isSameMonth,
|
|
7
|
+
startOfDay,
|
|
8
|
+
startOfMonth,
|
|
9
|
+
startOfWeek,
|
|
10
|
+
} from 'date-fns';
|
|
11
|
+
import { memo, useMemo } from 'react';
|
|
12
|
+
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
13
|
+
import { useCalendarTheme } from '../theme';
|
|
14
|
+
import type { CalendarEvent, EventKeyExtractor, RenderEvent, WeekStartsOn } from '../types';
|
|
15
|
+
import { getIsToday, isWeekend } from '../utils/dates';
|
|
16
|
+
import { eventDayKeys } from '../utils/layout';
|
|
17
|
+
|
|
18
|
+
const chunkIntoWeeks = (days: Date[]): Date[][] => {
|
|
19
|
+
const weeks: Date[][] = [];
|
|
20
|
+
for (let index = 0; index < days.length; index += 7) {
|
|
21
|
+
weeks.push(days.slice(index, index + 7));
|
|
22
|
+
}
|
|
23
|
+
return weeks;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type MonthViewProps<T> = {
|
|
27
|
+
date: Date;
|
|
28
|
+
events: CalendarEvent<T>[];
|
|
29
|
+
maxVisibleEventCount: number;
|
|
30
|
+
weekStartsOn: WeekStartsOn;
|
|
31
|
+
renderEvent: RenderEvent<T>;
|
|
32
|
+
keyExtractor: EventKeyExtractor<T>;
|
|
33
|
+
onPressDay?: (date: Date) => void;
|
|
34
|
+
onPressEvent: (event: CalendarEvent<T>) => void;
|
|
35
|
+
onPressMore?: (events: CalendarEvent<T>[], date: Date) => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function MonthViewInner<T>({
|
|
39
|
+
date,
|
|
40
|
+
events,
|
|
41
|
+
maxVisibleEventCount,
|
|
42
|
+
weekStartsOn,
|
|
43
|
+
renderEvent,
|
|
44
|
+
keyExtractor,
|
|
45
|
+
onPressDay,
|
|
46
|
+
onPressEvent,
|
|
47
|
+
onPressMore,
|
|
48
|
+
}: MonthViewProps<T>) {
|
|
49
|
+
const theme = useCalendarTheme();
|
|
50
|
+
const RenderEventComponent = renderEvent;
|
|
51
|
+
|
|
52
|
+
const weeks = useMemo(() => {
|
|
53
|
+
const start = startOfWeek(startOfMonth(date), { weekStartsOn });
|
|
54
|
+
const end = endOfWeek(endOfMonth(date), { weekStartsOn });
|
|
55
|
+
return chunkIntoWeeks(eachDayOfInterval({ start, end }));
|
|
56
|
+
}, [date, weekStartsOn]);
|
|
57
|
+
|
|
58
|
+
// Group events by calendar day once per `events` change, rather than scanning
|
|
59
|
+
// the whole list inside every one of the (up to) 42 day cells on each render.
|
|
60
|
+
// Multi-day events are indexed under every day they span.
|
|
61
|
+
const eventsByDay = useMemo(() => {
|
|
62
|
+
const map = new Map<string, CalendarEvent<T>[]>();
|
|
63
|
+
for (const event of events) {
|
|
64
|
+
for (const key of eventDayKeys(event)) {
|
|
65
|
+
const existing = map.get(key);
|
|
66
|
+
if (existing) existing.push(event);
|
|
67
|
+
else map.set(key, [event]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return map;
|
|
71
|
+
}, [events]);
|
|
72
|
+
|
|
73
|
+
const renderDay = (day: Date) => {
|
|
74
|
+
const dayEvents = eventsByDay.get(startOfDay(day).toISOString()) ?? [];
|
|
75
|
+
const isCurrentMonth = isSameMonth(day, date);
|
|
76
|
+
const isToday = getIsToday(day);
|
|
77
|
+
const hiddenCount = dayEvents.length - maxVisibleEventCount;
|
|
78
|
+
|
|
79
|
+
const dateColor = isToday
|
|
80
|
+
? theme.colors.todayText
|
|
81
|
+
: isCurrentMonth
|
|
82
|
+
? theme.colors.text
|
|
83
|
+
: theme.colors.textDisabled;
|
|
84
|
+
|
|
85
|
+
// Summarise the cell for screen readers: full date, today marker, and how
|
|
86
|
+
// many events it holds (the chips inside are grouped under this cell).
|
|
87
|
+
const eventCount = dayEvents.length;
|
|
88
|
+
const accessibilityLabel = `${format(day, 'EEEE, d LLLL yyyy')}${isToday ? ', today' : ''}, ${eventCount} ${eventCount === 1 ? 'event' : 'events'}`;
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<TouchableOpacity
|
|
92
|
+
key={day.toISOString()}
|
|
93
|
+
style={[
|
|
94
|
+
styles.dayCell,
|
|
95
|
+
{ borderColor: theme.colors.gridLine },
|
|
96
|
+
isWeekend(day) && { backgroundColor: theme.colors.weekendBackground },
|
|
97
|
+
]}
|
|
98
|
+
onPress={onPressDay ? () => onPressDay(day) : undefined}
|
|
99
|
+
disabled={!onPressDay}
|
|
100
|
+
accessibilityRole={onPressDay ? 'button' : undefined}
|
|
101
|
+
accessibilityLabel={accessibilityLabel}
|
|
102
|
+
>
|
|
103
|
+
<View
|
|
104
|
+
style={[
|
|
105
|
+
styles.dateBadge,
|
|
106
|
+
isToday && {
|
|
107
|
+
backgroundColor: theme.colors.todayBackground,
|
|
108
|
+
borderRadius: theme.todayBadgeRadius,
|
|
109
|
+
},
|
|
110
|
+
]}
|
|
111
|
+
>
|
|
112
|
+
<Text style={[theme.text.dateCell, { color: dateColor }]} allowFontScaling={false}>
|
|
113
|
+
{format(day, 'd')}
|
|
114
|
+
</Text>
|
|
115
|
+
</View>
|
|
116
|
+
{dayEvents.slice(0, maxVisibleEventCount).map((event, index) => (
|
|
117
|
+
<View key={keyExtractor(event, index)} style={styles.monthEvent}>
|
|
118
|
+
<RenderEventComponent event={event} mode="month" onPress={() => onPressEvent(event)} />
|
|
119
|
+
</View>
|
|
120
|
+
))}
|
|
121
|
+
{hiddenCount > 0 ? (
|
|
122
|
+
<Text
|
|
123
|
+
style={[theme.text.more, styles.moreLabel, { color: theme.colors.textMuted }]}
|
|
124
|
+
onPress={onPressMore ? () => onPressMore(dayEvents, day) : undefined}
|
|
125
|
+
accessibilityRole="button"
|
|
126
|
+
accessibilityLabel={`Show ${hiddenCount} more events`}
|
|
127
|
+
allowFontScaling={false}
|
|
128
|
+
>
|
|
129
|
+
{`${hiddenCount} More`}
|
|
130
|
+
</Text>
|
|
131
|
+
) : null}
|
|
132
|
+
</TouchableOpacity>
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<View style={styles.container}>
|
|
138
|
+
{weeks.map((week) => (
|
|
139
|
+
<View style={styles.weekRow} key={week[0].toISOString()}>
|
|
140
|
+
{week.map((day) => renderDay(day))}
|
|
141
|
+
</View>
|
|
142
|
+
))}
|
|
143
|
+
</View>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const MonthView = memo(MonthViewInner) as typeof MonthViewInner;
|
|
148
|
+
|
|
149
|
+
const styles = StyleSheet.create({
|
|
150
|
+
container: {
|
|
151
|
+
flex: 1,
|
|
152
|
+
},
|
|
153
|
+
weekRow: {
|
|
154
|
+
flex: 1,
|
|
155
|
+
flexDirection: 'row',
|
|
156
|
+
},
|
|
157
|
+
dayCell: {
|
|
158
|
+
flex: 1,
|
|
159
|
+
alignItems: 'center',
|
|
160
|
+
paddingTop: 4,
|
|
161
|
+
gap: 2,
|
|
162
|
+
overflow: 'hidden',
|
|
163
|
+
borderTopWidth: StyleSheet.hairlineWidth,
|
|
164
|
+
borderRightWidth: StyleSheet.hairlineWidth,
|
|
165
|
+
},
|
|
166
|
+
dateBadge: {
|
|
167
|
+
justifyContent: 'center',
|
|
168
|
+
alignItems: 'center',
|
|
169
|
+
height: 24,
|
|
170
|
+
width: 24,
|
|
171
|
+
},
|
|
172
|
+
monthEvent: {
|
|
173
|
+
width: '92%',
|
|
174
|
+
},
|
|
175
|
+
moreLabel: {
|
|
176
|
+
marginTop: 2,
|
|
177
|
+
},
|
|
178
|
+
});
|