react-native-bigger-calendar 0.1.0 → 0.2.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.
@@ -4,10 +4,19 @@ import {
4
4
  type LegendListRenderItemProps,
5
5
  type OnViewableItemsChangedInfo,
6
6
  } from '@legendapp/list/react-native';
7
- import { addMonths, differenceInCalendarMonths, startOfMonth } from 'date-fns';
7
+ import { addMonths, differenceInCalendarMonths, format, type Locale, startOfMonth } from 'date-fns';
8
8
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
- import { StyleSheet, useWindowDimensions, View } from 'react-native';
9
+ import {
10
+ StyleSheet,
11
+ type StyleProp,
12
+ Text,
13
+ useWindowDimensions,
14
+ View,
15
+ type ViewStyle,
16
+ } from 'react-native';
17
+ import { useCalendarTheme } from '../theme';
10
18
  import type { CalendarEvent, EventKeyExtractor, RenderEvent, WeekStartsOn } from '../types';
19
+ import { getWeekDays } from '../utils/dates';
11
20
  import { MonthView } from './MonthView';
12
21
 
13
22
  // Months rendered either side of the current page. LegendList virtualises, so
@@ -24,13 +33,27 @@ export type MonthPagerProps<T> = {
24
33
  events: CalendarEvent<T>[];
25
34
  maxVisibleEventCount: number;
26
35
  weekStartsOn: WeekStartsOn;
36
+ locale?: Locale;
37
+ sortedMonthView?: boolean;
38
+ moreLabel?: string;
39
+ showAdjacentMonths?: boolean;
40
+ disableMonthEventCellPress?: boolean;
41
+ isRTL?: boolean;
42
+ calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
27
43
  renderEvent: RenderEvent<T>;
28
44
  keyExtractor: EventKeyExtractor<T>;
29
45
  onPressDay?: (date: Date) => void;
46
+ onLongPressDay?: (date: Date) => void;
30
47
  onPressEvent: (event: CalendarEvent<T>) => void;
48
+ onLongPressEvent?: (event: CalendarEvent<T>) => void;
31
49
  onPressMore?: (events: CalendarEvent<T>[], date: Date) => void;
32
50
  onChangeDate: (date: Date) => void;
33
51
  freeSwipe?: boolean;
52
+ swipeEnabled?: boolean;
53
+ showSixWeeks?: boolean;
54
+ activeDate?: Date;
55
+ /** Replace the weekday-label header above the month grid. Receives the week's days. */
56
+ renderHeaderForMonthView?: (weekDays: Date[]) => React.ReactNode;
34
57
  };
35
58
 
36
59
  function MonthPagerInner<T>({
@@ -38,13 +61,26 @@ function MonthPagerInner<T>({
38
61
  events,
39
62
  maxVisibleEventCount,
40
63
  weekStartsOn,
64
+ locale,
65
+ sortedMonthView,
66
+ moreLabel,
67
+ showAdjacentMonths,
68
+ disableMonthEventCellPress,
69
+ isRTL,
70
+ calendarCellStyle,
41
71
  renderEvent,
42
72
  keyExtractor,
43
73
  onPressDay,
74
+ onLongPressDay,
44
75
  onPressEvent,
76
+ onLongPressEvent,
45
77
  onPressMore,
46
78
  onChangeDate,
47
79
  freeSwipe = false,
80
+ swipeEnabled = true,
81
+ showSixWeeks = false,
82
+ activeDate,
83
+ renderHeaderForMonthView,
48
84
  }: MonthPagerProps<T>) {
49
85
  const { width, height } = useWindowDimensions();
50
86
  const listRef = useRef<LegendListRef>(null);
@@ -91,6 +127,14 @@ function MonthPagerInner<T>({
91
127
  listRef.current?.scrollToIndex({ index: activeIndex, animated: false });
92
128
  }, [activeIndex]);
93
129
 
130
+ // The seven weekday labels for the header above the grid. Weekday names depend
131
+ // only on `weekStartsOn`, so any week works; reuse the anchor. Reversed in RTL
132
+ // to line up with the mirrored day cells.
133
+ const weekDays = useMemo(() => {
134
+ const days = getWeekDays(anchor, weekStartsOn);
135
+ return isRTL ? days.reverse() : days;
136
+ }, [anchor, weekStartsOn, isRTL]);
137
+
94
138
  const snapToIndices = useMemo(() => monthDates.map((_, index) => index), [monthDates]);
95
139
  const keyExtractorList = useCallback((item: Date) => item.toISOString(), []);
96
140
  const getFixedItemSize = useCallback(() => width, [width]);
@@ -102,10 +146,21 @@ function MonthPagerInner<T>({
102
146
  events={events}
103
147
  maxVisibleEventCount={maxVisibleEventCount}
104
148
  weekStartsOn={weekStartsOn}
149
+ locale={locale}
150
+ sortedMonthView={sortedMonthView}
151
+ moreLabel={moreLabel}
152
+ showAdjacentMonths={showAdjacentMonths}
153
+ disableMonthEventCellPress={disableMonthEventCellPress}
154
+ isRTL={isRTL}
155
+ showSixWeeks={showSixWeeks}
156
+ activeDate={activeDate}
157
+ calendarCellStyle={calendarCellStyle}
105
158
  renderEvent={renderEvent}
106
159
  keyExtractor={keyExtractor}
107
160
  onPressDay={onPressDay}
161
+ onLongPressDay={onLongPressDay}
108
162
  onPressEvent={onPressEvent}
163
+ onLongPressEvent={onLongPressEvent}
109
164
  onPressMore={onPressMore}
110
165
  />
111
166
  </View>
@@ -116,50 +171,107 @@ function MonthPagerInner<T>({
116
171
  events,
117
172
  maxVisibleEventCount,
118
173
  weekStartsOn,
174
+ locale,
175
+ sortedMonthView,
176
+ moreLabel,
177
+ showAdjacentMonths,
178
+ disableMonthEventCellPress,
179
+ isRTL,
180
+ showSixWeeks,
181
+ activeDate,
182
+ calendarCellStyle,
119
183
  renderEvent,
120
184
  keyExtractor,
121
185
  onPressDay,
186
+ onLongPressDay,
122
187
  onPressEvent,
188
+ onLongPressEvent,
123
189
  onPressMore,
124
190
  ],
125
191
  );
126
192
 
127
193
  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
- />
194
+ <View style={styles.container}>
195
+ {renderHeaderForMonthView ? (
196
+ renderHeaderForMonthView(weekDays)
197
+ ) : (
198
+ <MonthWeekdayHeader weekDays={weekDays} locale={locale} />
199
+ )}
200
+ <View
201
+ style={styles.pager}
202
+ onLayout={(event) => setPageHeight(event.nativeEvent.layout.height)}
203
+ >
204
+ <LegendList
205
+ // Remount when the measured page height changes so the list adopts the
206
+ // corrected item height. Without this the list can keep the oversized
207
+ // initial (window-height) seed and clip the last week row.
208
+ key={pageHeight}
209
+ ref={listRef}
210
+ style={styles.pagerList}
211
+ data={monthDates}
212
+ horizontal
213
+ recycleItems={false}
214
+ keyExtractor={keyExtractorList}
215
+ getFixedItemSize={getFixedItemSize}
216
+ scrollEnabled={swipeEnabled}
217
+ // Default: native paging — each page is the viewport width, so a swipe
218
+ // hard-stops at the adjacent month and can't fling past it. With
219
+ // `freeSwipe`, momentum carries across months and snaps to a boundary.
220
+ pagingEnabled={!freeSwipe}
221
+ snapToIndices={freeSwipe ? snapToIndices : undefined}
222
+ initialScrollIndex={activeIndex}
223
+ showsHorizontalScrollIndicator={false}
224
+ viewabilityConfig={PAGE_VIEWABILITY}
225
+ onViewableItemsChanged={handleViewableItemsChanged}
226
+ renderItem={renderItem}
227
+ />
228
+ </View>
152
229
  </View>
153
230
  );
154
231
  }
155
232
 
156
233
  export const MonthPager = memo(MonthPagerInner) as typeof MonthPagerInner;
157
234
 
235
+ type MonthWeekdayHeaderProps = {
236
+ weekDays: Date[];
237
+ locale?: Locale;
238
+ };
239
+
240
+ // The default weekday-label row above the month grid (e.g. "Mon Tue Wed…"),
241
+ // one flex column per day to line up with the grid cells below.
242
+ const MonthWeekdayHeader = ({ weekDays, locale }: MonthWeekdayHeaderProps) => {
243
+ const theme = useCalendarTheme();
244
+ return (
245
+ <View style={styles.weekdayHeader}>
246
+ {weekDays.map((day) => (
247
+ <Text
248
+ key={day.toISOString()}
249
+ style={[theme.text.weekday, styles.weekdayLabel, { color: theme.colors.textMuted }]}
250
+ allowFontScaling={false}
251
+ >
252
+ {format(day, 'EEE', { locale })}
253
+ </Text>
254
+ ))}
255
+ </View>
256
+ );
257
+ };
258
+
158
259
  const styles = StyleSheet.create({
260
+ container: {
261
+ flex: 1,
262
+ },
159
263
  pager: {
160
264
  flex: 1,
161
265
  },
162
266
  pagerList: {
163
267
  flex: 1,
164
268
  },
269
+ weekdayHeader: {
270
+ flexDirection: 'row',
271
+ paddingBottom: 4,
272
+ },
273
+ weekdayLabel: {
274
+ flex: 1,
275
+ textAlign: 'center',
276
+ },
165
277
  });
@@ -1,19 +1,21 @@
1
1
  import {
2
+ addDays,
2
3
  eachDayOfInterval,
3
4
  endOfMonth,
4
5
  endOfWeek,
5
6
  format,
7
+ type Locale,
6
8
  isSameMonth,
7
9
  startOfDay,
8
10
  startOfMonth,
9
11
  startOfWeek,
10
12
  } from 'date-fns';
11
13
  import { memo, useMemo } from 'react';
12
- import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
14
+ import { StyleSheet, type StyleProp, Text, TouchableOpacity, View, type ViewStyle } from 'react-native';
13
15
  import { useCalendarTheme } from '../theme';
14
16
  import type { CalendarEvent, EventKeyExtractor, RenderEvent, WeekStartsOn } from '../types';
15
- import { getIsToday, isWeekend } from '../utils/dates';
16
- import { eventDayKeys } from '../utils/layout';
17
+ import { getIsToday, isSameCalendarDay, isWeekend } from '../utils/dates';
18
+ import { eventDayKeys, isAllDayEvent } from '../utils/layout';
17
19
 
18
20
  const chunkIntoWeeks = (days: Date[]): Date[][] => {
19
21
  const weeks: Date[][] = [];
@@ -28,10 +30,29 @@ export type MonthViewProps<T> = {
28
30
  events: CalendarEvent<T>[];
29
31
  maxVisibleEventCount: number;
30
32
  weekStartsOn: WeekStartsOn;
33
+ locale?: Locale;
34
+ /** Sort each day's events by start time before slicing. Default true. */
35
+ sortedMonthView?: boolean;
36
+ /** Template for the overflow label; `{moreCount}` is replaced. Default "{moreCount} More". */
37
+ moreLabel?: string;
38
+ /** Show dimmed days from adjacent months in the grid. Default true. */
39
+ showAdjacentMonths?: boolean;
40
+ /** Ignore taps on month-cell events (day-cell taps still fire). Default false. */
41
+ disableMonthEventCellPress?: boolean;
42
+ /** Reverse the day order within each week (right-to-left). Default false. */
43
+ isRTL?: boolean;
44
+ /** Always render six week rows, for a fixed-height grid. Default false. */
45
+ showSixWeeks?: boolean;
46
+ /** Highlight this date instead of the real "today". */
47
+ activeDate?: Date;
48
+ /** Per-date style merged onto the day cell. */
49
+ calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
31
50
  renderEvent: RenderEvent<T>;
32
51
  keyExtractor: EventKeyExtractor<T>;
33
52
  onPressDay?: (date: Date) => void;
53
+ onLongPressDay?: (date: Date) => void;
34
54
  onPressEvent: (event: CalendarEvent<T>) => void;
55
+ onLongPressEvent?: (event: CalendarEvent<T>) => void;
35
56
  onPressMore?: (events: CalendarEvent<T>[], date: Date) => void;
36
57
  };
37
58
 
@@ -40,10 +61,21 @@ function MonthViewInner<T>({
40
61
  events,
41
62
  maxVisibleEventCount,
42
63
  weekStartsOn,
64
+ locale,
65
+ sortedMonthView = true,
66
+ moreLabel = '{moreCount} More',
67
+ showAdjacentMonths = true,
68
+ disableMonthEventCellPress = false,
69
+ isRTL = false,
70
+ showSixWeeks = false,
71
+ activeDate,
72
+ calendarCellStyle,
43
73
  renderEvent,
44
74
  keyExtractor,
45
75
  onPressDay,
76
+ onLongPressDay,
46
77
  onPressEvent,
78
+ onLongPressEvent,
47
79
  onPressMore,
48
80
  }: MonthViewProps<T>) {
49
81
  const theme = useCalendarTheme();
@@ -51,9 +83,13 @@ function MonthViewInner<T>({
51
83
 
52
84
  const weeks = useMemo(() => {
53
85
  const start = startOfWeek(startOfMonth(date), { weekStartsOn });
54
- const end = endOfWeek(endOfMonth(date), { weekStartsOn });
55
- return chunkIntoWeeks(eachDayOfInterval({ start, end }));
56
- }, [date, weekStartsOn]);
86
+ const naturalEnd = endOfWeek(endOfMonth(date), { weekStartsOn });
87
+ // Pad to six rows (42 days) for a fixed-height grid; some months span only
88
+ // four or five weeks.
89
+ const end = showSixWeeks ? addDays(start, 41) : naturalEnd;
90
+ const chunked = chunkIntoWeeks(eachDayOfInterval({ start, end }));
91
+ return isRTL ? chunked.map((week) => [...week].reverse()) : chunked;
92
+ }, [date, weekStartsOn, isRTL, showSixWeeks]);
57
93
 
58
94
  // Group events by calendar day once per `events` change, rather than scanning
59
95
  // the whole list inside every one of the (up to) 42 day cells on each render.
@@ -67,16 +103,36 @@ function MonthViewInner<T>({
67
103
  else map.set(key, [event]);
68
104
  }
69
105
  }
106
+ if (sortedMonthView) {
107
+ for (const list of map.values()) list.sort((a, b) => a.start.getTime() - b.start.getTime());
108
+ }
70
109
  return map;
71
- }, [events]);
110
+ }, [events, sortedMonthView]);
72
111
 
73
112
  const renderDay = (day: Date) => {
74
- const dayEvents = eventsByDay.get(startOfDay(day).toISOString()) ?? [];
75
113
  const isCurrentMonth = isSameMonth(day, date);
114
+
115
+ // Blank out adjacent-month days when they're hidden, keeping the grid shape.
116
+ if (!isCurrentMonth && !showAdjacentMonths) {
117
+ return (
118
+ <View
119
+ key={day.toISOString()}
120
+ style={[
121
+ styles.dayCell,
122
+ { borderColor: theme.colors.gridLine },
123
+ isWeekend(day) && { backgroundColor: theme.colors.weekendBackground },
124
+ ]}
125
+ />
126
+ );
127
+ }
128
+
129
+ const dayEvents = eventsByDay.get(startOfDay(day).toISOString()) ?? [];
76
130
  const isToday = getIsToday(day);
131
+ // Highlight the chosen `activeDate` when supplied, else the real today.
132
+ const isHighlighted = activeDate ? isSameCalendarDay(day, activeDate) : isToday;
77
133
  const hiddenCount = dayEvents.length - maxVisibleEventCount;
78
134
 
79
- const dateColor = isToday
135
+ const dateColor = isHighlighted
80
136
  ? theme.colors.todayText
81
137
  : isCurrentMonth
82
138
  ? theme.colors.text
@@ -85,7 +141,7 @@ function MonthViewInner<T>({
85
141
  // Summarise the cell for screen readers: full date, today marker, and how
86
142
  // many events it holds (the chips inside are grouped under this cell).
87
143
  const eventCount = dayEvents.length;
88
- const accessibilityLabel = `${format(day, 'EEEE, d LLLL yyyy')}${isToday ? ', today' : ''}, ${eventCount} ${eventCount === 1 ? 'event' : 'events'}`;
144
+ const accessibilityLabel = `${format(day, 'EEEE, d LLLL yyyy', { locale })}${isToday ? ', today' : ''}, ${eventCount} ${eventCount === 1 ? 'event' : 'events'}`;
89
145
 
90
146
  return (
91
147
  <TouchableOpacity
@@ -94,16 +150,18 @@ function MonthViewInner<T>({
94
150
  styles.dayCell,
95
151
  { borderColor: theme.colors.gridLine },
96
152
  isWeekend(day) && { backgroundColor: theme.colors.weekendBackground },
153
+ calendarCellStyle?.(day),
97
154
  ]}
98
155
  onPress={onPressDay ? () => onPressDay(day) : undefined}
99
- disabled={!onPressDay}
156
+ onLongPress={onLongPressDay ? () => onLongPressDay(day) : undefined}
157
+ disabled={!onPressDay && !onLongPressDay}
100
158
  accessibilityRole={onPressDay ? 'button' : undefined}
101
159
  accessibilityLabel={accessibilityLabel}
102
160
  >
103
161
  <View
104
162
  style={[
105
163
  styles.dateBadge,
106
- isToday && {
164
+ isHighlighted && {
107
165
  backgroundColor: theme.colors.todayBackground,
108
166
  borderRadius: theme.todayBadgeRadius,
109
167
  },
@@ -115,7 +173,17 @@ function MonthViewInner<T>({
115
173
  </View>
116
174
  {dayEvents.slice(0, maxVisibleEventCount).map((event, index) => (
117
175
  <View key={keyExtractor(event, index)} style={styles.monthEvent}>
118
- <RenderEventComponent event={event} mode="month" onPress={() => onPressEvent(event)} />
176
+ <RenderEventComponent
177
+ event={event}
178
+ mode="month"
179
+ isAllDay={isAllDayEvent(event)}
180
+ onPress={disableMonthEventCellPress ? () => {} : () => onPressEvent(event)}
181
+ onLongPress={
182
+ disableMonthEventCellPress || !onLongPressEvent
183
+ ? undefined
184
+ : () => onLongPressEvent(event)
185
+ }
186
+ />
119
187
  </View>
120
188
  ))}
121
189
  {hiddenCount > 0 ? (
@@ -126,7 +194,7 @@ function MonthViewInner<T>({
126
194
  accessibilityLabel={`Show ${hiddenCount} more events`}
127
195
  allowFontScaling={false}
128
196
  >
129
- {`${hiddenCount} More`}
197
+ {moreLabel.replace('{moreCount}', String(hiddenCount))}
130
198
  </Text>
131
199
  ) : null}
132
200
  </TouchableOpacity>