@lotics/ui 1.10.0 → 1.11.1

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.
Files changed (55) hide show
  1. package/package.json +24 -7
  2. package/src/alert.tsx +35 -5
  3. package/src/avatar.tsx +28 -3
  4. package/src/back_button.tsx +4 -2
  5. package/src/button.tsx +35 -5
  6. package/src/calendar/calendar_view.tsx +127 -0
  7. package/src/calendar/dates.ts +102 -0
  8. package/src/calendar/index.ts +20 -0
  9. package/src/calendar/layout.test.ts +103 -0
  10. package/src/calendar/layout.ts +142 -0
  11. package/src/calendar/month_view.tsx +159 -0
  12. package/src/calendar/time_grid_view.tsx +263 -0
  13. package/src/calendar/types.ts +67 -0
  14. package/src/checkbox_input.tsx +9 -3
  15. package/src/command_menu.tsx +50 -4
  16. package/src/dialog.tsx +1 -1
  17. package/src/download.ts +14 -2
  18. package/src/form_field.tsx +77 -25
  19. package/src/form_switch.tsx +22 -3
  20. package/src/gantt/gantt_view.tsx +145 -0
  21. package/src/gantt/index.ts +5 -0
  22. package/src/gantt/scale.test.ts +47 -0
  23. package/src/gantt/scale.ts +92 -0
  24. package/src/gantt/types.ts +51 -0
  25. package/src/grid/select_header_cell.tsx +1 -0
  26. package/src/icon.tsx +14 -8
  27. package/src/icon_button.tsx +10 -4
  28. package/src/index.css +11 -0
  29. package/src/kanban/constants.ts +18 -0
  30. package/src/kanban/default_renderers.tsx +160 -0
  31. package/src/kanban/drag_preview.tsx +157 -0
  32. package/src/kanban/index.ts +13 -0
  33. package/src/kanban/insert_card_zone.tsx +135 -0
  34. package/src/kanban/kanban_board.tsx +616 -0
  35. package/src/kanban/kanban_card.tsx +312 -0
  36. package/src/kanban/kanban_column.tsx +487 -0
  37. package/src/kanban/placeholders.tsx +54 -0
  38. package/src/kanban/types.ts +116 -0
  39. package/src/landmark.tsx +34 -0
  40. package/src/menu_button.tsx +21 -0
  41. package/src/menu_list_item.tsx +3 -0
  42. package/src/number_input.tsx +10 -1
  43. package/src/pill_button.tsx +1 -0
  44. package/src/popover.tsx +47 -2
  45. package/src/popover_header.tsx +4 -2
  46. package/src/pressable_highlight.tsx +24 -0
  47. package/src/radio_picker.tsx +63 -5
  48. package/src/section_heading.tsx +5 -3
  49. package/src/skip_link.tsx +46 -0
  50. package/src/switch.tsx +9 -1
  51. package/src/switch_button.tsx +3 -0
  52. package/src/tabs.tsx +81 -19
  53. package/src/text.tsx +33 -0
  54. package/src/text_input_field.tsx +31 -0
  55. package/src/tooltip.tsx +43 -6
@@ -0,0 +1,142 @@
1
+ import { dayDiff } from "./dates";
2
+ import type { CalendarEvent, EventColumn } from "./types";
3
+
4
+ /** A horizontal banner placement: which day column it starts on, how many days
5
+ * it spans, and which lane (row) it sits in. Used by the month grid and the
6
+ * week's all-day row. */
7
+ export interface LaneBar<T = unknown> {
8
+ event: CalendarEvent<T>;
9
+ startCol: number;
10
+ span: number;
11
+ lane: number;
12
+ }
13
+
14
+ /**
15
+ * Pack day-spanning events into horizontal lanes across a `numDays` window
16
+ * starting at `rangeStart`. Greedy first-fit: an event takes the topmost lane
17
+ * whose last bar ends before this event starts, else a new lane. Multi-day
18
+ * events become wide bars; single-day events are span-1 bars. Deterministic.
19
+ */
20
+ export function packEventLanes<T>(
21
+ events: CalendarEvent<T>[],
22
+ rangeStart: Date,
23
+ numDays: number,
24
+ ): { bars: LaneBar<T>[]; lanes: number } {
25
+ const items = events
26
+ .map((event) => ({
27
+ event,
28
+ startCol: Math.max(0, dayDiff(rangeStart, event.start)),
29
+ endCol: Math.min(numDays - 1, dayDiff(rangeStart, event.end ?? event.start)),
30
+ }))
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);
33
+
34
+ const laneEnds: number[] = [];
35
+ const bars = items.map((it) => {
36
+ let lane = laneEnds.findIndex((end) => end < it.startCol);
37
+ if (lane === -1) {
38
+ lane = laneEnds.length;
39
+ laneEnds.push(it.endCol);
40
+ } else {
41
+ laneEnds[lane] = it.endCol;
42
+ }
43
+ return { event: it.event, startCol: it.startCol, span: it.endCol - it.startCol + 1, lane };
44
+ });
45
+ return { bars, lanes: laneEnds.length };
46
+ }
47
+
48
+ const MINUTES_PER_DAY = 1440;
49
+ /** Short events still need a tappable height; floor the rendered span. */
50
+ const MIN_EVENT_MINUTES = 20;
51
+
52
+ function minutesInto(day: Date, t: Date): number {
53
+ const ms = t.getTime() - day.getTime();
54
+ return ms / 60000;
55
+ }
56
+
57
+ /**
58
+ * Position timed events for a single grid day into side-by-side columns.
59
+ *
60
+ * The defining piece of a real calendar: when events overlap in time they must
61
+ * share the day's width rather than stack on top of each other. We
62
+ * 1. clamp each event to the [0, 1440] minute window of `day`,
63
+ * 2. split events into overlap *clusters* (a maximal run where each event
64
+ * overlaps at least one earlier event in the run — transitive),
65
+ * 3. greedily first-fit each cluster's events into columns (a column is free
66
+ * once its last event ends at/before the next one starts),
67
+ * 4. give every event in a cluster the same `columns` count (the cluster's
68
+ * column total) so widths line up: width = 1/columns, left = column/columns.
69
+ *
70
+ * Pure and deterministic — input order doesn't matter (we sort). All-day events
71
+ * are the caller's concern (the all-day row); pass only timed events here.
72
+ */
73
+ export function layoutDayColumns<T>(
74
+ day: Date,
75
+ events: CalendarEvent<T>[],
76
+ ): EventColumn<T>[] {
77
+ const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate());
78
+
79
+ const placed = events
80
+ .map((event) => {
81
+ const rawTop = minutesInto(dayStart, event.start);
82
+ const rawEnd = event.end ? minutesInto(dayStart, event.end) : rawTop + MIN_EVENT_MINUTES;
83
+ const topMinutes = Math.max(0, Math.min(MINUTES_PER_DAY, rawTop));
84
+ const endMinutes = Math.max(topMinutes, Math.min(MINUTES_PER_DAY, rawEnd));
85
+ return {
86
+ event,
87
+ topMinutes,
88
+ endMinutes,
89
+ heightMinutes: Math.max(MIN_EVENT_MINUTES, endMinutes - topMinutes),
90
+ };
91
+ })
92
+ // Earliest first; on a tie the longer event leads so it anchors column 0.
93
+ .sort((a, b) => a.topMinutes - b.topMinutes || b.heightMinutes - a.heightMinutes);
94
+
95
+ const result: EventColumn<T>[] = [];
96
+ let cluster: typeof placed = [];
97
+ let clusterEnd = -1;
98
+
99
+ const flush = () => {
100
+ if (cluster.length === 0) return;
101
+ // First-fit column assignment within the cluster.
102
+ const columnEnds: number[] = []; // running end-minute per column
103
+ const assigned = cluster.map((item) => {
104
+ // The visual span (incl. the min-height floor) is what must not collide,
105
+ // otherwise a short event floored to 20min could overlap the next chip.
106
+ const itemVisualEnd = item.topMinutes + item.heightMinutes;
107
+ let col = columnEnds.findIndex((end) => end <= item.topMinutes);
108
+ if (col === -1) {
109
+ col = columnEnds.length;
110
+ columnEnds.push(itemVisualEnd);
111
+ } else {
112
+ columnEnds[col] = itemVisualEnd;
113
+ }
114
+ return { item, col };
115
+ });
116
+ const columns = columnEnds.length;
117
+ for (const { item, col } of assigned) {
118
+ result.push({
119
+ event: item.event,
120
+ topMinutes: item.topMinutes,
121
+ heightMinutes: item.heightMinutes,
122
+ column: col,
123
+ columns,
124
+ });
125
+ }
126
+ cluster = [];
127
+ clusterEnd = -1;
128
+ };
129
+
130
+ for (const item of placed) {
131
+ const visualEnd = item.topMinutes + item.heightMinutes;
132
+ if (cluster.length > 0 && item.topMinutes >= clusterEnd) {
133
+ // No overlap with anything still open → previous cluster is closed.
134
+ flush();
135
+ }
136
+ cluster.push(item);
137
+ clusterEnd = Math.max(clusterEnd, visualEnd);
138
+ }
139
+ flush();
140
+
141
+ return result;
142
+ }
@@ -0,0 +1,159 @@
1
+ import { useMemo } from "react";
2
+ import { View, Pressable, StyleSheet } from "react-native";
3
+ import { Text } from "../text";
4
+ import { colors } from "../colors";
5
+ import { packEventLanes } from "./layout";
6
+ import { daysInView, isSameMonth, isToday, weekdayShort } from "./dates";
7
+ import type { CalendarEvent, Weekday } from "./types";
8
+
9
+ const LANE_H = 19;
10
+ const LANE_GAP = 2;
11
+ const MAX_LANES = 3; // visible event lanes per week before "+N more"
12
+
13
+ export interface MonthViewProps<T = unknown> {
14
+ date: Date;
15
+ events: CalendarEvent<T>[];
16
+ weekStartsOn?: Weekday;
17
+ locale?: string;
18
+ /** Overflow chip label, e.g. (3) => "+3 more". Defaults to English. */
19
+ moreLabel?: (count: number) => string;
20
+ onEventPress?: (event: CalendarEvent<T>) => void;
21
+ onDayPress?: (day: Date) => void;
22
+ }
23
+
24
+ export function MonthView<T = unknown>(props: MonthViewProps<T>) {
25
+ const { date, events, weekStartsOn = 1, locale, moreLabel = (n) => `+${n} more`, onEventPress, onDayPress } = props;
26
+ const days = useMemo(() => daysInView("month", date, weekStartsOn), [date, weekStartsOn]);
27
+ const weeks = useMemo(() => Array.from({ length: 6 }, (_, w) => days.slice(w * 7, w * 7 + 7)), [days]);
28
+ const weekdayLabels = useMemo(() => days.slice(0, 7).map((d) => weekdayShort(d, locale)), [days, locale]);
29
+ const now = new Date();
30
+
31
+ return (
32
+ <View style={styles.root}>
33
+ <View style={styles.weekdayHeader}>
34
+ {weekdayLabels.map((label, i) => (
35
+ <View key={i} style={styles.weekdayCell}>
36
+ <Text size="xs" color="muted">{label.toUpperCase()}</Text>
37
+ </View>
38
+ ))}
39
+ </View>
40
+
41
+ {weeks.map((weekDays, w) => {
42
+ const { bars } = packEventLanes(events, weekDays[0], 7);
43
+ const visible = bars.filter((b) => b.lane < MAX_LANES);
44
+ const overflow = Array.from({ length: 7 }, () => 0);
45
+ for (const b of bars) {
46
+ if (b.lane >= MAX_LANES) {
47
+ for (let c = b.startCol; c < b.startCol + b.span && c < 7; c++) overflow[c]++;
48
+ }
49
+ }
50
+
51
+ return (
52
+ <View key={w} style={styles.weekRow}>
53
+ {/* Date numbers */}
54
+ <View style={styles.dateRow}>
55
+ {weekDays.map((day) => {
56
+ const inMonth = isSameMonth(day, date);
57
+ const today = isToday(day, now);
58
+ return (
59
+ <Pressable
60
+ key={day.toISOString()}
61
+ onPress={onDayPress ? () => onDayPress(day) : undefined}
62
+ accessibilityRole={onDayPress ? "button" : undefined}
63
+ style={styles.dateCell}
64
+ >
65
+ <View style={[styles.dateBadge, today && { backgroundColor: colors.teal[600] }]}>
66
+ <Text
67
+ size="xs"
68
+ weight={today ? "semibold" : "regular"}
69
+ style={{ color: today ? colors.white : inMonth ? colors.zinc[800] : colors.zinc[400] }}
70
+ >
71
+ {day.getDate()}
72
+ </Text>
73
+ </View>
74
+ </Pressable>
75
+ );
76
+ })}
77
+ </View>
78
+
79
+ {/* Event lanes */}
80
+ <View style={styles.laneArea}>
81
+ {weekDays.map((_, i) =>
82
+ i === 0 ? null : (
83
+ <View
84
+ key={i}
85
+ style={{ position: "absolute", left: `${(i / 7) * 100}%`, top: -2, bottom: 0, borderLeftWidth: 1, borderLeftColor: colors.zinc[100] }}
86
+ />
87
+ ),
88
+ )}
89
+ {visible.map((bar) => {
90
+ const accent = bar.event.color || colors.teal[600];
91
+ const banner = bar.span > 1 || !!bar.event.allDay;
92
+ return (
93
+ <Pressable
94
+ key={bar.event.id}
95
+ onPress={onEventPress ? () => onEventPress(bar.event) : undefined}
96
+ accessibilityRole={onEventPress ? "button" : undefined}
97
+ accessibilityLabel={bar.event.title}
98
+ style={{
99
+ position: "absolute",
100
+ top: bar.lane * (LANE_H + LANE_GAP),
101
+ height: LANE_H,
102
+ left: `${(bar.startCol / 7) * 100}%`,
103
+ width: `${(bar.span / 7) * 100}%`,
104
+ paddingHorizontal: 2,
105
+ }}
106
+ >
107
+ <View
108
+ style={{
109
+ flex: 1,
110
+ borderRadius: 4,
111
+ backgroundColor: banner ? accent : "transparent",
112
+ flexDirection: "row",
113
+ alignItems: "center",
114
+ paddingHorizontal: 5,
115
+ gap: 5,
116
+ }}
117
+ >
118
+ {!banner ? <View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: accent }} /> : null}
119
+ <Text
120
+ size="xs"
121
+ weight={banner ? "medium" : "regular"}
122
+ numberOfLines={1}
123
+ style={{ color: banner ? colors.white : colors.zinc[700], flex: 1 }}
124
+ >
125
+ {bar.event.title}
126
+ </Text>
127
+ </View>
128
+ </Pressable>
129
+ );
130
+ })}
131
+ {weekDays.map((day, col) =>
132
+ overflow[col] > 0 ? (
133
+ <Pressable
134
+ key={`o${col}`}
135
+ onPress={onDayPress ? () => onDayPress(day) : undefined}
136
+ style={{ position: "absolute", top: MAX_LANES * (LANE_H + LANE_GAP), left: `${(col / 7) * 100}%`, width: `${100 / 7}%`, paddingHorizontal: 6 }}
137
+ >
138
+ <Text size="xs" color="muted">{moreLabel(overflow[col])}</Text>
139
+ </Pressable>
140
+ ) : null,
141
+ )}
142
+ </View>
143
+ </View>
144
+ );
145
+ })}
146
+ </View>
147
+ );
148
+ }
149
+
150
+ const styles = StyleSheet.create({
151
+ root: { flex: 1, backgroundColor: colors.white },
152
+ weekdayHeader: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, paddingVertical: 6 },
153
+ weekdayCell: { flex: 1, alignItems: "center" },
154
+ weekRow: { flex: 1, borderBottomWidth: 1, borderBottomColor: colors.zinc[100] },
155
+ dateRow: { flexDirection: "row", paddingTop: 4 },
156
+ dateCell: { flex: 1, alignItems: "center" },
157
+ dateBadge: { minWidth: 22, height: 22, borderRadius: 11, alignItems: "center", justifyContent: "center", paddingHorizontal: 4 },
158
+ laneArea: { flex: 1, position: "relative", marginTop: 2 },
159
+ });
@@ -0,0 +1,263 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { View, ScrollView, Pressable, StyleSheet, Text as RNText } from "react-native";
3
+ import { Text } from "../text";
4
+ import { colors } from "../colors";
5
+ import { layoutDayColumns, packEventLanes } from "./layout";
6
+ import {
7
+ daysInView,
8
+ formatTime,
9
+ hourLabel,
10
+ isSameDay,
11
+ isToday,
12
+ minutesSinceMidnight,
13
+ startOfDay,
14
+ weekdayShort,
15
+ } from "./dates";
16
+ import type { CalendarEvent, Weekday } from "./types";
17
+
18
+ const HOUR_HEIGHT = 44;
19
+ const PX_PER_MIN = HOUR_HEIGHT / 60;
20
+ const GRID_HEIGHT = 24 * HOUR_HEIGHT;
21
+ const GUTTER = 56;
22
+ const ALLDAY_LANE_H = 24;
23
+ const HOURS = Array.from({ length: 24 }, (_, i) => i);
24
+ const COL_GAP = 2;
25
+
26
+ export interface TimeGridViewProps<T = unknown> {
27
+ mode: "week" | "day";
28
+ date: Date;
29
+ events: CalendarEvent<T>[];
30
+ weekStartsOn?: Weekday;
31
+ locale?: string;
32
+ /** Hour to scroll to on mount. Default: an hour before now (clamped). */
33
+ scrollToHour?: number;
34
+ /** All-day lane label; defaults to English ("all-day"). */
35
+ allDayLabel?: string;
36
+ onEventPress?: (event: CalendarEvent<T>) => void;
37
+ }
38
+
39
+ function EventBlock<T>({
40
+ event,
41
+ top,
42
+ height,
43
+ leftPct,
44
+ widthPct,
45
+ onPress,
46
+ locale,
47
+ }: {
48
+ event: CalendarEvent<T>;
49
+ top: number;
50
+ height: number;
51
+ leftPct: number;
52
+ widthPct: number;
53
+ onPress?: (e: CalendarEvent<T>) => void;
54
+ locale?: string;
55
+ }) {
56
+ const accent = event.color || colors.teal[600];
57
+ const compact = height < 34;
58
+ return (
59
+ <Pressable
60
+ onPress={onPress ? () => onPress(event) : undefined}
61
+ accessibilityRole={onPress ? "button" : undefined}
62
+ accessibilityLabel={event.title}
63
+ style={{
64
+ position: "absolute",
65
+ top,
66
+ height: Math.max(height - 1, 14),
67
+ left: `${leftPct}%`,
68
+ width: `${widthPct}%`,
69
+ paddingHorizontal: COL_GAP,
70
+ }}
71
+ >
72
+ <View
73
+ style={{
74
+ flex: 1,
75
+ borderRadius: 6,
76
+ backgroundColor: accent + "1f",
77
+ borderLeftWidth: 3,
78
+ borderLeftColor: accent,
79
+ paddingHorizontal: 6,
80
+ paddingVertical: compact ? 1 : 3,
81
+ overflow: "hidden",
82
+ }}
83
+ >
84
+ <Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.zinc[800] }}>
85
+ {event.title}
86
+ </Text>
87
+ {!compact ? (
88
+ <Text size="xs" numberOfLines={1} style={{ color: colors.zinc[500] }}>
89
+ {formatTime(event.start, locale)}
90
+ </Text>
91
+ ) : null}
92
+ </View>
93
+ </Pressable>
94
+ );
95
+ }
96
+
97
+ export function TimeGridView<T = unknown>(props: TimeGridViewProps<T>) {
98
+ const { mode, date, events, weekStartsOn = 1, locale, onEventPress } = props;
99
+ const days = useMemo(
100
+ () => daysInView(mode, date, weekStartsOn),
101
+ [mode, date, weekStartsOn],
102
+ );
103
+
104
+ // Live now-indicator: tick each minute.
105
+ const [now, setNow] = useState(() => new Date());
106
+ useEffect(() => {
107
+ const id = setInterval(() => setNow(new Date()), 60_000);
108
+ return () => clearInterval(id);
109
+ }, []);
110
+
111
+ const scrollRef = useRef<ScrollView>(null);
112
+ useEffect(() => {
113
+ const hour = props.scrollToHour ?? Math.max(0, now.getHours() - 1);
114
+ const id = setTimeout(() => scrollRef.current?.scrollTo({ y: hour * HOUR_HEIGHT, animated: false }), 0);
115
+ return () => clearTimeout(id);
116
+ // Only on mount / mode change — not every minute.
117
+ // eslint-disable-next-line react-hooks/exhaustive-deps
118
+ }, [mode]);
119
+
120
+ const { timedByDay, allDay } = useMemo(() => {
121
+ const timed: CalendarEvent<T>[] = [];
122
+ const banner: CalendarEvent<T>[] = [];
123
+ for (const e of events) {
124
+ const multiDay = e.end ? !isSameDay(e.start, e.end) : false;
125
+ if (e.allDay || multiDay) banner.push(e);
126
+ else timed.push(e);
127
+ }
128
+ const byDay = days.map((day) =>
129
+ timed.filter((e) => {
130
+ const s = startOfDay(e.start);
131
+ return s.getTime() === day.getTime();
132
+ }),
133
+ );
134
+ return { timedByDay: byDay, allDay: banner };
135
+ }, [events, days]);
136
+
137
+ const allDayPacked = useMemo(() => packEventLanes(allDay, days[0], days.length), [allDay, days]);
138
+
139
+ return (
140
+ <View style={styles.root}>
141
+ {/* Header: day columns */}
142
+ <View style={styles.headerRow}>
143
+ <View style={{ width: GUTTER }} />
144
+ {days.map((day) => {
145
+ const today = isToday(day, now);
146
+ return (
147
+ <View key={day.toISOString()} style={styles.headerCell}>
148
+ <Text size="xs" style={{ color: today ? colors.teal[600] : colors.zinc[500] }}>
149
+ {weekdayShort(day, locale).toUpperCase()}
150
+ </Text>
151
+ <View style={[styles.dateBadge, today && { backgroundColor: colors.teal[600] }]}>
152
+ <Text size="sm" weight={today ? "semibold" : "medium"} style={{ color: today ? colors.white : colors.zinc[800] }}>
153
+ {day.getDate()}
154
+ </Text>
155
+ </View>
156
+ </View>
157
+ );
158
+ })}
159
+ </View>
160
+
161
+ {/* All-day banner row */}
162
+ {allDayPacked.lanes > 0 ? (
163
+ <View style={[styles.allDayRow, { height: allDayPacked.lanes * (ALLDAY_LANE_H + 2) + 6 }]}>
164
+ <View style={{ width: GUTTER, justifyContent: "center" }}>
165
+ <Text size="xs" style={{ color: colors.zinc[400], textAlign: "right", paddingRight: 6 }}>
166
+ {props.allDayLabel ?? "all-day"}
167
+ </Text>
168
+ </View>
169
+ <View style={{ flex: 1 }}>
170
+ {allDayPacked.bars.map((bar) => {
171
+ const accent = bar.event.color || colors.teal[600];
172
+ return (
173
+ <Pressable
174
+ key={bar.event.id}
175
+ onPress={onEventPress ? () => onEventPress(bar.event) : undefined}
176
+ accessibilityRole={onEventPress ? "button" : undefined}
177
+ accessibilityLabel={bar.event.title}
178
+ style={{
179
+ position: "absolute",
180
+ top: bar.lane * (ALLDAY_LANE_H + 2) + 3,
181
+ height: ALLDAY_LANE_H,
182
+ left: `${(bar.startCol / days.length) * 100}%`,
183
+ width: `${(bar.span / days.length) * 100}%`,
184
+ paddingHorizontal: COL_GAP,
185
+ }}
186
+ >
187
+ <View style={{ flex: 1, borderRadius: 5, backgroundColor: accent, justifyContent: "center", paddingHorizontal: 8 }}>
188
+ <Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.white }}>
189
+ {bar.event.title}
190
+ </Text>
191
+ </View>
192
+ </Pressable>
193
+ );
194
+ })}
195
+ </View>
196
+ </View>
197
+ ) : null}
198
+
199
+ {/* Scrollable time grid */}
200
+ <ScrollView ref={scrollRef} style={{ flex: 1 }} showsVerticalScrollIndicator>
201
+ <View style={{ flexDirection: "row", height: GRID_HEIGHT }}>
202
+ {/* Hour gutter */}
203
+ <View style={{ width: GUTTER }}>
204
+ {HOURS.map((h) =>
205
+ h === 0 ? null : (
206
+ <RNText
207
+ key={h}
208
+ style={{
209
+ position: "absolute",
210
+ top: h * HOUR_HEIGHT - 7,
211
+ right: 6,
212
+ fontSize: 11,
213
+ color: colors.zinc[400],
214
+ }}
215
+ >
216
+ {hourLabel(h)}
217
+ </RNText>
218
+ ),
219
+ )}
220
+ </View>
221
+ {/* Day columns */}
222
+ {days.map((day, dayIdx) => {
223
+ const placed = layoutDayColumns(day, timedByDay[dayIdx]);
224
+ const today = isToday(day, now);
225
+ return (
226
+ <View key={day.toISOString()} style={styles.dayColumn}>
227
+ {HOURS.map((h) => (
228
+ <View key={h} style={{ position: "absolute", top: h * HOUR_HEIGHT, left: 0, right: 0, borderTopWidth: 1, borderTopColor: colors.zinc[100] }} />
229
+ ))}
230
+ {placed.map((c) => (
231
+ <EventBlock
232
+ key={c.event.id}
233
+ event={c.event}
234
+ top={c.topMinutes * PX_PER_MIN}
235
+ height={c.heightMinutes * PX_PER_MIN}
236
+ leftPct={(c.column / c.columns) * 100}
237
+ widthPct={(1 / c.columns) * 100}
238
+ onPress={onEventPress}
239
+ locale={locale}
240
+ />
241
+ ))}
242
+ {today ? (
243
+ <View style={{ position: "absolute", top: minutesSinceMidnight(now) * PX_PER_MIN, left: 0, right: 0, height: 2, backgroundColor: colors.red[500] }}>
244
+ <View style={{ position: "absolute", left: -4, top: -3, width: 8, height: 8, borderRadius: 4, backgroundColor: colors.red[500] }} />
245
+ </View>
246
+ ) : null}
247
+ </View>
248
+ );
249
+ })}
250
+ </View>
251
+ </ScrollView>
252
+ </View>
253
+ );
254
+ }
255
+
256
+ const styles = StyleSheet.create({
257
+ root: { flex: 1, backgroundColor: colors.white },
258
+ headerRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, paddingVertical: 6 },
259
+ headerCell: { flex: 1, alignItems: "center", gap: 3 },
260
+ dateBadge: { minWidth: 26, height: 26, borderRadius: 13, alignItems: "center", justifyContent: "center", paddingHorizontal: 4 },
261
+ allDayRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, paddingVertical: 3 },
262
+ dayColumn: { flex: 1, borderLeftWidth: 1, borderLeftColor: colors.zinc[100] },
263
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Calendar primitive — data model. Mirrors the minimum shape every calendar API
3
+ * converges on ({title, start, end}) plus the layered extras (all-day, color).
4
+ * `data` carries the consumer's row so render/press callbacks stay typed.
5
+ */
6
+ export interface CalendarEvent<T = unknown> {
7
+ id: string;
8
+ title: string;
9
+ /** Event start (local time). For an all-day event, the day it begins. */
10
+ start: Date;
11
+ /** Exclusive end. Null/undefined → a zero-length point (treated as 30 min in the time grid). */
12
+ end?: Date | null;
13
+ /** All-day / multi-day banner event (rendered in the all-day row, not the time grid). */
14
+ allDay?: boolean;
15
+ /** Hex or design-token color for the event chip. */
16
+ color?: string | null;
17
+ data?: T;
18
+ }
19
+
20
+ export type CalendarViewMode = "month" | "week" | "day";
21
+
22
+ /**
23
+ * User-facing chrome strings. Primitives default to English and take these as a
24
+ * prop — i18n is the consumer's responsibility (see the @lotics/ui convention in
25
+ * grid/data_grid.tsx). Dates/weekday/month names come from `locale` via Intl and
26
+ * are not part of this set.
27
+ */
28
+ export interface CalendarLabels {
29
+ today: string;
30
+ month: string;
31
+ week: string;
32
+ day: string;
33
+ previous: string;
34
+ next: string;
35
+ allDay: string;
36
+ /** Month-cell overflow chip, e.g. (3) => "+3 more". */
37
+ more: (count: number) => string;
38
+ }
39
+
40
+ export const DEFAULT_CALENDAR_LABELS: CalendarLabels = {
41
+ today: "Today",
42
+ month: "Month",
43
+ week: "Week",
44
+ day: "Day",
45
+ previous: "Previous",
46
+ next: "Next",
47
+ allDay: "all-day",
48
+ more: (n) => `+${n} more`,
49
+ };
50
+
51
+ /** 0 = Sunday … 6 = Saturday. */
52
+ export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
53
+
54
+ /**
55
+ * Geometry produced by the overlap layout for one timed event: which column it
56
+ * occupies and how many columns its overlap cluster spans. width = 1/columns,
57
+ * left = column/columns — concurrent events render side-by-side, FullCalendar-style.
58
+ */
59
+ export interface EventColumn<T = unknown> {
60
+ event: CalendarEvent<T>;
61
+ /** Minutes from midnight of the grid day (clamped to [0, 1440]). */
62
+ topMinutes: number;
63
+ /** Height in minutes (>= a floor so very short events stay tappable). */
64
+ heightMinutes: number;
65
+ column: number;
66
+ columns: number;
67
+ }
@@ -2,6 +2,11 @@ import { useCallback } from "react";
2
2
  import { Pressable } from "react-native";
3
3
  import { Checkbox } from "./checkbox";
4
4
  interface CheckboxInputProps {
5
+ /**
6
+ * Accessible name. Required because an unlabelled checkbox only announces
7
+ * its state, leaving the user with no idea what is being checked.
8
+ */
9
+ accessibilityLabel: string;
5
10
  checked: boolean;
6
11
  onChange?: (checked: boolean) => void;
7
12
  disabled?: boolean;
@@ -10,7 +15,7 @@ interface CheckboxInputProps {
10
15
  }
11
16
 
12
17
  export function CheckboxInput(props: CheckboxInputProps) {
13
- const { indeterminate, checked, onChange, disabled, testID } = props;
18
+ const { accessibilityLabel, indeterminate, checked, onChange, disabled, testID } = props;
14
19
 
15
20
  const handlePress = useCallback(() => {
16
21
  if (!disabled) {
@@ -24,8 +29,9 @@ export function CheckboxInput(props: CheckboxInputProps) {
24
29
  onPress={handlePress}
25
30
  disabled={disabled}
26
31
  style={{ opacity: disabled ? 0.5 : 1 }}
27
- role="checkbox"
28
- aria-checked={checked}
32
+ accessibilityRole="checkbox"
33
+ accessibilityLabel={accessibilityLabel}
34
+ accessibilityState={{ checked: indeterminate ? "mixed" : checked, disabled: !!disabled }}
29
35
  >
30
36
  <Checkbox checked={checked} indeterminate={indeterminate} />
31
37
  </Pressable>