@lotics/ui 46.14.1 → 47.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,141 @@
1
+ import type { ReactNode } from "react";
2
+ import { View, StyleSheet, type StyleProp, type ViewStyle } from "react-native";
3
+ import { Text } from "../text";
4
+ import { colors, type ColorName } from "../colors";
5
+ import { proportionalRadius } from "../control_surface";
6
+ import { FocusRingPressable } from "../focus_ring_pressable";
7
+ import { formatTime } from "./dates";
8
+ import type { CalendarEvent } from "./types";
9
+
10
+ const DEFAULT_COLOR: ColorName = "teal";
11
+
12
+ /**
13
+ * The ground and ink one event colour resolves to.
14
+ *
15
+ * **A wash with matching ink, never a saturated fill with white on it.** The
16
+ * pairing is `Badge`'s tonal one — the palette's own light ground and dark ink
17
+ * of a single family — so a calendar reads as the same product as everything
18
+ * beside it, and a month with a dozen colours in it stays a surface you can
19
+ * look at. A `solid()` bar per event turns the grid into a paint chart, which
20
+ * is what "the colours are ugly" actually names.
21
+ *
22
+ * The dot is the one place the solid shade still belongs: it is 6px, it carries
23
+ * identity rather than area, and it needs the contrast.
24
+ */
25
+ function chipColors(name: ColorName): { ground: string; hovered: string; ink: string; dot: string } {
26
+ const hue = colors[name];
27
+ // `hovered` is the SAME ground one rung deeper, never a different colour:
28
+ // what has to change is only "this responds to me", and the chip must still
29
+ // read as the event it was a moment ago.
30
+ return { ground: hue[100], hovered: hue[200], ink: hue[900], dot: hue[500] };
31
+ }
32
+
33
+ /**
34
+ * The three ways an event is drawn.
35
+ *
36
+ * - `banner` — a bar. A thing that occupies whole DAYS: an all-day marker, a
37
+ * multi-day campaign. Reads as continuous across day boundaries.
38
+ * - `dot` — a coloured dot beside plain text. A timed event inside a month
39
+ * cell, where a filled bar per meeting turns the month into a colour chart
40
+ * and the dates stop being findable.
41
+ * - `block` — a body sized to its duration, for the time grid, where vertical
42
+ * extent carries the meaning.
43
+ *
44
+ * **None of them draws a border.** A washed body already states its own edge;
45
+ * a rule on top of it is the same edge asserted twice, on a surface that is
46
+ * already full of grid lines. (`board.tsx` states the same law for its cards.)
47
+ */
48
+ export type EventChipVariant = "banner" | "dot" | "block";
49
+
50
+ export interface EventChipProps {
51
+ event: CalendarEvent<unknown>;
52
+ variant: EventChipVariant;
53
+ onPress?: () => void;
54
+ /** Replaces the chip's contents; the press target and geometry stay ours. */
55
+ render?: (event: CalendarEvent<unknown>) => ReactNode;
56
+ /** Show the start time before the title (month cells, where there is no rail). */
57
+ showTime?: boolean;
58
+ locale?: string;
59
+ style?: StyleProp<ViewStyle>;
60
+ /** Below this the block drops its second line rather than clipping it. */
61
+ compact?: boolean;
62
+ /** The chip's rendered height, so its corners stay proportionate to it — a
63
+ * fixed radius turns a 20px lane chip into a pill and barely marks a tall
64
+ * block. */
65
+ height: number;
66
+ }
67
+
68
+ export function EventChip(props: EventChipProps) {
69
+ const { event, variant, onPress, render, showTime, locale, style, compact, height } = props;
70
+ const { ground, hovered, ink, dot } = chipColors(event.color ?? DEFAULT_COLOR);
71
+ const radius = proportionalRadius(height);
72
+
73
+ const label = showTime ? `${formatTime(event.start, locale)} ${event.title}` : event.title;
74
+
75
+ return (
76
+ <FocusRingPressable
77
+ onPress={onPress}
78
+ accessibilityRole={onPress ? "button" : undefined}
79
+ accessibilityLabel={accessibleName(event, locale)}
80
+ style={[styles.press, style]}
81
+ >
82
+ {({ hovered: over }) => {
83
+ // Nothing lights up without a handler, so a read-only calendar never
84
+ // advertises a press that does nothing.
85
+ const lit = !!onPress && over;
86
+ return render ? (
87
+ render(event)
88
+ ) : variant === "banner" ? (
89
+ <View style={[styles.banner, { backgroundColor: lit ? hovered : ground, borderRadius: radius }]}>
90
+ <Text size="xs" weight="medium" numberOfLines={1} style={{ color: ink }}>
91
+ {event.title}
92
+ </Text>
93
+ </View>
94
+ ) : variant === "dot" ? (
95
+ // The dot row has no ground of its own, so its hover is a NEUTRAL
96
+ // wash: the row is mostly plain text, and tinting it with the event's
97
+ // own hue would read as the event changing state rather than as the
98
+ // pointer being over it.
99
+ <View style={[styles.dotRow, lit ? { backgroundColor: colors.zinc[100], borderRadius: radius } : null]}>
100
+ <View style={[styles.dot, { backgroundColor: dot }]} />
101
+ <Text size="xs" numberOfLines={1} style={styles.dotLabel}>
102
+ {label}
103
+ </Text>
104
+ </View>
105
+ ) : (
106
+ <View style={[styles.block, { backgroundColor: lit ? hovered : ground, borderRadius: radius }]}>
107
+ <Text size="xs" weight="medium" numberOfLines={1} style={{ color: ink }}>
108
+ {event.title}
109
+ </Text>
110
+ {!compact ? (
111
+ <Text size="xs" numberOfLines={1} style={{ color: ink, opacity: 0.7 }}>
112
+ {event.meta ?? formatTime(event.start, locale)}
113
+ </Text>
114
+ ) : null}
115
+ </View>
116
+ );
117
+ }}
118
+ </FocusRingPressable>
119
+ );
120
+ }
121
+
122
+ /** What a screen reader hears: the title, then when it is — never a bare title,
123
+ * which in a grid of forty of them says nothing about which one this is. */
124
+ export function accessibleName(event: CalendarEvent<unknown>, locale?: string): string {
125
+ if (event.allDay) return event.title;
126
+ const start = formatTime(event.start, locale);
127
+ const end = event.end ? formatTime(event.end, locale) : null;
128
+ return end ? `${event.title}, ${start}–${end}` : `${event.title}, ${start}`;
129
+ }
130
+
131
+ const styles = StyleSheet.create({
132
+ press: { minWidth: 0 },
133
+ banner: { flex: 1, justifyContent: "center", paddingHorizontal: 8, minWidth: 0 },
134
+ dotRow: { flex: 1, flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: 4, minWidth: 0 },
135
+ dot: { width: 6, height: 6, borderRadius: 3, flexShrink: 0 },
136
+ // `flexShrink: 1` + `minWidth: 0` on the label, not just the row: React Native
137
+ // defaults flexShrink to 0, so without it the title holds its full width and
138
+ // pushes the dot out of the cell instead of ellipsing.
139
+ dotLabel: { color: colors.zinc[700], flexShrink: 1, minWidth: 0 },
140
+ block: { flex: 1, paddingHorizontal: 8, paddingVertical: 2, overflow: "hidden", minWidth: 0 },
141
+ });
@@ -1,19 +1,42 @@
1
- export { CalendarView } from "./calendar_view";
1
+ export { CalendarView, CalendarBody } from "./calendar_view";
2
2
  export type { CalendarViewProps } from "./calendar_view";
3
- export { TimeGridView } from "./time_grid_view";
4
- export type { TimeGridViewProps } from "./time_grid_view";
5
- export { MonthView } from "./month_view";
6
- export type { MonthViewProps } from "./month_view";
7
- export { layoutDayColumns, packEventLanes } from "./layout";
3
+ export { CalendarToolbar } from "./calendar_toolbar";
4
+ export type { CalendarToolbarProps } from "./calendar_toolbar";
5
+ export { CalendarMonth } from "./month_view";
6
+ export { CalendarWeek, CalendarDay, gridWindow } from "./time_grid_view";
7
+ export { CalendarAgenda } from "./agenda_view";
8
+ export { EventChip } from "./event_chip";
9
+ export type { EventChipProps, EventChipVariant } from "./event_chip";
10
+ export { useCalendar } from "./context";
11
+ export type { CalendarContextValue } from "./context";
12
+ export {
13
+ layoutDayColumns,
14
+ packEventLanes,
15
+ compareEvents,
16
+ fitLanes,
17
+ initialScrollMinutes,
18
+ isBanner,
19
+ } from "./layout";
8
20
  export type { LaneBar } from "./layout";
9
- export { DEFAULT_CALENDAR_LABELS } from "./types";
10
- export type { CalendarEvent, CalendarViewMode, Weekday, EventColumn, CalendarLabels } from "./types";
21
+ export { expandRepeats, occurrenceDays, occurrenceId, sourceEventId } from "./repeat";
22
+ export type { RepeatRule } from "./repeat";
23
+ export { DEFAULT_CALENDAR_VIEW_LABELS } from "./types";
24
+ export type {
25
+ CalendarEvent,
26
+ CalendarViewMode,
27
+ Weekday,
28
+ EventColumn,
29
+ CalendarViewLabels,
30
+ } from "./types";
11
31
  export {
12
32
  addDays,
13
33
  addMonths,
14
34
  startOfWeek,
15
35
  startOfMonth,
36
+ endOfMonth,
16
37
  daysInView,
38
+ weekRowsInMonth,
39
+ dayHeading,
17
40
  isSameDay,
18
41
  isToday,
19
42
  viewTitle,
@@ -1,4 +1,4 @@
1
- import { dayDiff } from "./dates";
1
+ import { dayDiff, minutesSinceMidnight, startOfDay } from "./dates";
2
2
  import type { CalendarEvent, EventColumn } from "./types";
3
3
 
4
4
  /** A horizontal banner placement: which day column it starts on, how many days
@@ -11,38 +11,112 @@ export interface LaneBar<T = unknown> {
11
11
  lane: number;
12
12
  }
13
13
 
14
+ /** True for an event that occupies whole days rather than a slot in one. */
15
+ export function isBanner<T>(event: CalendarEvent<T>): boolean {
16
+ if (event.allDay) return true;
17
+ return event.end ? dayDiff(event.start, event.end) > 0 : false;
18
+ }
19
+
20
+ /**
21
+ * The order events take within a day, and the order lanes are handed out.
22
+ *
23
+ * Banners lead — a bar that runs Monday to Friday must sit ABOVE the meetings
24
+ * inside those days, or it gets a different lane every week row and stops
25
+ * reading as one continuous thing. Longest first among them, so the widest bar
26
+ * anchors the top lane. Timed events then follow in clock order, which is the
27
+ * rule the previous version was missing entirely: it sorted by day only, so a
28
+ * day's events appeared in whatever order the consumer's array happened to be
29
+ * in — 16:00 above 09:00 as often as not.
30
+ */
31
+ export function compareEvents<T>(a: CalendarEvent<T>, b: CalendarEvent<T>): number {
32
+ const ab = isBanner(a);
33
+ const bb = isBanner(b);
34
+ if (ab !== bb) return ab ? -1 : 1;
35
+ const byDay = startOfDay(a.start).getTime() - startOfDay(b.start).getTime();
36
+ if (byDay !== 0) return byDay;
37
+ if (ab) {
38
+ const len = (e: CalendarEvent<T>) => (e.end ? dayDiff(e.start, e.end) : 0);
39
+ const byLen = len(b) - len(a);
40
+ if (byLen !== 0) return byLen;
41
+ } else {
42
+ const byTime = minutesSinceMidnight(a.start) - minutesSinceMidnight(b.start);
43
+ if (byTime !== 0) return byTime;
44
+ }
45
+ // Ties break on id so the layout is deterministic across renders.
46
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
47
+ }
48
+
14
49
  /**
15
50
  * Pack day-spanning events into horizontal lanes across a `numDays` window
16
51
  * starting at `rangeStart`. Greedy first-fit: an event takes the topmost lane
17
52
  * whose last bar ends before this event starts, else a new lane. Multi-day
18
53
  * events become wide bars; single-day events are span-1 bars. Deterministic.
54
+ *
55
+ * Pure over the list it is handed, which is what keeps a per-resource split
56
+ * (one call per group, rendered as sub-columns) a caller-side concern rather
57
+ * than a second code path in here.
19
58
  */
20
59
  export function packEventLanes<T>(
21
60
  events: CalendarEvent<T>[],
22
61
  rangeStart: Date,
23
62
  numDays: number,
24
63
  ): { bars: LaneBar<T>[]; lanes: number } {
25
- const items = events
64
+ const items = [...events]
65
+ .sort(compareEvents)
26
66
  .map((event) => ({
27
67
  event,
28
68
  startCol: Math.max(0, dayDiff(rangeStart, event.start)),
29
69
  endCol: Math.min(numDays - 1, dayDiff(rangeStart, event.end ?? event.start)),
30
70
  }))
31
- .filter((it) => it.endCol >= 0 && it.startCol <= numDays - 1 && it.endCol >= it.startCol)
32
- .sort((a, b) => a.startCol - b.startCol || b.endCol - a.endCol);
71
+ .filter((it) => it.endCol >= 0 && it.startCol <= numDays - 1 && it.endCol >= it.startCol);
33
72
 
34
- const laneEnds: number[] = [];
73
+ // Occupancy per lane is a set of COLUMNS, not a single "last end column".
74
+ // With one end per lane, a bar late in the week blocks that lane for every
75
+ // day BEFORE it too — a Saturday holiday banner took lane 0 across the whole
76
+ // row and pushed Monday's first meeting down a lane, which on a short row is
77
+ // the difference between seeing it and reading "+2 more".
78
+ const laneCols: boolean[][] = [];
79
+ const free = (lane: boolean[], from: number, to: number) => {
80
+ for (let c = from; c <= to; c++) if (lane[c]) return false;
81
+ return true;
82
+ };
35
83
  const bars = items.map((it) => {
36
- let lane = laneEnds.findIndex((end) => end < it.startCol);
84
+ let lane = laneCols.findIndex((cols) => free(cols, it.startCol, it.endCol));
37
85
  if (lane === -1) {
38
- lane = laneEnds.length;
39
- laneEnds.push(it.endCol);
40
- } else {
41
- laneEnds[lane] = it.endCol;
86
+ lane = laneCols.length;
87
+ laneCols.push(Array.from({ length: numDays }, () => false));
42
88
  }
89
+ for (let c = it.startCol; c <= it.endCol; c++) laneCols[lane][c] = true;
43
90
  return { event: it.event, startCol: it.startCol, span: it.endCol - it.startCol + 1, lane };
44
91
  });
45
- return { bars, lanes: laneEnds.length };
92
+ return { bars, lanes: laneCols.length };
93
+ }
94
+
95
+ /**
96
+ * How many event lanes fit in a measured week row, and whether the row must
97
+ * give one back to the "+N more" chip.
98
+ *
99
+ * The lane cap used to be the constant 3 while the row itself was `flex: 1` —
100
+ * so a tall calendar wasted the space it had and a short one drew three lanes
101
+ * into a 20px row, straight over the next week's dates. A cap has to be a
102
+ * function of the height actually measured.
103
+ */
104
+ export function fitLanes(
105
+ availableHeight: number,
106
+ laneHeight: number,
107
+ laneGap: number,
108
+ neededLanes: number,
109
+ overflowChipHeight: number,
110
+ ): { visibleLanes: number; overflows: boolean } {
111
+ const per = laneHeight + laneGap;
112
+ // The last lane needs no trailing gap, so the gap is added back before
113
+ // dividing — without it a row that fits exactly N lanes reports N-1.
114
+ const fit = (h: number) => Math.max(0, Math.floor((h + laneGap) / per));
115
+ if (neededLanes <= fit(availableHeight)) return { visibleLanes: neededLanes, overflows: false };
116
+ // Overflowing costs the row the chip's height, NOT a whole lane: the chip is
117
+ // one line of small text, and charging it a full lane threw away an event
118
+ // that had room to be shown.
119
+ return { visibleLanes: fit(availableHeight - overflowChipHeight), overflows: true };
46
120
  }
47
121
 
48
122
  const MINUTES_PER_DAY = 1440;
@@ -54,6 +128,34 @@ function minutesInto(day: Date, t: Date): number {
54
128
  return ms / 60000;
55
129
  }
56
130
 
131
+ /**
132
+ * The minute window a time grid should open on.
133
+ *
134
+ * The previous rule was "an hour before now", unconditionally — so navigating to
135
+ * any week whose events sit outside the current wall-clock hour opened on an
136
+ * empty grid. A week of 09:00 meetings, viewed at 13:00, looked like a week with
137
+ * nothing in it. What a reader wants is the content: the earliest event in view,
138
+ * and only when there is none does the clock (or the configured day start) decide.
139
+ */
140
+ export function initialScrollMinutes(
141
+ events: { start: Date }[],
142
+ daysInRange: Date[],
143
+ now: Date,
144
+ dayStartMinutes: number,
145
+ ): number {
146
+ let earliest: number | null = null;
147
+ for (const e of events) {
148
+ const inRange = daysInRange.some((d) => startOfDay(e.start).getTime() === d.getTime());
149
+ if (!inRange) continue;
150
+ const m = minutesSinceMidnight(e.start);
151
+ if (earliest === null || m < earliest) earliest = m;
152
+ }
153
+ if (earliest !== null) return Math.max(0, earliest - 30);
154
+ const todayInRange = daysInRange.some((d) => startOfDay(now).getTime() === d.getTime());
155
+ if (todayInRange) return Math.max(0, minutesSinceMidnight(now) - 60);
156
+ return dayStartMinutes;
157
+ }
158
+
57
159
  /**
58
160
  * Position timed events for a single grid day into side-by-side columns.
59
161
  *