@super-calendar/native 2.1.5 → 2.3.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.
@@ -16,7 +16,15 @@ import {
16
16
  const isWeb = Platform.OS === "web";
17
17
  import { useCalendarTheme } from "../theme";
18
18
  import type { CalendarEvent, EventKeyExtractor, RenderEvent, WeekStartsOn } from "../types";
19
- import { type DateRange, daySelectionState, useCalendarSelection } from "@super-calendar/core";
19
+ import { withEventAccessibilityLabel } from "../utils/withEventAccessibilityLabel";
20
+ import {
21
+ type DateRange,
22
+ type EventAccessibilityLabeler,
23
+ type WeekdayFormat,
24
+ daySelectionState,
25
+ useCalendarSelection,
26
+ weekdayFormatToken,
27
+ } from "@super-calendar/core";
20
28
  import { dayBadgeKind, rangeBandKind } from "@super-calendar/core";
21
29
  import {
22
30
  buildMonthWeeks,
@@ -54,6 +62,8 @@ export type MonthViewProps<T> = {
54
62
  */
55
63
  maxVisibleEventCount?: number;
56
64
  weekStartsOn: WeekStartsOn;
65
+ /** Weekday header label width: `narrow` ("M"), `short` ("Mon", default), or `long` ("Monday"). */
66
+ weekdayFormat?: WeekdayFormat;
57
67
  locale?: Locale;
58
68
  /** Sort each day's events by start time before slicing. Default true. */
59
69
  sortedMonthView?: boolean;
@@ -95,6 +105,12 @@ export type MonthViewProps<T> = {
95
105
  /** Per-date style merged onto the day cell. */
96
106
  calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
97
107
  renderEvent: RenderEvent<T>;
108
+ /**
109
+ * Override the screen-reader label for each event chip. Receives the event and a
110
+ * `{ mode: "month", isAllDay, ampm: false }` context; return the full text to
111
+ * announce. Defaults to the built-in title-and-time label.
112
+ */
113
+ eventAccessibilityLabel?: EventAccessibilityLabeler<T>;
98
114
  keyExtractor: EventKeyExtractor<T>;
99
115
  onPressDay?: (date: Date) => void;
100
116
  onLongPressDay?: (date: Date) => void;
@@ -113,6 +129,7 @@ function MonthViewInner<T>({
113
129
  events,
114
130
  maxVisibleEventCount,
115
131
  weekStartsOn,
132
+ weekdayFormat = "short",
116
133
  locale,
117
134
  sortedMonthView = true,
118
135
  moreLabel = "{moreCount} More",
@@ -131,6 +148,7 @@ function MonthViewInner<T>({
131
148
  isDateDisabled: isDateDisabledProp,
132
149
  calendarCellStyle,
133
150
  renderEvent,
151
+ eventAccessibilityLabel,
134
152
  keyExtractor,
135
153
  onPressDay,
136
154
  onLongPressDay,
@@ -150,7 +168,11 @@ function MonthViewInner<T>({
150
168
  const minDate = minDateProp ?? selection.minDate;
151
169
  const maxDate = maxDateProp ?? selection.maxDate;
152
170
  const isDateDisabled = isDateDisabledProp ?? selection.isDateDisabled;
153
- const RenderEventComponent = renderEvent;
171
+ // Month cells never show a time, so the override context reports 24h (ampm:false).
172
+ const RenderEventComponent = useMemo(
173
+ () => withEventAccessibilityLabel(renderEvent, eventAccessibilityLabel, false),
174
+ [renderEvent, eventAccessibilityLabel],
175
+ );
154
176
  // Measured grid height, used to auto-fit the event chips per cell.
155
177
  const [gridHeight, setGridHeight] = useState(0);
156
178
  // Web-only hover highlight on the day badge (mouse pointers); stays null on
@@ -225,6 +247,7 @@ function MonthViewInner<T>({
225
247
  },
226
248
  // No weekend tint on blank placeholders, so the shading doesn't bleed
227
249
  // into the empty cells of non-existent days.
250
+ theme.containers.dayCell,
228
251
  ]}
229
252
  />
230
253
  );
@@ -291,6 +314,7 @@ function MonthViewInner<T>({
291
314
  },
292
315
  isWeekend(day) && { backgroundColor: theme.colors.weekendBackground },
293
316
  calendarCellStyle?.(day),
317
+ theme.containers.dayCell,
294
318
  ]}
295
319
  // In the picker, don't dim the whole cell on press: the tap should show on
296
320
  // the badge (the circle) only, not the cell background. `onPressIn/Out` drive
@@ -382,6 +406,7 @@ function MonthViewInner<T>({
382
406
  },
383
407
  // Tap feedback lives on the badge, not the cell (picker only).
384
408
  !showGrid && pressedKey === dayKey && { opacity: 0.2 },
409
+ theme.containers.dayBadge,
385
410
  ]}
386
411
  >
387
412
  <Text style={[theme.text.dateCell, { color: dateColor }]} allowFontScaling={false}>
@@ -390,7 +415,10 @@ function MonthViewInner<T>({
390
415
  </View>
391
416
  )}
392
417
  {dayEvents.slice(0, visibleCount).map((event, index) => (
393
- <View key={keyExtractor(event, index)} style={styles.monthEvent}>
418
+ <View
419
+ key={keyExtractor(event, index)}
420
+ style={[styles.monthEvent, theme.containers.monthEvent]}
421
+ >
394
422
  <RenderEventComponent
395
423
  event={event}
396
424
  mode="month"
@@ -427,26 +455,29 @@ function MonthViewInner<T>({
427
455
  return (
428
456
  <View style={styles.root}>
429
457
  {showTitle ? (
430
- <Text style={[styles.title, { color: theme.colors.text }]} allowFontScaling={false}>
458
+ <Text
459
+ style={[styles.title, theme.text.monthTitle, { color: theme.colors.text }]}
460
+ allowFontScaling={false}
461
+ >
431
462
  {format(date, "MMMM yyyy", locale ? { locale } : undefined)}
432
463
  </Text>
433
464
  ) : null}
434
465
  {showWeekdays ? (
435
- <View style={styles.weekdayHeader}>
466
+ <View style={[styles.weekdayHeader, theme.containers.weekdayHeader]}>
436
467
  {weekdayLabels.map((day) => (
437
468
  <Text
438
469
  key={day.toISOString()}
439
470
  style={[theme.text.weekday, styles.weekdayLabel, { color: theme.colors.textMuted }]}
440
471
  allowFontScaling={false}
441
472
  >
442
- {format(day, "EEE", { locale })}
473
+ {format(day, weekdayFormatToken(weekdayFormat), { locale })}
443
474
  </Text>
444
475
  ))}
445
476
  </View>
446
477
  ) : null}
447
478
  <View style={styles.container} onLayout={handleLayout}>
448
479
  {weeks.map((week) => (
449
- <View style={styles.weekRow} key={week[0].toISOString()}>
480
+ <View style={[styles.weekRow, theme.containers.weekRow]} key={week[0].toISOString()}>
450
481
  {week.map((day) => renderDay(day))}
451
482
  </View>
452
483
  ))}
@@ -477,10 +508,8 @@ const styles = StyleSheet.create({
477
508
  root: {
478
509
  flex: 1,
479
510
  },
480
- // Matches the dom MonthView title: "MMMM yyyy" above the grid.
511
+ // Layout only; the font is themeable via `theme.text.monthTitle`.
481
512
  title: {
482
- fontSize: 17,
483
- fontWeight: "700",
484
513
  paddingTop: 10,
485
514
  paddingHorizontal: 14,
486
515
  paddingBottom: 6,
@@ -0,0 +1,441 @@
1
+ import type { ComponentType, ReactElement } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef } from "react";
3
+ import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
4
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
5
+ import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
6
+ import {
7
+ type CalendarEvent,
8
+ eventTimeLabel,
9
+ formatHour,
10
+ layoutDayEvents,
11
+ type PositionedEvent,
12
+ resolveDraggedBounds,
13
+ snapDeltaMinutes,
14
+ } from "@super-calendar/core";
15
+ import { useCalendarTheme } from "../theme";
16
+
17
+ // Native long-press duration (ms) before a bar is picked up to move.
18
+ const MOVE_ACTIVATE_MS = 250;
19
+ const MINUTES_PER_HOUR = 60;
20
+
21
+ /** A schedulable lane (room, person, machine) events are grouped under. */
22
+ export interface Resource {
23
+ /** Stable id events reference via their `resourceId`. */
24
+ id: string;
25
+ /** Row label; falls back to the id. */
26
+ title?: string;
27
+ }
28
+
29
+ /** Props passed to a custom resource-timeline event renderer. */
30
+ export interface ResourceEventArgs<T = unknown> {
31
+ event: CalendarEvent<T>;
32
+ /** Pixel width of the event bar. */
33
+ width: number;
34
+ onPress: () => void;
35
+ }
36
+
37
+ /** Props for {@link ResourceTimeline}. */
38
+ export interface ResourceTimelineProps<T = unknown> {
39
+ /** The day to lay out along the horizontal axis. */
40
+ date: Date;
41
+ /** The rows, top to bottom. */
42
+ resources: Resource[];
43
+ /** Events; each is placed in the row named by `resourceId(event)`. */
44
+ events: CalendarEvent<T>[];
45
+ /** Read an event's resource id. Default: `event.resourceId`. */
46
+ resourceId?: (event: CalendarEvent<T>) => string | undefined;
47
+ /** First hour shown (default 0). */
48
+ startHour?: number;
49
+ /** Last hour shown, exclusive (default 24). */
50
+ endHour?: number;
51
+ /** Pixels per hour along the axis (default 80). */
52
+ hourWidth?: number;
53
+ /** Height of each resource row in px (default 56). */
54
+ rowHeight?: number;
55
+ /** Width of the left resource-label column in px (default 140). */
56
+ labelWidth?: number;
57
+ /** 12-hour AM/PM axis labels (default false). */
58
+ ampm?: boolean;
59
+ /** Custom event renderer; falls back to the built-in bar. */
60
+ renderEvent?: ComponentType<ResourceEventArgs<T>>;
61
+ /** Tap an event. */
62
+ onPressEvent?: (event: CalendarEvent<T>) => void;
63
+ /**
64
+ * Enables drag-to-move and edge-resize along the time axis: long-press a bar to
65
+ * move it, or drag its right edge to resize. Called with the proposed new
66
+ * start/end; return `false` to reject the drop (it snaps back).
67
+ */
68
+ onDragEvent?: (event: CalendarEvent<T>, start: Date, end: Date) => void | boolean;
69
+ /** Snap dragged events to this many minutes (default 15). */
70
+ dragStepMinutes?: number;
71
+ }
72
+
73
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
74
+
75
+ function DefaultBar<T>({ event, width }: ResourceEventArgs<T>): ReactElement {
76
+ const theme = useCalendarTheme();
77
+ const time = eventTimeLabel({
78
+ mode: "day",
79
+ isAllDay: false,
80
+ start: event.start,
81
+ end: event.end,
82
+ ampm: false,
83
+ showTime: true,
84
+ });
85
+ return (
86
+ <View
87
+ style={[
88
+ styles.bar,
89
+ { backgroundColor: theme.colors.eventBackground },
90
+ theme.containers.timeGridEvent,
91
+ ]}
92
+ >
93
+ <Text
94
+ numberOfLines={1}
95
+ style={[theme.text.eventTitle, { color: theme.colors.eventText }]}
96
+ allowFontScaling={false}
97
+ >
98
+ {event.title}
99
+ </Text>
100
+ {time && width > 56 ? (
101
+ <Text
102
+ numberOfLines={1}
103
+ style={[styles.barTime, { color: theme.colors.eventText }]}
104
+ allowFontScaling={false}
105
+ >
106
+ {time}
107
+ </Text>
108
+ ) : null}
109
+ </View>
110
+ );
111
+ }
112
+
113
+ type ResourceBarProps<T> = {
114
+ pe: PositionedEvent<T>;
115
+ left: number;
116
+ width: number;
117
+ laneHeight: number;
118
+ hourWidth: number;
119
+ snapMinutes: number;
120
+ Renderer: ComponentType<ResourceEventArgs<T>>;
121
+ onPress: () => void;
122
+ onDragEvent: (event: CalendarEvent<T>, start: Date, end: Date) => void | boolean;
123
+ theme: ReturnType<typeof useCalendarTheme>;
124
+ };
125
+
126
+ // A bar with drag-to-move (long-press) and right-edge resize along the time axis.
127
+ // The pure snap/commit math is shared with the time grid (`snapDeltaMinutes`,
128
+ // `resolveDraggedBounds`); this only wires the horizontal gestures.
129
+ function ResourceBar<T>({
130
+ pe,
131
+ left,
132
+ width,
133
+ laneHeight,
134
+ hourWidth,
135
+ snapMinutes,
136
+ Renderer,
137
+ onPress,
138
+ onDragEvent,
139
+ theme,
140
+ }: ResourceBarProps<T>): ReactElement {
141
+ const moveX = useSharedValue(0);
142
+ const resizeW = useSharedValue(0);
143
+ const latest = useRef({ event: pe.event, onDragEvent });
144
+ latest.current = { event: pe.event, onDragEvent };
145
+ const draggable = !(pe.event as { disabled?: boolean }).disabled;
146
+
147
+ // Clear the live preview once the committed change re-renders the bar.
148
+ useEffect(() => {
149
+ moveX.value = 0;
150
+ resizeW.value = 0;
151
+ }, [pe.startHours, pe.durationHours, moveX, resizeW]);
152
+
153
+ const snapBack = useCallback(() => {
154
+ moveX.value = 0;
155
+ resizeW.value = 0;
156
+ }, [moveX, resizeW]);
157
+
158
+ const commit = useCallback(
159
+ (deltaStartMin: number, deltaEndMin: number) => {
160
+ const { event, onDragEvent: handler } = latest.current;
161
+ const next = resolveDraggedBounds(
162
+ event.start,
163
+ event.end,
164
+ deltaStartMin,
165
+ deltaEndMin,
166
+ snapMinutes,
167
+ );
168
+ if (!next) {
169
+ snapBack();
170
+ return;
171
+ }
172
+ if (handler(event, next.start, next.end) === false) snapBack();
173
+ },
174
+ [snapMinutes, snapBack],
175
+ );
176
+
177
+ const barStyle = useAnimatedStyle(
178
+ () => ({ transform: [{ translateX: moveX.value }], width: Math.max(width + resizeW.value, 2) }),
179
+ [width],
180
+ );
181
+
182
+ const moveGesture = useMemo(
183
+ () =>
184
+ Gesture.Pan()
185
+ .enabled(draggable)
186
+ .activateAfterLongPress(MOVE_ACTIVATE_MS)
187
+ .onUpdate((e) => {
188
+ moveX.value = e.translationX;
189
+ })
190
+ .onEnd((e) => {
191
+ const delta = snapDeltaMinutes(e.translationX, hourWidth, snapMinutes);
192
+ if (delta === 0) {
193
+ moveX.value = 0;
194
+ return;
195
+ }
196
+ moveX.value = (delta / MINUTES_PER_HOUR) * hourWidth;
197
+ runOnJS(commit)(delta, delta);
198
+ }),
199
+ [draggable, hourWidth, snapMinutes, moveX, commit],
200
+ );
201
+
202
+ const resizeGesture = useMemo(
203
+ () =>
204
+ Gesture.Pan()
205
+ .enabled(draggable)
206
+ .onUpdate((e) => {
207
+ resizeW.value = e.translationX;
208
+ })
209
+ .onEnd((e) => {
210
+ const delta = snapDeltaMinutes(e.translationX, hourWidth, snapMinutes);
211
+ if (delta === 0) {
212
+ resizeW.value = 0;
213
+ return;
214
+ }
215
+ resizeW.value = (delta / MINUTES_PER_HOUR) * hourWidth;
216
+ runOnJS(commit)(0, delta);
217
+ }),
218
+ [draggable, hourWidth, snapMinutes, resizeW, commit],
219
+ );
220
+
221
+ return (
222
+ <Animated.View
223
+ style={[
224
+ { position: "absolute", left, top: pe.column * laneHeight, height: laneHeight, padding: 1 },
225
+ barStyle,
226
+ ]}
227
+ >
228
+ <GestureDetector gesture={moveGesture}>
229
+ <Pressable
230
+ onPress={onPress}
231
+ accessibilityRole="button"
232
+ accessibilityLabel={pe.event.title}
233
+ style={styles.fill}
234
+ >
235
+ <Renderer event={pe.event} width={width} onPress={onPress} />
236
+ </Pressable>
237
+ </GestureDetector>
238
+ <GestureDetector gesture={resizeGesture}>
239
+ <View
240
+ style={[styles.resizeGrip, { backgroundColor: theme.colors.eventText }]}
241
+ accessibilityRole="adjustable"
242
+ accessibilityLabel={`Resize ${pe.event.title}`}
243
+ />
244
+ </GestureDetector>
245
+ </Animated.View>
246
+ );
247
+ }
248
+
249
+ /**
250
+ * A horizontal resource timeline: rows are resources (rooms, people, machines)
251
+ * and the x-axis is one day's hours. Events sit in their resource's row, and
252
+ * overlapping events in the same row stack into sub-lanes (via the shared
253
+ * `layoutDayEvents`). The grid scrolls horizontally when the axis is wider than
254
+ * the screen. Pass `onDragEvent` to enable long-press drag-to-move and edge
255
+ * resize along the time axis. Read the theme from context — wrap in
256
+ * `CalendarThemeProvider` (or render inside `<Calendar>`) to restyle.
257
+ *
258
+ * @example
259
+ * ```tsx
260
+ * <ResourceTimeline
261
+ * date={new Date()}
262
+ * resources={[{ id: "a", title: "Room A" }, { id: "b", title: "Room B" }]}
263
+ * events={events} // each event carries a `resourceId`
264
+ * />
265
+ * ```
266
+ */
267
+ export function ResourceTimeline<T = unknown>({
268
+ date,
269
+ resources,
270
+ events,
271
+ resourceId = (event) => (event as { resourceId?: string }).resourceId,
272
+ startHour = 0,
273
+ endHour = 24,
274
+ hourWidth = 80,
275
+ rowHeight = 56,
276
+ labelWidth = 140,
277
+ ampm = false,
278
+ renderEvent,
279
+ onPressEvent,
280
+ onDragEvent,
281
+ dragStepMinutes = 15,
282
+ }: ResourceTimelineProps<T>): ReactElement {
283
+ const theme = useCalendarTheme();
284
+ const Renderer = renderEvent ?? DefaultBar;
285
+
286
+ const hours = useMemo(
287
+ () => Array.from({ length: Math.max(0, endHour - startHour) }, (_, i) => startHour + i),
288
+ [startHour, endHour],
289
+ );
290
+ const trackWidth = (endHour - startHour) * hourWidth;
291
+
292
+ // Group the overlap-resolved events by resource, once.
293
+ const byResource = useMemo(() => {
294
+ const map = new Map<string, ReturnType<typeof layoutDayEvents<T>>>();
295
+ for (const resource of resources) {
296
+ const own = events.filter((event) => resourceId(event) === resource.id);
297
+ map.set(resource.id, layoutDayEvents(own, date));
298
+ }
299
+ return map;
300
+ }, [resources, events, resourceId, date]);
301
+
302
+ return (
303
+ <ScrollView horizontal showsHorizontalScrollIndicator style={styles.root}>
304
+ <View style={{ width: labelWidth + trackWidth }}>
305
+ {/* Header: corner + hour axis */}
306
+ <View style={[styles.header, { borderBottomColor: theme.colors.gridLine }]}>
307
+ <View style={{ width: labelWidth }} />
308
+ <View style={{ width: trackWidth, height: 24 }}>
309
+ {hours.map((h) => (
310
+ <Text
311
+ key={h}
312
+ allowFontScaling={false}
313
+ style={[
314
+ styles.hourTick,
315
+ { left: (h - startHour) * hourWidth, color: theme.colors.textMuted },
316
+ ]}
317
+ >
318
+ {formatHour(h, { ampm })}
319
+ </Text>
320
+ ))}
321
+ </View>
322
+ </View>
323
+
324
+ {/* Resource rows */}
325
+ {resources.map((resource) => {
326
+ const positioned = byResource.get(resource.id) ?? [];
327
+ return (
328
+ <View
329
+ key={resource.id}
330
+ style={[
331
+ styles.row,
332
+ { height: rowHeight, borderBottomColor: theme.colors.gridLine },
333
+ theme.containers.resourceRow,
334
+ ]}
335
+ >
336
+ <View
337
+ style={[
338
+ styles.label,
339
+ { width: labelWidth, borderRightColor: theme.colors.gridLine },
340
+ theme.containers.resourceLabel,
341
+ ]}
342
+ >
343
+ <Text
344
+ numberOfLines={1}
345
+ style={{ color: theme.colors.text }}
346
+ allowFontScaling={false}
347
+ >
348
+ {resource.title ?? resource.id}
349
+ </Text>
350
+ </View>
351
+ <View style={{ width: trackWidth }}>
352
+ {/* Hour grid lines */}
353
+ {hours.slice(1).map((h) => (
354
+ <View
355
+ key={h}
356
+ pointerEvents="none"
357
+ style={[
358
+ styles.gridLine,
359
+ { left: (h - startHour) * hourWidth, backgroundColor: theme.colors.gridLine },
360
+ ]}
361
+ />
362
+ ))}
363
+ {positioned.map((pe, idx) => {
364
+ const left = clamp(pe.startHours - startHour, 0, endHour - startHour) * hourWidth;
365
+ const right =
366
+ clamp(pe.startHours + pe.durationHours - startHour, 0, endHour - startHour) *
367
+ hourWidth;
368
+ const width = Math.max(right - left, 2);
369
+ // Overlapping events share the row height as stacked sub-lanes.
370
+ const laneHeight = rowHeight / pe.columns;
371
+ const onPress = () => onPressEvent?.(pe.event);
372
+ if (onDragEvent) {
373
+ return (
374
+ <ResourceBar
375
+ key={idx}
376
+ pe={pe}
377
+ left={left}
378
+ width={width}
379
+ laneHeight={laneHeight}
380
+ hourWidth={hourWidth}
381
+ snapMinutes={dragStepMinutes}
382
+ Renderer={Renderer}
383
+ onPress={onPress}
384
+ onDragEvent={onDragEvent}
385
+ theme={theme}
386
+ />
387
+ );
388
+ }
389
+ return (
390
+ <Pressable
391
+ key={idx}
392
+ onPress={onPress}
393
+ accessibilityRole="button"
394
+ accessibilityLabel={pe.event.title}
395
+ style={{
396
+ position: "absolute",
397
+ left,
398
+ width,
399
+ top: pe.column * laneHeight,
400
+ height: laneHeight,
401
+ padding: 1,
402
+ }}
403
+ >
404
+ <Renderer event={pe.event} width={width} onPress={onPress} />
405
+ </Pressable>
406
+ );
407
+ })}
408
+ </View>
409
+ </View>
410
+ );
411
+ })}
412
+ </View>
413
+ </ScrollView>
414
+ );
415
+ }
416
+
417
+ const styles = StyleSheet.create({
418
+ root: { flexGrow: 0 },
419
+ header: { flexDirection: "row", borderBottomWidth: StyleSheet.hairlineWidth },
420
+ hourTick: { position: "absolute", top: 4, fontSize: 10 },
421
+ row: { flexDirection: "row", borderBottomWidth: StyleSheet.hairlineWidth },
422
+ label: {
423
+ justifyContent: "center",
424
+ paddingHorizontal: 10,
425
+ borderRightWidth: StyleSheet.hairlineWidth,
426
+ },
427
+ gridLine: { position: "absolute", top: 0, bottom: 0, width: StyleSheet.hairlineWidth },
428
+ bar: { flex: 1, borderRadius: 6, paddingHorizontal: 6, paddingVertical: 2, overflow: "hidden" },
429
+ barTime: { fontSize: 11, opacity: 0.75 },
430
+ fill: { flex: 1 },
431
+ // A slim right-edge handle for resizing along the time axis.
432
+ resizeGrip: {
433
+ position: "absolute",
434
+ right: 1,
435
+ top: "30%",
436
+ bottom: "30%",
437
+ width: 4,
438
+ borderRadius: 2,
439
+ opacity: 0.5,
440
+ },
441
+ });