@super-calendar/native 2.3.2 → 2.4.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.
@@ -1,6 +1,13 @@
1
1
  import type { ComponentType, ReactElement } from "react";
2
2
  import { useCallback, useEffect, useMemo, useRef } from "react";
3
- import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
3
+ import {
4
+ type AccessibilityActionEvent,
5
+ Pressable,
6
+ ScrollView,
7
+ StyleSheet,
8
+ Text,
9
+ View,
10
+ } from "react-native";
4
11
  import { Gesture, GestureDetector } from "react-native-gesture-handler";
5
12
  import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
6
13
  import {
@@ -29,16 +36,28 @@ export interface Resource {
29
36
  /** Props passed to a custom resource-timeline event renderer. */
30
37
  export interface ResourceEventArgs<T = unknown> {
31
38
  event: CalendarEvent<T>;
32
- /** Pixel width of the event bar. */
39
+ /**
40
+ * Pixel width of the event bar. In the vertical orientation the columns flex
41
+ * to the container, so this is 0 there; size against `height` instead.
42
+ */
33
43
  width: number;
44
+ /** Pixel height of the event bar; set in the vertical orientation. */
45
+ height?: number;
34
46
  onPress: () => void;
35
47
  }
36
48
 
37
49
  /** Props for {@link ResourceTimeline}. */
38
50
  export interface ResourceTimelineProps<T = unknown> {
39
- /** The day to lay out along the horizontal axis. */
51
+ /** The day to lay out along the time axis. */
40
52
  date: Date;
41
- /** The rows, top to bottom. */
53
+ /**
54
+ * Lay the day out along the horizontal axis with resources as rows (the
55
+ * default), or down the vertical axis with resources as columns, like the
56
+ * time grid. Vertical reads better on narrow screens: the columns share the
57
+ * width instead of the axis scrolling sideways.
58
+ */
59
+ orientation?: "horizontal" | "vertical";
60
+ /** The resource lanes: rows when horizontal, columns when vertical. */
42
61
  resources: Resource[];
43
62
  /** Events; each is placed in the row named by `resourceId(event)`. */
44
63
  events: CalendarEvent<T>[];
@@ -48,11 +67,13 @@ export interface ResourceTimelineProps<T = unknown> {
48
67
  startHour?: number;
49
68
  /** Last hour shown, exclusive (default 24). */
50
69
  endHour?: number;
51
- /** Pixels per hour along the axis (default 80). */
70
+ /** Pixels per hour along the horizontal axis (default 80). Horizontal only. */
52
71
  hourWidth?: number;
53
- /** Height of each resource row in px (default 56). */
72
+ /** Pixels per hour down the vertical axis (default 48). Vertical only. */
73
+ hourHeight?: number;
74
+ /** Height of each resource row in px (default 56). Horizontal only. */
54
75
  rowHeight?: number;
55
- /** Width of the left resource-label column in px (default 140). */
76
+ /** Width of the left resource-label column in px (default 140). Horizontal only. */
56
77
  labelWidth?: number;
57
78
  /** 12-hour AM/PM axis labels (default false). */
58
79
  ampm?: boolean;
@@ -72,8 +93,13 @@ export interface ResourceTimelineProps<T = unknown> {
72
93
 
73
94
  const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
74
95
 
75
- function DefaultBar<T>({ event, width }: ResourceEventArgs<T>): ReactElement {
96
+ // Hour-gutter width in the vertical orientation, matching the time grid's axis.
97
+ const GUTTER_WIDTH = 56;
98
+
99
+ function DefaultBar<T>({ event, width, height }: ResourceEventArgs<T>): ReactElement {
76
100
  const theme = useCalendarTheme();
101
+ // Show the time line only when the bar has room for it along its long axis.
102
+ const showTime = height != null ? height > 34 : width > 56;
77
103
  const time = eventTimeLabel({
78
104
  mode: "day",
79
105
  isAllDay: false,
@@ -97,7 +123,7 @@ function DefaultBar<T>({ event, width }: ResourceEventArgs<T>): ReactElement {
97
123
  >
98
124
  {event.title}
99
125
  </Text>
100
- {time && width > 56 ? (
126
+ {time && showTime ? (
101
127
  <Text
102
128
  numberOfLines={1}
103
129
  style={[styles.barTime, { color: theme.colors.eventText }]}
@@ -112,10 +138,19 @@ function DefaultBar<T>({ event, width }: ResourceEventArgs<T>): ReactElement {
112
138
 
113
139
  type ResourceBarProps<T> = {
114
140
  pe: PositionedEvent<T>;
115
- left: number;
116
- width: number;
117
- laneHeight: number;
118
- hourWidth: number;
141
+ /** Time flows down instead of across; gestures and resize follow. */
142
+ vertical: boolean;
143
+ /** Pixels per hour along the time axis. */
144
+ hourSize: number;
145
+ /** Cross-axis lane offset: px when horizontal, a percent string when vertical. */
146
+ left: number | `${number}%`;
147
+ top: number;
148
+ /** Along-axis size when horizontal (px); lane width (percent) when vertical. */
149
+ width: number | `${number}%`;
150
+ /** Lane height when horizontal; along-axis size when vertical (px). */
151
+ height: number;
152
+ /** What the custom renderer is told about the bar's box. */
153
+ rendererSize: { width: number; height?: number };
119
154
  snapMinutes: number;
120
155
  Renderer: ComponentType<ResourceEventArgs<T>>;
121
156
  onPress: () => void;
@@ -123,15 +158,19 @@ type ResourceBarProps<T> = {
123
158
  theme: ReturnType<typeof useCalendarTheme>;
124
159
  };
125
160
 
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.
161
+ // A bar with drag-to-move (long-press) and edge resize along the time axis
162
+ // (right edge when horizontal, bottom edge when vertical). The pure snap/commit
163
+ // math is shared with the time grid (`snapDeltaMinutes`, `resolveDraggedBounds`);
164
+ // this only wires the gestures.
129
165
  function ResourceBar<T>({
130
166
  pe,
167
+ vertical,
168
+ hourSize,
131
169
  left,
170
+ top,
132
171
  width,
133
- laneHeight,
134
- hourWidth,
172
+ height,
173
+ rendererSize,
135
174
  snapMinutes,
136
175
  Renderer,
137
176
  onPress,
@@ -174,9 +213,50 @@ function ResourceBar<T>({
174
213
  [snapMinutes, snapBack],
175
214
  );
176
215
 
216
+ // Dragging is gesture-only, so mirror move AND resize as screen-reader actions on
217
+ // the same `commit` path. They all live on the bar's accessible Pressable (the
218
+ // resize grip is a non-focusable visual/gesture affordance, so an `adjustable`
219
+ // role there would never receive focus on a real device).
220
+ const unit = (n: number) => `${n} minute${n === 1 ? "" : "s"}`;
221
+ const barActions = draggable
222
+ ? [
223
+ { name: "move-later", label: `Move ${unit(snapMinutes)} later` },
224
+ { name: "move-earlier", label: `Move ${unit(snapMinutes)} earlier` },
225
+ { name: "extend", label: `Extend by ${unit(snapMinutes)}` },
226
+ { name: "shrink", label: `Shorten by ${unit(snapMinutes)}` },
227
+ ]
228
+ : undefined;
229
+ const onBarAction = draggable
230
+ ? (e: AccessibilityActionEvent) => {
231
+ switch (e.nativeEvent.actionName) {
232
+ case "move-later":
233
+ commit(snapMinutes, snapMinutes);
234
+ break;
235
+ case "move-earlier":
236
+ commit(-snapMinutes, -snapMinutes);
237
+ break;
238
+ case "extend":
239
+ commit(0, snapMinutes);
240
+ break;
241
+ case "shrink":
242
+ commit(0, -snapMinutes);
243
+ break;
244
+ }
245
+ }
246
+ : undefined;
247
+
177
248
  const barStyle = useAnimatedStyle(
178
- () => ({ transform: [{ translateX: moveX.value }], width: Math.max(width + resizeW.value, 2) }),
179
- [width],
249
+ () =>
250
+ vertical
251
+ ? {
252
+ transform: [{ translateY: moveX.value }],
253
+ height: Math.max(height + resizeW.value, 2),
254
+ }
255
+ : {
256
+ transform: [{ translateX: moveX.value }],
257
+ width: Math.max((width as number) + resizeW.value, 2),
258
+ },
259
+ [vertical, width, height],
180
260
  );
181
261
 
182
262
  const moveGesture = useMemo(
@@ -185,18 +265,19 @@ function ResourceBar<T>({
185
265
  .enabled(draggable)
186
266
  .activateAfterLongPress(MOVE_ACTIVATE_MS)
187
267
  .onUpdate((e) => {
188
- moveX.value = e.translationX;
268
+ moveX.value = vertical ? e.translationY : e.translationX;
189
269
  })
190
270
  .onEnd((e) => {
191
- const delta = snapDeltaMinutes(e.translationX, hourWidth, snapMinutes);
271
+ const translation = vertical ? e.translationY : e.translationX;
272
+ const delta = snapDeltaMinutes(translation, hourSize, snapMinutes);
192
273
  if (delta === 0) {
193
274
  moveX.value = 0;
194
275
  return;
195
276
  }
196
- moveX.value = (delta / MINUTES_PER_HOUR) * hourWidth;
277
+ moveX.value = (delta / MINUTES_PER_HOUR) * hourSize;
197
278
  runOnJS(commit)(delta, delta);
198
279
  }),
199
- [draggable, hourWidth, snapMinutes, moveX, commit],
280
+ [draggable, vertical, hourSize, snapMinutes, moveX, commit],
200
281
  );
201
282
 
202
283
  const resizeGesture = useMemo(
@@ -204,24 +285,26 @@ function ResourceBar<T>({
204
285
  Gesture.Pan()
205
286
  .enabled(draggable)
206
287
  .onUpdate((e) => {
207
- resizeW.value = e.translationX;
288
+ resizeW.value = vertical ? e.translationY : e.translationX;
208
289
  })
209
290
  .onEnd((e) => {
210
- const delta = snapDeltaMinutes(e.translationX, hourWidth, snapMinutes);
291
+ const translation = vertical ? e.translationY : e.translationX;
292
+ const delta = snapDeltaMinutes(translation, hourSize, snapMinutes);
211
293
  if (delta === 0) {
212
294
  resizeW.value = 0;
213
295
  return;
214
296
  }
215
- resizeW.value = (delta / MINUTES_PER_HOUR) * hourWidth;
297
+ resizeW.value = (delta / MINUTES_PER_HOUR) * hourSize;
216
298
  runOnJS(commit)(0, delta);
217
299
  }),
218
- [draggable, hourWidth, snapMinutes, resizeW, commit],
300
+ [draggable, vertical, hourSize, snapMinutes, resizeW, commit],
219
301
  );
220
302
 
221
303
  return (
222
304
  <Animated.View
223
305
  style={[
224
- { position: "absolute", left, top: pe.column * laneHeight, height: laneHeight, padding: 1 },
306
+ { position: "absolute", left, top, padding: 1 },
307
+ vertical ? { width } : { height },
225
308
  barStyle,
226
309
  ]}
227
310
  >
@@ -230,16 +313,29 @@ function ResourceBar<T>({
230
313
  onPress={onPress}
231
314
  accessibilityRole="button"
232
315
  accessibilityLabel={pe.event.title}
316
+ accessibilityActions={barActions}
317
+ onAccessibilityAction={onBarAction}
233
318
  style={styles.fill}
234
319
  >
235
- <Renderer event={pe.event} width={width} onPress={onPress} />
320
+ <Renderer
321
+ event={pe.event}
322
+ width={rendererSize.width}
323
+ height={rendererSize.height}
324
+ onPress={onPress}
325
+ />
236
326
  </Pressable>
237
327
  </GestureDetector>
238
328
  <GestureDetector gesture={resizeGesture}>
329
+ {/* Visual + gesture resize affordance only; screen readers use the bar's
330
+ extend/shorten actions above (a non-focusable View can't hold them). */}
239
331
  <View
240
- style={[styles.resizeGrip, { backgroundColor: theme.colors.eventText }]}
241
- accessibilityRole="adjustable"
242
- accessibilityLabel={`Resize ${pe.event.title}`}
332
+ testID="resource-resize-grip"
333
+ style={[
334
+ vertical ? styles.resizeGripBottom : styles.resizeGrip,
335
+ { backgroundColor: theme.colors.eventText },
336
+ ]}
337
+ accessibilityElementsHidden
338
+ importantForAccessibility="no"
243
339
  />
244
340
  </GestureDetector>
245
341
  </Animated.View>
@@ -266,12 +362,14 @@ function ResourceBar<T>({
266
362
  */
267
363
  export function ResourceTimeline<T = unknown>({
268
364
  date,
365
+ orientation = "horizontal",
269
366
  resources,
270
367
  events,
271
368
  resourceId = (event) => (event as { resourceId?: string }).resourceId,
272
369
  startHour = 0,
273
370
  endHour = 24,
274
371
  hourWidth = 80,
372
+ hourHeight = 48,
275
373
  rowHeight = 56,
276
374
  labelWidth = 140,
277
375
  ampm = false,
@@ -299,6 +397,123 @@ export function ResourceTimeline<T = unknown>({
299
397
  return map;
300
398
  }, [resources, events, resourceId, date]);
301
399
 
400
+ if (orientation === "vertical") {
401
+ // Time flows down like the time grid: hour gutter on the left and one
402
+ // flexed column per resource, so narrow screens share the width instead of
403
+ // scrolling sideways. The resource headers stay fixed above the scroll.
404
+ const trackHeight = (endHour - startHour) * hourHeight;
405
+ return (
406
+ <View style={styles.vroot}>
407
+ <View style={[styles.header, { borderBottomColor: theme.colors.gridLine }]}>
408
+ <View style={{ width: GUTTER_WIDTH }} />
409
+ {resources.map((resource) => (
410
+ <View key={resource.id} style={[styles.vheaderCell, theme.containers.resourceLabel]}>
411
+ <Text
412
+ numberOfLines={1}
413
+ style={[styles.vheaderText, { color: theme.colors.text }]}
414
+ allowFontScaling={false}
415
+ >
416
+ {resource.title ?? resource.id}
417
+ </Text>
418
+ </View>
419
+ ))}
420
+ </View>
421
+ <ScrollView showsVerticalScrollIndicator>
422
+ <View style={[styles.vbody, { height: trackHeight }]}>
423
+ <View style={{ width: GUTTER_WIDTH }}>
424
+ {hours.map((h) => (
425
+ <Text
426
+ key={h}
427
+ allowFontScaling={false}
428
+ style={[
429
+ styles.vhourLabel,
430
+ {
431
+ top: Math.max(0, (h - startHour) * hourHeight - 6),
432
+ color: theme.colors.textMuted,
433
+ },
434
+ ]}
435
+ >
436
+ {formatHour(h, { ampm })}
437
+ </Text>
438
+ ))}
439
+ </View>
440
+ {resources.map((resource) => {
441
+ const positioned = byResource.get(resource.id) ?? [];
442
+ return (
443
+ <View
444
+ key={resource.id}
445
+ style={[
446
+ styles.vcolumn,
447
+ { borderLeftColor: theme.colors.gridLine },
448
+ theme.containers.resourceRow,
449
+ ]}
450
+ >
451
+ {hours.slice(1).map((h) => (
452
+ <View
453
+ key={h}
454
+ pointerEvents="none"
455
+ style={[
456
+ styles.vgridLine,
457
+ {
458
+ top: (h - startHour) * hourHeight,
459
+ backgroundColor: theme.colors.gridLine,
460
+ },
461
+ ]}
462
+ />
463
+ ))}
464
+ {positioned.map((pe, idx) => {
465
+ const top =
466
+ clamp(pe.startHours - startHour, 0, endHour - startHour) * hourHeight;
467
+ const bottomPx =
468
+ clamp(pe.startHours + pe.durationHours - startHour, 0, endHour - startHour) *
469
+ hourHeight;
470
+ const height = Math.max(bottomPx - top, 2);
471
+ // Overlapping events share the column as side-by-side sub-lanes.
472
+ const lanePct = 100 / pe.columns;
473
+ const left = `${pe.column * lanePct}%` as const;
474
+ const width = `${lanePct}%` as const;
475
+ const onPress = () => onPressEvent?.(pe.event);
476
+ if (onDragEvent) {
477
+ return (
478
+ <ResourceBar
479
+ key={idx}
480
+ pe={pe}
481
+ vertical
482
+ hourSize={hourHeight}
483
+ left={left}
484
+ top={top}
485
+ width={width}
486
+ height={height}
487
+ rendererSize={{ width: 0, height }}
488
+ snapMinutes={dragStepMinutes}
489
+ Renderer={Renderer}
490
+ onPress={onPress}
491
+ onDragEvent={onDragEvent}
492
+ theme={theme}
493
+ />
494
+ );
495
+ }
496
+ return (
497
+ <Pressable
498
+ key={idx}
499
+ onPress={onPress}
500
+ accessibilityRole="button"
501
+ accessibilityLabel={pe.event.title}
502
+ style={{ position: "absolute", left, width, top, height, padding: 1 }}
503
+ >
504
+ <Renderer event={pe.event} width={0} height={height} onPress={onPress} />
505
+ </Pressable>
506
+ );
507
+ })}
508
+ </View>
509
+ );
510
+ })}
511
+ </View>
512
+ </ScrollView>
513
+ </View>
514
+ );
515
+ }
516
+
302
517
  return (
303
518
  <ScrollView horizontal showsHorizontalScrollIndicator style={styles.root}>
304
519
  <View style={{ width: labelWidth + trackWidth }}>
@@ -374,10 +589,13 @@ export function ResourceTimeline<T = unknown>({
374
589
  <ResourceBar
375
590
  key={idx}
376
591
  pe={pe}
592
+ vertical={false}
593
+ hourSize={hourWidth}
377
594
  left={left}
595
+ top={pe.column * laneHeight}
378
596
  width={width}
379
- laneHeight={laneHeight}
380
- hourWidth={hourWidth}
597
+ height={laneHeight}
598
+ rendererSize={{ width }}
381
599
  snapMinutes={dragStepMinutes}
382
600
  Renderer={Renderer}
383
601
  onPress={onPress}
@@ -438,4 +656,27 @@ const styles = StyleSheet.create({
438
656
  borderRadius: 2,
439
657
  opacity: 0.5,
440
658
  },
659
+ // Its bottom-edge counterpart in the vertical orientation.
660
+ resizeGripBottom: {
661
+ position: "absolute",
662
+ bottom: 1,
663
+ left: "30%",
664
+ right: "30%",
665
+ height: 4,
666
+ borderRadius: 2,
667
+ opacity: 0.5,
668
+ },
669
+ vroot: { flex: 1 },
670
+ vheaderCell: {
671
+ flex: 1,
672
+ paddingVertical: 6,
673
+ paddingHorizontal: 4,
674
+ alignItems: "center",
675
+ borderLeftWidth: StyleSheet.hairlineWidth,
676
+ },
677
+ vheaderText: { fontSize: 13, fontWeight: "600" },
678
+ vbody: { flexDirection: "row" },
679
+ vcolumn: { flex: 1, borderLeftWidth: StyleSheet.hairlineWidth },
680
+ vhourLabel: { position: "absolute", right: 6, fontSize: 10 },
681
+ vgridLine: { position: "absolute", left: 0, right: 0, height: StyleSheet.hairlineWidth },
441
682
  });