@super-calendar/native 2.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.
@@ -0,0 +1,456 @@
1
+ import {
2
+ endOfDay,
3
+ endOfMonth,
4
+ endOfWeek,
5
+ type Locale,
6
+ startOfDay,
7
+ startOfMonth,
8
+ startOfWeek,
9
+ } from "date-fns";
10
+ import { useCallback, useMemo } from "react";
11
+ import type { StyleProp, ViewStyle } from "react-native";
12
+ import { useSharedValue } from "react-native-reanimated";
13
+ import { CalendarThemeProvider, mergeTheme, type PartialCalendarTheme } from "../theme";
14
+ import type {
15
+ BusinessHours,
16
+ CalendarEvent,
17
+ CalendarMode,
18
+ EventKeyExtractor,
19
+ RenderEvent,
20
+ RenderEventArgs,
21
+ WeekStartsOn,
22
+ } from "../types";
23
+ import { getViewDays } from "@super-calendar/core";
24
+ import { Agenda } from "./Agenda";
25
+ import { DefaultEvent } from "./DefaultEvent";
26
+ import { MonthPager } from "./MonthPager";
27
+ import {
28
+ DEFAULT_HOUR_HEIGHT,
29
+ type EventDragHandler,
30
+ type EventDragStartHandler,
31
+ type HourRenderer,
32
+ TimeGrid,
33
+ } from "./TimeGrid";
34
+
35
+ export type CalendarProps<T> = {
36
+ events: CalendarEvent<T>[];
37
+ mode: CalendarMode;
38
+ date: Date;
39
+ onChangeDate: (date: Date) => void;
40
+ /** Fired alongside `onChangeDate` with the `[start, end]` of the newly-visible range. */
41
+ onChangeDateRange?: (range: [Date, Date]) => void;
42
+ onPressEvent: (event: CalendarEvent<T>) => void;
43
+ /** Long-press an event (month/week/day). */
44
+ onLongPressEvent?: (event: CalendarEvent<T>) => void;
45
+ /**
46
+ * Enable drag-to-move and drag-to-resize on the week/day grid. Called with the
47
+ * dragged event and its new start/end (snapped to `dragStepMinutes`); update
48
+ * your own event state in response. Long-press an event to move it (drag
49
+ * horizontally to change the day too); drag its bottom grip to resize. Return
50
+ * `false` to reject the drop (e.g. an overlap) and snap the event back.
51
+ */
52
+ onDragEvent?: EventDragHandler<T>;
53
+ /**
54
+ * Fired when a move or resize gesture begins on the week/day grid, before any
55
+ * change is committed: on grab for a move (after the long-press), and when a
56
+ * resize drag starts. Use it to trigger haptic feedback, e.g.
57
+ * `Haptics.impactAsync()` from `expo-haptics`. Native-only and inert unless
58
+ * `onDragEvent` is also set.
59
+ */
60
+ onDragStart?: EventDragStartHandler<T>;
61
+ /** Minutes a drag-to-move/resize snaps to. Default 15. */
62
+ dragStepMinutes?: number;
63
+ /**
64
+ * Show the resize grip on draggable events. Default true. Set false to keep
65
+ * drag-to-move and drag-to-resize working while hiding the visible indicator.
66
+ */
67
+ showDragHandle?: boolean;
68
+ /** Tap a day cell (month mode) — e.g. drill into the day view. */
69
+ onPressDay?: (date: Date) => void;
70
+ /** Long-press a day cell (month mode). */
71
+ onLongPressDay?: (date: Date) => void;
72
+ /** Tap the "+N more" overflow label in a month cell. */
73
+ onPressMore?: (events: CalendarEvent<T>[], date: Date) => void;
74
+ /** Tap empty space on the week/day grid; receives the date+time pressed. */
75
+ onPressCell?: (date: Date) => void;
76
+ /** After an empty-cell press, snap the pager back to the active page. Default false. */
77
+ resetPageOnPressCell?: boolean;
78
+ /** Long-press empty space on the week/day grid; receives the date+time. */
79
+ onLongPressCell?: (date: Date) => void;
80
+ /**
81
+ * Enable drag-to-create on the week/day grid: long-press empty space and drag
82
+ * to sweep out a new event's time range. Called on release with the snapped
83
+ * `start`/`end` (to `dragStepMinutes`); create your own event in response. A
84
+ * stationary press yields a one-step range. Native-only; supersedes
85
+ * `onLongPressCell` on empty space when set.
86
+ */
87
+ onCreateEvent?: (start: Date, end: Date) => void;
88
+ /** Tap a day's column header on the week/day grid (default header only). */
89
+ onPressDateHeader?: (date: Date) => void;
90
+ /**
91
+ * Max event chips per month cell before they collapse into "+N more". Omit to
92
+ * auto-fit as many as the cell height allows (default); set a number for a
93
+ * fixed cap. Pass an explicit value when using a custom `renderEvent`.
94
+ */
95
+ maxVisibleEventCount?: number;
96
+ /** Sort each month day's events by start. Default true. */
97
+ sortedMonthView?: boolean;
98
+ /** Month overflow label template; `{moreCount}` is replaced. Default "{moreCount} More". */
99
+ moreLabel?: string;
100
+ /** Show dimmed adjacent-month days in the month grid. Default true. */
101
+ showAdjacentMonths?: boolean;
102
+ /** Ignore taps on month-cell events (day taps still fire). Default false. */
103
+ disableMonthEventCellPress?: boolean;
104
+ /** First day of the week. Sunday = 0 (default) … Saturday = 6. */
105
+ weekStartsOn?: WeekStartsOn;
106
+ /** Number of day columns when `mode="custom"`. Ignored by other modes. Default 1. */
107
+ numberOfDays?: number;
108
+ /**
109
+ * Last weekday of a `custom` partial-week view (0–6). When set, `custom` shows
110
+ * `weekStartsOn`…`weekEndsOn` of the visible week and pages by week, taking
111
+ * precedence over `numberOfDays`. Ignored by other modes.
112
+ */
113
+ weekEndsOn?: WeekStartsOn;
114
+ /** Replace the built-in event box. Return a `flex: 1` element. */
115
+ renderEvent?: RenderEvent<T>;
116
+ /** Per-event style merged onto the built-in event box (static or a function of the event). */
117
+ eventCellStyle?: StyleProp<ViewStyle> | ((event: CalendarEvent<T>) => StyleProp<ViewStyle>);
118
+ /** Per-date style for month cells and week/day columns (e.g. shade specific dates). */
119
+ calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
120
+ /**
121
+ * Week/day grid only: open hours per day for business-hours shading. Hours
122
+ * outside the returned `{ start, end }` are tinted; return `null` to shade the
123
+ * whole day (closed). Omit for no shading.
124
+ */
125
+ businessHours?: BusinessHours;
126
+ /** Stable key per event. Defaults to start-time + index. */
127
+ keyExtractor?: EventKeyExtractor<T>;
128
+ /** Partial theme merged over the defaults. */
129
+ theme?: PartialCalendarTheme;
130
+ /** Externally-owned per-hour row height (week/day). Created internally if omitted. */
131
+ cellHeight?: ReturnType<typeof useSharedValue<number>>;
132
+ /** Initial per-hour row height in px (week/day). Default 48. */
133
+ hourHeight?: number;
134
+ minHourHeight?: number;
135
+ maxHourHeight?: number;
136
+ hourColumnWidth?: number;
137
+ /** Hide the left hour-axis column on the week/day grid. Default false. */
138
+ hideHours?: boolean;
139
+ /** Sub-hour divider lines per hour on the week/day grid (e.g. 2 = half-hours). Default 1. */
140
+ timeslots?: number;
141
+ /**
142
+ * Show the all-day lane above the week/day grid. Default true. When false the
143
+ * lane is never rendered, so all-day events (see `ICalendarEvent.allDay` and
144
+ * whole-day spans) are not shown on the time grid.
145
+ */
146
+ showAllDayEventCell?: boolean;
147
+ /** Show the ISO week number in the week/day header gutter. Default false. */
148
+ showWeekNumber?: boolean;
149
+ /** Prefix for the week-number label (e.g. "W"). Default "W". */
150
+ weekNumberPrefix?: string;
151
+ /** Replace the hour-axis label on the week/day grid. Receives the hour (0–23) and `ampm`. */
152
+ hourComponent?: HourRenderer;
153
+ /** Always render six week rows in month view, for a fixed-height grid. Default false. */
154
+ showSixWeeks?: boolean;
155
+ /** Allow swiping between pages (all modes). Default true. */
156
+ swipeEnabled?: boolean;
157
+ /** Show the vertical scroll indicator on the week/day grid. Default true. */
158
+ showVerticalScrollIndicator?: boolean;
159
+ /** Allow vertical scrolling of the week/day grid. Default true. */
160
+ verticalScrollEnabled?: boolean;
161
+ /** Element rendered between the day header and the week/day grid. */
162
+ headerComponent?: React.ReactNode;
163
+ /** First hour shown on the week/day grid (0–23). Default 0. */
164
+ minHour?: number;
165
+ /** Last hour shown on the week/day grid, exclusive (1–24). Default 24. */
166
+ maxHour?: number;
167
+ /** Show hour labels (and built-in event times) in 12-hour AM/PM form. Default false (24h). */
168
+ ampm?: boolean;
169
+ /** Show the time range in the built-in event renderer (day/week/schedule). Default true. */
170
+ showTime?: boolean;
171
+ /** Add a trailing ellipsis (…) when an event title overflows in the built-in renderer; otherwise the text is clipped. Default false. */
172
+ ellipsizeTitle?: boolean;
173
+ /** Label the built-in renderer shows for an all-day event in the schedule (and its screen-reader text). Default "All day". */
174
+ allDayLabel?: string;
175
+ /** Initial vertical scroll, in minutes from midnight (week/day). */
176
+ scrollOffsetMinutes?: number;
177
+ /** Show the current-time line on the week/day grid. Default true. */
178
+ showNowIndicator?: boolean;
179
+ /** A date-fns `Locale` for weekday/date labels. Defaults to English. */
180
+ locale?: Locale;
181
+ /** Highlight this date (header/cell/agenda) instead of the real "today". */
182
+ activeDate?: Date;
183
+ /**
184
+ * Lay the day columns out right-to-left (month, week/day grid and all-day lane).
185
+ * Cosmetic only: the hour gutter stays on the left and paging still advances
186
+ * with the system scroll direction. Default false. For full RTL (including
187
+ * scroll direction), also enable React Native's `I18nManager`.
188
+ */
189
+ isRTL?: boolean;
190
+ /**
191
+ * Allow a fling to carry across several pages before snapping. Default false:
192
+ * one day/week/month per swipe.
193
+ */
194
+ freeSwipe?: boolean;
195
+ /** Custom header above the week/day grid. Receives the visible days. */
196
+ renderTimeGridHeader?: (days: Date[]) => React.ReactNode;
197
+ /** Replace the weekday-label header above the month grid. Return `null` to hide it. */
198
+ renderHeaderForMonthView?: (weekDays: Date[]) => React.ReactNode;
199
+ /**
200
+ * Month mode only: replace the default date badge in each day cell. Receives
201
+ * the day; return your own date label (e.g. a number with a dot or badge).
202
+ * Event chips and the "+N more" label still render below it.
203
+ */
204
+ renderCustomDateForMonth?: (date: Date) => React.ReactNode;
205
+ /** Drawn between rows of the `schedule` (agenda) list. */
206
+ itemSeparatorComponent?: React.ComponentType<unknown> | null;
207
+ };
208
+
209
+ // Derive a key purely from event data so identity is stable across reorders and
210
+ // list mutations. Supply your own `keyExtractor` returning a real id when events
211
+ // can share an identical start/end/title.
212
+ const defaultKeyExtractor: EventKeyExtractor<unknown> = (event) =>
213
+ `${event.start.toISOString()}|${event.end.toISOString()}|${event.title ?? ""}`;
214
+
215
+ // The [start, end] of the dates a given mode shows around `date`. Month spans the
216
+ // padded grid (whole weeks); time-grid modes span their day columns.
217
+ function visibleRange(
218
+ mode: CalendarMode,
219
+ date: Date,
220
+ weekStartsOn: WeekStartsOn,
221
+ numberOfDays: number,
222
+ weekEndsOn?: WeekStartsOn,
223
+ ): [Date, Date] {
224
+ if (mode === "month") {
225
+ return [
226
+ startOfWeek(startOfMonth(date), { weekStartsOn }),
227
+ endOfWeek(endOfMonth(date), { weekStartsOn }),
228
+ ];
229
+ }
230
+ const days = getViewDays(mode, date, weekStartsOn, numberOfDays, false, weekEndsOn);
231
+ return [startOfDay(days[0]), endOfDay(days[days.length - 1])];
232
+ }
233
+
234
+ export function Calendar<T>({
235
+ events,
236
+ mode,
237
+ date,
238
+ onChangeDate,
239
+ onChangeDateRange,
240
+ onPressEvent,
241
+ onLongPressEvent,
242
+ onDragEvent,
243
+ onDragStart,
244
+ dragStepMinutes,
245
+ showDragHandle,
246
+ onPressDay,
247
+ onLongPressDay,
248
+ onPressMore,
249
+ onPressCell,
250
+ resetPageOnPressCell,
251
+ onLongPressCell,
252
+ onCreateEvent,
253
+ onPressDateHeader,
254
+ maxVisibleEventCount,
255
+ sortedMonthView,
256
+ moreLabel,
257
+ showAdjacentMonths,
258
+ disableMonthEventCellPress,
259
+ weekStartsOn = 0,
260
+ numberOfDays,
261
+ weekEndsOn,
262
+ renderEvent = DefaultEvent,
263
+ eventCellStyle,
264
+ calendarCellStyle,
265
+ businessHours,
266
+ keyExtractor = defaultKeyExtractor as EventKeyExtractor<T>,
267
+ theme,
268
+ cellHeight: cellHeightProp,
269
+ hourHeight = DEFAULT_HOUR_HEIGHT,
270
+ minHourHeight,
271
+ maxHourHeight,
272
+ hourColumnWidth,
273
+ hideHours,
274
+ timeslots,
275
+ showAllDayEventCell,
276
+ showWeekNumber,
277
+ weekNumberPrefix,
278
+ hourComponent,
279
+ showSixWeeks,
280
+ swipeEnabled,
281
+ showVerticalScrollIndicator,
282
+ verticalScrollEnabled,
283
+ headerComponent,
284
+ minHour,
285
+ maxHour,
286
+ ampm,
287
+ showTime,
288
+ ellipsizeTitle,
289
+ allDayLabel,
290
+ scrollOffsetMinutes,
291
+ showNowIndicator,
292
+ locale,
293
+ activeDate,
294
+ isRTL,
295
+ freeSwipe,
296
+ renderTimeGridHeader,
297
+ renderHeaderForMonthView,
298
+ renderCustomDateForMonth,
299
+ itemSeparatorComponent,
300
+ }: CalendarProps<T>) {
301
+ const mergedTheme = useMemo(() => mergeTheme(theme), [theme]);
302
+ const internalCellHeight = useSharedValue(hourHeight);
303
+ const cellHeight = cellHeightProp ?? internalCellHeight;
304
+
305
+ // Swallow presses on disabled events once, so every view inherits the guard.
306
+ const handlePressEvent = useCallback(
307
+ (event: CalendarEvent<T>) => {
308
+ if (!event.disabled) onPressEvent(event);
309
+ },
310
+ [onPressEvent],
311
+ );
312
+ const handleLongPressEvent = useMemo(
313
+ () =>
314
+ onLongPressEvent
315
+ ? (event: CalendarEvent<T>) => {
316
+ if (!event.disabled) onLongPressEvent(event);
317
+ }
318
+ : undefined,
319
+ [onLongPressEvent],
320
+ );
321
+
322
+ // Echo every date change and, when asked, the full visible range derived from it.
323
+ const handleChangeDate = useCallback(
324
+ (next: Date) => {
325
+ onChangeDate(next);
326
+ onChangeDateRange?.(visibleRange(mode, next, weekStartsOn, numberOfDays ?? 1, weekEndsOn));
327
+ },
328
+ [onChangeDate, onChangeDateRange, mode, weekStartsOn, numberOfDays, weekEndsOn],
329
+ );
330
+
331
+ // Inject `eventCellStyle`, `ampm`, `showTime`, `ellipsizeTitle` and
332
+ // `allDayLabel` into the renderer once, so every view gets them for free
333
+ // without threading the props through each component. Skip the wrapper
334
+ // entirely when none are set.
335
+ const resolvedRenderEvent = useMemo<RenderEvent<T>>(() => {
336
+ if (
337
+ eventCellStyle == null &&
338
+ ampm == null &&
339
+ showTime == null &&
340
+ ellipsizeTitle == null &&
341
+ allDayLabel == null
342
+ )
343
+ return renderEvent;
344
+ const Base = renderEvent;
345
+ return function StyledEvent(props: RenderEventArgs<T>) {
346
+ const cellStyle =
347
+ typeof eventCellStyle === "function" ? eventCellStyle(props.event) : eventCellStyle;
348
+ return (
349
+ <Base
350
+ {...props}
351
+ cellStyle={cellStyle}
352
+ ampm={ampm}
353
+ showTime={showTime}
354
+ ellipsizeTitle={ellipsizeTitle}
355
+ allDayLabel={allDayLabel}
356
+ />
357
+ );
358
+ };
359
+ }, [renderEvent, eventCellStyle, ampm, showTime, ellipsizeTitle, allDayLabel]);
360
+
361
+ return (
362
+ <CalendarThemeProvider value={mergedTheme}>
363
+ {mode === "month" ? (
364
+ <MonthPager
365
+ date={date}
366
+ events={events}
367
+ maxVisibleEventCount={maxVisibleEventCount}
368
+ weekStartsOn={weekStartsOn}
369
+ locale={locale}
370
+ sortedMonthView={sortedMonthView}
371
+ moreLabel={moreLabel}
372
+ showAdjacentMonths={showAdjacentMonths}
373
+ disableMonthEventCellPress={disableMonthEventCellPress}
374
+ isRTL={isRTL}
375
+ showSixWeeks={showSixWeeks}
376
+ activeDate={activeDate}
377
+ renderHeaderForMonthView={renderHeaderForMonthView}
378
+ renderCustomDateForMonth={renderCustomDateForMonth}
379
+ calendarCellStyle={calendarCellStyle}
380
+ renderEvent={resolvedRenderEvent}
381
+ keyExtractor={keyExtractor}
382
+ onPressDay={onPressDay}
383
+ onLongPressDay={onLongPressDay}
384
+ onPressEvent={handlePressEvent}
385
+ onLongPressEvent={handleLongPressEvent}
386
+ onPressMore={onPressMore}
387
+ onChangeDate={handleChangeDate}
388
+ freeSwipe={freeSwipe}
389
+ swipeEnabled={swipeEnabled}
390
+ />
391
+ ) : mode === "schedule" ? (
392
+ <Agenda
393
+ events={events}
394
+ locale={locale}
395
+ renderEvent={resolvedRenderEvent}
396
+ keyExtractor={keyExtractor}
397
+ onPressEvent={handlePressEvent}
398
+ onLongPressEvent={handleLongPressEvent}
399
+ onPressDay={onPressDay}
400
+ activeDate={activeDate}
401
+ itemSeparatorComponent={itemSeparatorComponent}
402
+ />
403
+ ) : (
404
+ <TimeGrid
405
+ mode={mode}
406
+ numberOfDays={numberOfDays}
407
+ weekEndsOn={weekEndsOn}
408
+ date={date}
409
+ events={events}
410
+ cellHeight={cellHeight}
411
+ hourHeight={hourHeight}
412
+ weekStartsOn={weekStartsOn}
413
+ renderEvent={resolvedRenderEvent}
414
+ keyExtractor={keyExtractor}
415
+ scrollOffsetMinutes={scrollOffsetMinutes}
416
+ hourColumnWidth={hourColumnWidth}
417
+ hideHours={hideHours}
418
+ timeslots={timeslots}
419
+ showAllDayEventCell={showAllDayEventCell}
420
+ calendarCellStyle={calendarCellStyle}
421
+ businessHours={businessHours}
422
+ showWeekNumber={showWeekNumber}
423
+ weekNumberPrefix={weekNumberPrefix}
424
+ hourComponent={hourComponent}
425
+ showVerticalScrollIndicator={showVerticalScrollIndicator}
426
+ verticalScrollEnabled={verticalScrollEnabled}
427
+ headerComponent={headerComponent}
428
+ minHour={minHour}
429
+ maxHour={maxHour}
430
+ ampm={ampm}
431
+ minHourHeight={minHourHeight}
432
+ maxHourHeight={maxHourHeight}
433
+ showNowIndicator={showNowIndicator}
434
+ locale={locale}
435
+ activeDate={activeDate}
436
+ isRTL={isRTL}
437
+ freeSwipe={freeSwipe}
438
+ swipeEnabled={swipeEnabled}
439
+ onPressEvent={handlePressEvent}
440
+ onLongPressEvent={handleLongPressEvent}
441
+ onDragEvent={onDragEvent}
442
+ showDragHandle={showDragHandle}
443
+ onDragStart={onDragStart}
444
+ dragStepMinutes={dragStepMinutes}
445
+ onPressCell={onPressCell}
446
+ resetPageOnPressCell={resetPageOnPressCell}
447
+ onLongPressCell={onLongPressCell}
448
+ onCreateEvent={onCreateEvent}
449
+ onPressDateHeader={onPressDateHeader}
450
+ onChangeDate={handleChangeDate}
451
+ renderHeader={renderTimeGridHeader}
452
+ />
453
+ )}
454
+ </CalendarThemeProvider>
455
+ );
456
+ }
@@ -0,0 +1,223 @@
1
+ import { StyleSheet, Text, TouchableOpacity } from "react-native";
2
+ import Animated, { useAnimatedStyle, useDerivedValue } from "react-native-reanimated";
3
+ import { useCalendarTheme } from "../theme";
4
+ import type { RenderEventArgs } from "../types";
5
+ import {
6
+ eventAccessibilityLabel,
7
+ eventChipLayout,
8
+ eventTimeLabel,
9
+ titleEllipsizeMode,
10
+ titleNumberOfLines,
11
+ } from "@super-calendar/core";
12
+
13
+ // Box vertical padding (mirrors styles.box) and the reserved height for the time
14
+ // line. The time wraps to at most two lines on a narrow column, so reserve two
15
+ // lines' worth; the title gets every whole line that fits in what remains.
16
+ const BOX_PADDING_V = 2;
17
+ const TIME_LINE_HEIGHT = 30;
18
+ const FALLBACK_TITLE_LINE_HEIGHT = 16;
19
+
20
+ const numericStyle = (value: number | string | undefined, fallback: number) =>
21
+ typeof value === "number" ? value : fallback;
22
+
23
+ /**
24
+ * The built-in event renderer: a filled, rounded box showing the event title
25
+ * and (on the day/week grid, when the box is tall enough) its time range. The
26
+ * title fills the box in whole lines, never a half-cut last line, and clips
27
+ * without an ellipsis unless `ellipsizeTitle` is set. The time is secondary: it
28
+ * only shows once a full line is free beneath the title. Pass your own
29
+ * `renderEvent` to `<Calendar>` to replace it entirely.
30
+ */
31
+ export function DefaultEvent<T>({
32
+ event,
33
+ mode,
34
+ boxHeight,
35
+ isAllDay,
36
+ ampm = false,
37
+ showTime = true,
38
+ ellipsizeTitle = false,
39
+ allDayLabel,
40
+ cellStyle,
41
+ onPress,
42
+ onLongPress,
43
+ }: RenderEventArgs<T>) {
44
+ const theme = useCalendarTheme();
45
+ const isAllDayEvent = isAllDay ?? false;
46
+ const timeLabel = eventTimeLabel({
47
+ mode,
48
+ isAllDay: isAllDayEvent,
49
+ start: event.start,
50
+ end: event.end,
51
+ ampm,
52
+ showTime,
53
+ allDayLabel,
54
+ });
55
+ const ellipsizeMode = titleEllipsizeMode(ellipsizeTitle);
56
+ const titleLineHeight = numericStyle(
57
+ theme.text.eventTitle.lineHeight,
58
+ FALLBACK_TITLE_LINE_HEIGHT,
59
+ );
60
+
61
+ // Announce the full event to screen readers: title plus the all-day label or
62
+ // the time range (which is otherwise only shown visually).
63
+ const accessibilityLabel = eventAccessibilityLabel({
64
+ title: event.title,
65
+ isAllDay: isAllDayEvent,
66
+ start: event.start,
67
+ end: event.end,
68
+ ampm,
69
+ allDayLabel,
70
+ });
71
+
72
+ // Month cells and the all-day lane get a single clipped line; the timed grid
73
+ // (and the roomy schedule rows) wrap to fill the box. `titleNumberOfLines`
74
+ // returns 1 for the single-line contexts and undefined for the wrapping ones.
75
+ const fixedTitleLines = titleNumberOfLines(mode, isAllDayEvent);
76
+
77
+ // The schedule view is a list, not a grid, so its rows are a roomier card with
78
+ // a larger title and time, mirroring the dom renderer's default agenda row.
79
+ const isSchedule = mode === "schedule";
80
+
81
+ // Whole-line title clamp + time visibility, recomputed on the UI thread as the
82
+ // box grows or shrinks with pinch-zoom, so the title never shows a half-cut
83
+ // line and the secondary time only appears once a full line is free below it.
84
+ const layout = useDerivedValue(
85
+ () =>
86
+ eventChipLayout({
87
+ boxHeightPx: boxHeight?.value,
88
+ mode,
89
+ hasTime: timeLabel != null,
90
+ titleLineHeightPx: titleLineHeight,
91
+ timeLineHeightPx: TIME_LINE_HEIGHT,
92
+ paddingYPx: BOX_PADDING_V,
93
+ }),
94
+ [boxHeight, mode, timeLabel, titleLineHeight],
95
+ );
96
+
97
+ // Clip the wrapped title to a whole number of lines (overflow hidden on the
98
+ // wrapper), so the visible text is always full words on full lines.
99
+ const titleClipStyle = useAnimatedStyle(() => {
100
+ const lines = layout.value.titleMaxLines;
101
+ return { maxHeight: lines > 0 ? lines * titleLineHeight : undefined };
102
+ }, [layout, titleLineHeight]);
103
+
104
+ const timeStyle = useAnimatedStyle(() => {
105
+ return { display: layout.value.showTime ? "flex" : "none" };
106
+ }, [layout]);
107
+
108
+ return (
109
+ <TouchableOpacity
110
+ style={[
111
+ styles.box,
112
+ isSchedule && styles.scheduleBox,
113
+ { backgroundColor: theme.colors.eventBackground },
114
+ event.disabled && styles.disabled,
115
+ cellStyle,
116
+ ]}
117
+ onPress={onPress}
118
+ onLongPress={onLongPress}
119
+ activeOpacity={0.7}
120
+ accessibilityRole="button"
121
+ accessibilityLabel={accessibilityLabel}
122
+ accessibilityState={{ disabled: event.disabled ?? false }}
123
+ >
124
+ {event.title ? (
125
+ fixedTitleLines == null ? (
126
+ <Animated.View style={[styles.titleClip, titleClipStyle]}>
127
+ <Text
128
+ style={[
129
+ theme.text.eventTitle,
130
+ isSchedule && styles.scheduleTitle,
131
+ { color: theme.colors.eventText },
132
+ ]}
133
+ ellipsizeMode={ellipsizeMode}
134
+ allowFontScaling={false}
135
+ >
136
+ {event.title}
137
+ </Text>
138
+ </Animated.View>
139
+ ) : (
140
+ <Text
141
+ style={[
142
+ theme.text.eventTitle,
143
+ styles.title,
144
+ isSchedule && styles.scheduleTitle,
145
+ { color: theme.colors.eventText },
146
+ ]}
147
+ // Month cells and the all-day lane are compact: a single clipped line.
148
+ numberOfLines={fixedTitleLines}
149
+ ellipsizeMode={ellipsizeMode}
150
+ allowFontScaling={false}
151
+ >
152
+ {event.title}
153
+ </Text>
154
+ )
155
+ ) : null}
156
+ {timeLabel ? (
157
+ <Animated.View style={timeStyle}>
158
+ <Text
159
+ style={[
160
+ styles.time,
161
+ isSchedule && styles.scheduleTime,
162
+ { color: theme.colors.eventText },
163
+ ]}
164
+ // Wrap rather than clip horizontally: a narrow column shows the full
165
+ // range across two lines instead of a cut-off "11:00 - 1".
166
+ numberOfLines={2}
167
+ ellipsizeMode={ellipsizeMode}
168
+ allowFontScaling={false}
169
+ >
170
+ {timeLabel}
171
+ </Text>
172
+ </Animated.View>
173
+ ) : null}
174
+ </TouchableOpacity>
175
+ );
176
+ }
177
+
178
+ const styles = StyleSheet.create({
179
+ box: {
180
+ // Grow to fill the grid's sized box, but fall back to the content height
181
+ // (flexBasis: auto) so month chips — whose wrapper has no fixed height —
182
+ // size to their text instead of collapsing.
183
+ flexGrow: 1,
184
+ flexShrink: 1,
185
+ flexBasis: "auto",
186
+ borderRadius: 6,
187
+ paddingVertical: BOX_PADDING_V,
188
+ paddingHorizontal: 4,
189
+ overflow: "hidden",
190
+ },
191
+ // The roomy schedule-row card, matching the dom default agenda row.
192
+ scheduleBox: {
193
+ borderRadius: 8,
194
+ paddingVertical: 6,
195
+ paddingHorizontal: 10,
196
+ },
197
+ scheduleTitle: {
198
+ fontSize: 14,
199
+ fontWeight: "600",
200
+ lineHeight: 18,
201
+ },
202
+ scheduleTime: {
203
+ fontSize: 13,
204
+ lineHeight: 18,
205
+ opacity: 0.75,
206
+ },
207
+ // Clips the wrapped title to the animated whole-line max-height.
208
+ titleClip: {
209
+ overflow: "hidden",
210
+ },
211
+ // The all-day single line shrinks and clips before the time line (whose default
212
+ // flexShrink of 0 keeps it whole), so a short lane never shows a half-cut time.
213
+ title: {
214
+ flexShrink: 1,
215
+ },
216
+ time: {
217
+ fontSize: 11,
218
+ lineHeight: 15,
219
+ },
220
+ disabled: {
221
+ opacity: 0.5,
222
+ },
223
+ });