@lotics/ui 46.14.1 → 47.1.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.
Files changed (49) hide show
  1. package/AGENTS.md +20 -0
  2. package/MIGRATION.md +58 -0
  3. package/docs/catalog.md +72 -13
  4. package/docs/composition.md +94 -6
  5. package/docs/reviewing.md +56 -5
  6. package/docs/templates.md +8 -2
  7. package/examples/tpl_calendar.tsx +22 -29
  8. package/examples/tpl_item_list.tsx +7 -2
  9. package/package.json +1 -1
  10. package/src/accordion.tsx +11 -5
  11. package/src/bar_chart.tsx +3 -1
  12. package/src/board.tsx +1 -2
  13. package/src/breakdown.tsx +3 -1
  14. package/src/calendar/agenda_view.tsx +136 -0
  15. package/src/calendar/calendar_toolbar.tsx +84 -0
  16. package/src/calendar/calendar_view.tsx +196 -103
  17. package/src/calendar/context.ts +47 -0
  18. package/src/calendar/dates.ts +36 -6
  19. package/src/calendar/event_chip.tsx +141 -0
  20. package/src/calendar/index.ts +31 -8
  21. package/src/calendar/layout.ts +113 -11
  22. package/src/calendar/month_view.tsx +194 -150
  23. package/src/calendar/repeat.ts +174 -0
  24. package/src/calendar/time_grid_view.tsx +182 -202
  25. package/src/calendar/types.ts +37 -11
  26. package/src/charge_lines.tsx +24 -5
  27. package/src/control_surface.ts +28 -0
  28. package/src/deadline.ts +10 -0
  29. package/src/file_row.tsx +26 -3
  30. package/src/finding.tsx +1 -1
  31. package/src/form_text_input.tsx +9 -2
  32. package/src/gantt/gantt_view.tsx +212 -119
  33. package/src/gantt/index.ts +2 -2
  34. package/src/gantt/scale.ts +38 -2
  35. package/src/gantt/types.ts +34 -7
  36. package/src/inline_slot.tsx +16 -2
  37. package/src/inline_static.tsx +18 -4
  38. package/src/legend_item.tsx +14 -1
  39. package/src/locale.tsx +30 -0
  40. package/src/matrix.tsx +19 -5
  41. package/src/member_chip.tsx +18 -1
  42. package/src/menu_button.tsx +12 -2
  43. package/src/option_list.tsx +11 -1
  44. package/src/progress_bar.tsx +14 -1
  45. package/src/record_summary.tsx +5 -0
  46. package/src/stacked_bar_chart.tsx +4 -1
  47. package/src/table_fit.ts +18 -1
  48. package/src/thumbnail_stack.tsx +18 -3
  49. package/src/use_option_list.ts +13 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "46.14.1",
3
+ "version": "47.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
package/src/accordion.tsx CHANGED
@@ -175,9 +175,14 @@ export interface AccordionContentProps {
175
175
  }
176
176
 
177
177
  /** The collapsible body, revealed in place under the header. Plain by
178
- * design — flush with the header's left edge (aligns with the title), no
179
- * tinted well; the chevron + position already say "inside". Compose
180
- * rows/dividers/actions directly. */
178
+ * design — flush with the header BOX's left edge, no tinted well; the chevron
179
+ * + position already say "inside". Compose rows/dividers/actions directly.
180
+ *
181
+ * It does NOT align with the title text: `AccordionTitle` always reserves a
182
+ * 16px icon slot plus a 12px gap, so the title starts 28px in while the content
183
+ * starts at 0. That is deliberate — the body belongs to the section, not to the
184
+ * heading — but it means a header with no icon shows the offset with nothing
185
+ * explaining it, so pass an icon when the gap reads as a mistake. */
181
186
  export function AccordionContent(props: AccordionContentProps) {
182
187
  const { expanded } = useAccordionContext();
183
188
  if (!expanded) return null;
@@ -210,8 +215,9 @@ const styles = StyleSheet.create({
210
215
  meta: {
211
216
  flexShrink: 1,
212
217
  },
213
- // Flush with the header's left edge aligns with the title, like a Section
214
- // body. The chevron + position already say "inside"; no indent needed.
218
+ // Flush with the header BOX's left edge, like a Section body NOT with the
219
+ // title text, which the icon slot pushes 28px in. The chevron + position
220
+ // already say "inside"; no indent needed.
215
221
  content: {
216
222
  paddingBottom: 10,
217
223
  },
package/src/bar_chart.tsx CHANGED
@@ -300,7 +300,9 @@ export function BarChart(props: BarChartProps) {
300
300
  <Text numberOfLines={1} weight="medium">
301
301
  {item.label}
302
302
  </Text>
303
- <Text color="muted">{formatNumber(item.value)}</Text>
303
+ <Text numberOfLines={1} color="muted">
304
+ {formatNumber(item.value)}
305
+ </Text>
304
306
  </View>
305
307
  ) : null}
306
308
  </View>
package/src/board.tsx CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  } from "react";
11
11
  import { Platform, ScrollView, StyleSheet, View } from "react-native";
12
12
  import { colors, tint } from "./colors";
13
- import { CONTROL_RADIUS, MIN_CONTROL_WIDTH } from "./control_surface";
13
+ import { CONTROL_RADIUS, MIN_CONTROL_WIDTH, CARD_RADIUS } from "./control_surface";
14
14
  import { EmptyState } from "./empty_state";
15
15
  import { Icon } from "./icon";
16
16
  import { IconButton } from "./icon_button";
@@ -40,7 +40,6 @@ import { usePointerDrag } from "./use_pointer_drag";
40
40
  /** A card is a container, so it sits on the container rung of the radius ladder
41
41
  * — the same 16 a `Card` takes. Kept here rather than imported so the two
42
42
  * cannot drift apart silently; if the ladder moves, both move. */
43
- const CARD_RADIUS = 16;
44
43
 
45
44
  /** Half the difference between an `sm` IconButton's box (24) and its glyph (14).
46
45
  * It exists so a control cluster can be pulled out by exactly the amount that
package/src/breakdown.tsx CHANGED
@@ -85,7 +85,9 @@ export function Breakdown(props: BreakdownProps) {
85
85
  <Text size="sm" color={dimmed ? "muted" : "default"} numberOfLines={1} style={styles.label}>
86
86
  {item.label}
87
87
  </Text>
88
- <Text size="sm" weight={selected ? "semibold" : "regular"} color={dimmed ? "muted" : "default"} tabular>
88
+ {/* medium, matching `StackedBarChart`'s figure the value is what the
89
+ row is read for. `semibold` stays reserved for selection. */}
90
+ <Text size="sm" weight={selected ? "semibold" : "medium"} color={dimmed ? "muted" : "default"} tabular>
89
91
  {formatValue(item.value)}
90
92
  </Text>
91
93
  <Text size="xs" color="muted" tabular align="right" style={styles.share}>
@@ -0,0 +1,136 @@
1
+ import { useMemo } from "react";
2
+ import { ScrollView, View, StyleSheet } from "react-native";
3
+ import { Text } from "../text";
4
+ import { colors, solid } from "../colors";
5
+ import { CARD_RADIUS } from "../control_surface";
6
+ import { ListItem } from "../list_item";
7
+ import { useCalendar } from "./context";
8
+ import { compareEvents, isBanner } from "./layout";
9
+ import { dayDiff, dayHeading, daysInView, formatTime, isToday, startOfDay } from "./dates";
10
+ import type { CalendarEvent } from "./types";
11
+
12
+ /**
13
+ * The agenda: days as headings, their events as rows.
14
+ *
15
+ * **This is the narrow-screen answer, and it is a different SHAPE rather than a
16
+ * smaller month grid.** Seven equal columns on a 375px screen leave 53px per
17
+ * day for titles that need 160 — every event reads "Cont…" and the calendar can
18
+ * only tell you WHICH days have something, never what. Below `compactBelow` the
19
+ * root mounts this instead, and the one app that hit this had already reached
20
+ * the same conclusion by hand, wrapping the grid in a horizontal scroller with
21
+ * a 760px floor.
22
+ *
23
+ * Empty days are omitted. A month is mostly empty days, and printing thirty
24
+ * "nothing scheduled" rows to find four with content is not a list, it is a
25
+ * haystack.
26
+ */
27
+ export function CalendarAgenda() {
28
+ const { events, date, weekStartsOn, locale, labels, now, onEventPress } = useCalendar();
29
+ const days = useMemo(() => daysInView("agenda", date, weekStartsOn), [date, weekStartsOn]);
30
+
31
+ const grouped = useMemo(() => {
32
+ const first = days[0];
33
+ const byDay = days.map((day) => ({
34
+ day,
35
+ items: events
36
+ .filter((e) => {
37
+ const from = dayDiff(startOfDay(e.start), day);
38
+ const to = dayDiff(day, startOfDay(e.end ?? e.start));
39
+ if (from < 0 || to < 0) return false;
40
+ // A multi-day banner is listed ONCE, on the day it starts — or on the
41
+ // first day of the range when it started before it, so something
42
+ // already running is still visible. Repeating it per day put a
43
+ // 28-day campaign on twenty-eight rows and buried everything that
44
+ // only happens once.
45
+ if (!isBanner(e)) return true;
46
+ const startsHere = startOfDay(e.start).getTime() === day.getTime();
47
+ const ongoingAtOpen =
48
+ day.getTime() === first.getTime() && startOfDay(e.start).getTime() < first.getTime();
49
+ return startsHere || ongoingAtOpen;
50
+ })
51
+ .sort(compareEvents),
52
+ }));
53
+ return byDay.filter((g) => g.items.length > 0);
54
+ }, [events, days]);
55
+
56
+
57
+ if (grouped.length === 0) {
58
+ return (
59
+ <View style={styles.empty}>
60
+ <Text size="sm" color="muted">{labels.noEvents}</Text>
61
+ </View>
62
+ );
63
+ }
64
+
65
+ return (
66
+ <ScrollView style={styles.root} contentContainerStyle={styles.content}>
67
+ {grouped.map(({ day, items }) => {
68
+ const today = isToday(day, now);
69
+ return (
70
+ <View key={day.toISOString()} style={styles.group}>
71
+ <View style={styles.heading}>
72
+ <Text size="sm" weight={today ? "semibold" : "medium"} style={today ? styles.todayInk : undefined}>
73
+ {dayHeading(day, locale)}
74
+ </Text>
75
+ {today ? <View style={styles.todayDot} /> : null}
76
+ </View>
77
+ <View style={styles.card}>
78
+ {items.map((e) => (
79
+ <ListItem
80
+ key={e.id}
81
+ left={<View style={[styles.dot, { backgroundColor: solid(e.color ?? "teal") }]} />}
82
+ title={e.title}
83
+ description={describe(e, labels.allDay, locale)}
84
+ onPress={onEventPress ? () => onEventPress(e.id) : undefined}
85
+ />
86
+ ))}
87
+ </View>
88
+ </View>
89
+ );
90
+ })}
91
+ </ScrollView>
92
+ );
93
+ }
94
+
95
+ /** The row's second line: when it is, then whatever the event carries. Never
96
+ * the title again — the row above it already said that. */
97
+ function describe(event: CalendarEvent<unknown>, allDayLabel: string, locale?: string): string {
98
+ const when = isBanner(event)
99
+ ? bannerSpan(event, allDayLabel, locale)
100
+ : event.end
101
+ ? `${formatTime(event.start, locale)} – ${formatTime(event.end, locale)}`
102
+ : formatTime(event.start, locale);
103
+ return event.meta ? `${when} · ${event.meta}` : when;
104
+ }
105
+
106
+ /** A banner listed once has to say how far it runs, or the row claims the whole
107
+ * thing happens on the day it is filed under. */
108
+ function bannerSpan(event: CalendarEvent<unknown>, allDayLabel: string, locale?: string): string {
109
+ const end = event.end ? startOfDay(event.end) : null;
110
+ if (!end || dayDiff(event.start, end) <= 0) return allDayLabel;
111
+ const md = (d: Date) => new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" }).format(d);
112
+ return `${md(event.start)} – ${md(end)}`;
113
+ }
114
+
115
+ const styles = StyleSheet.create({
116
+ root: { flex: 1, backgroundColor: colors.white },
117
+ content: { paddingBottom: 16 },
118
+ group: { paddingTop: 0, paddingHorizontal: 8 },
119
+ card: { borderRadius: CARD_RADIUS, overflow: "hidden", backgroundColor: colors.zinc[50] },
120
+ // A day heading is separated by SPACE and weight, not a rule — the rule was
121
+ // a second statement of a boundary the gap already makes.
122
+ heading: {
123
+ flexDirection: "row",
124
+ alignItems: "center",
125
+ gap: 6,
126
+ paddingHorizontal: 14,
127
+ paddingTop: 16,
128
+ paddingBottom: 6,
129
+ },
130
+ todayInk: { color: colors.teal[700] },
131
+ todayDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.teal[600] },
132
+ // A dot, not a bar down the row's left edge: an edge is a border, and the
133
+ // row's own wash already states where it begins.
134
+ dot: { width: 8, height: 8, borderRadius: 4 },
135
+ empty: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.white },
136
+ });
@@ -0,0 +1,84 @@
1
+ import type { ReactNode } from "react";
2
+ import { View, StyleSheet } from "react-native";
3
+ import { Text } from "../text";
4
+ import { Button } from "../button";
5
+ import { IconButton } from "../icon_button";
6
+ import { SegmentedControl, type SegmentOption } from "../segmented_control";
7
+ import { useCalendar } from "./context";
8
+ import { addDays, addMonths, startOfDay, viewTitle } from "./dates";
9
+ import type { CalendarViewMode } from "./types";
10
+
11
+ /**
12
+ * The toolbar's height when it does not wrap — what the root has to subtract
13
+ * before asking whether the GRID still fits. It wraps only at widths that are
14
+ * already compact, where the answer is the agenda either way.
15
+ */
16
+ export const CALENDAR_TOOLBAR_HEIGHT = 57;
17
+
18
+ export interface CalendarToolbarProps {
19
+ /** Extra controls, rendered after the view switch — filters, an add action. */
20
+ children?: ReactNode;
21
+ }
22
+
23
+ /**
24
+ * Prev / today / next, the range title, and the view switch.
25
+ *
26
+ * **Optional on purpose.** Mounting `CalendarView` with children means the app
27
+ * supplies its own chrome, because a Lotics screen already has a header band and
28
+ * a calendar that welds one on produces two stacked bands. The switch is the
29
+ * kit's `SegmentedControl` rather than three hand-rolled pills — a mutually
30
+ * exclusive choice among a few peers is exactly what that control is.
31
+ */
32
+ export function CalendarToolbar(props: CalendarToolbarProps) {
33
+ const { date, setDate, view, setView, views, weekStartsOn, locale, labels } = useCalendar();
34
+
35
+ const step = (dir: number) => {
36
+ if (view === "month" || view === "agenda") setDate(addMonths(date, dir));
37
+ else setDate(addDays(date, dir * (view === "week" ? 7 : 1)));
38
+ };
39
+
40
+ const options: SegmentOption<CalendarViewMode>[] = views.map((v) => ({ label: labels[v], value: v }));
41
+
42
+ return (
43
+ <View style={styles.toolbar}>
44
+ <View style={styles.navGroup}>
45
+ <IconButton icon="chevron-left" size="md" accessibilityLabel={labels.previous} onPress={() => step(-1)} />
46
+ <Button color="secondary" title={labels.today} onPress={() => setDate(startOfDay(new Date()))} />
47
+ <IconButton icon="chevron-right" size="md" accessibilityLabel={labels.next} onPress={() => step(1)} />
48
+ </View>
49
+
50
+ <Text size="lg" weight="semibold" numberOfLines={1} style={styles.title}>
51
+ {viewTitle(view, date, weekStartsOn, locale)}
52
+ </Text>
53
+
54
+ <View style={styles.trailing}>
55
+ {props.children}
56
+ {options.length > 1 ? (
57
+ <SegmentedControl<CalendarViewMode>
58
+ accessibilityLabel={labels.month}
59
+ options={options}
60
+ value={view}
61
+ onValueChange={setView}
62
+ />
63
+ ) : null}
64
+ </View>
65
+ </View>
66
+ );
67
+ }
68
+
69
+ const styles = StyleSheet.create({
70
+ toolbar: {
71
+ flexDirection: "row",
72
+ alignItems: "center",
73
+ justifyContent: "space-between",
74
+ paddingHorizontal: 12,
75
+ paddingVertical: 10,
76
+ gap: 12,
77
+ flexWrap: "wrap",
78
+ },
79
+ navGroup: { flexDirection: "row", alignItems: "center", gap: 6 },
80
+ // The title takes the slack and ellipses; the two control groups hold their
81
+ // size, so a long month name never squeezes the buttons out of the band.
82
+ title: { flexGrow: 1, flexShrink: 1, minWidth: 0, textAlign: "center" },
83
+ trailing: { flexDirection: "row", alignItems: "center", gap: 8, flexShrink: 0 },
84
+ });
@@ -1,131 +1,224 @@
1
- import { useState } from "react";
2
- import { View, StyleSheet } from "react-native";
3
- import { Text } from "../text";
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import { View, StyleSheet, type LayoutChangeEvent } from "react-native";
4
3
  import { colors } from "../colors";
5
- import { FocusRingPressable } from "../focus_ring_pressable";
6
- import { MonthView } from "./month_view";
7
- import { TimeGridView } from "./time_grid_view";
8
- import { addDays, addMonths, viewTitle } from "./dates";
9
- import { DEFAULT_CALENDAR_LABELS } from "./types";
10
- import type { CalendarEvent, CalendarLabels, CalendarViewMode, Weekday } from "./types";
4
+ import { useLoticsLocale } from "../locale";
5
+ import { CalendarProvider, useCalendar, type CalendarContextValue } from "./context";
6
+ import { CalendarToolbar, CALENDAR_TOOLBAR_HEIGHT } from "./calendar_toolbar";
7
+ import { CalendarMonth, MIN_MONTH_HEIGHT } from "./month_view";
8
+ import { CalendarWeek, CalendarDay } from "./time_grid_view";
9
+ import { CalendarAgenda } from "./agenda_view";
10
+ import { daysInView, startOfDay } from "./dates";
11
+ import { expandRepeats, sourceEventId } from "./repeat";
12
+ import type { CalendarEvent, CalendarViewLabels, CalendarViewMode, Weekday } from "./types";
11
13
 
12
- const VIEW_ORDER: CalendarViewMode[] = ["month", "week", "day"];
14
+ /** Below this width the month/week grids stop being readable and the agenda
15
+ * takes over. Seven columns need ~110px each to hold a real title; under that
16
+ * the grid can only say WHICH days have something. */
17
+ const DEFAULT_COMPACT_BELOW = 720;
18
+
19
+ const ALL_VIEWS: CalendarViewMode[] = ["month", "week", "day", "agenda"];
20
+ /** What the switcher offers once the surface is too narrow for a grid. */
21
+ const COMPACT_VIEWS: CalendarViewMode[] = ["agenda", "day"];
13
22
 
14
23
  export interface CalendarViewProps<T = unknown> {
15
24
  events: CalendarEvent<T>[];
16
- defaultView?: CalendarViewMode;
25
+ /** Controlled anchor date. Omit and pass `defaultDate` for the drop-in form. */
26
+ date?: Date;
17
27
  defaultDate?: Date;
28
+ onDateChange?: (date: Date) => void;
29
+ /** Controlled view. Omit and pass `defaultView` for the drop-in form. */
30
+ view?: CalendarViewMode;
31
+ defaultView?: CalendarViewMode;
32
+ onViewChange?: (view: CalendarViewMode) => void;
33
+ /** Which modes the built-in toolbar offers. Default: all four. */
34
+ views?: CalendarViewMode[];
18
35
  weekStartsOn?: Weekday;
19
36
  locale?: string;
20
- /** User-facing chrome strings; defaults to English. */
21
- labels?: Partial<CalendarLabels>;
37
+ /** Per-instance overrides; the rest resolve from `LoticsLocale.calendarView`. */
38
+ labels?: Partial<CalendarViewLabels>;
39
+ /** Default visible hours of a day in the time grid. A viewport, not a filter —
40
+ * an event outside it still widens the grid rather than disappearing. */
41
+ dayStartHour?: number;
42
+ dayEndHour?: number;
43
+ /** Width below which month/week fall back to the agenda. */
44
+ compactBelow?: number;
45
+ /**
46
+ * What counts as "now" — the today badge, the now line, the hour a time grid
47
+ * opens at. Defaults to the browser's clock.
48
+ *
49
+ * **This is not a timezone conversion, and the calendar deliberately has no
50
+ * `timeZone` prop.** Lotics stores a datetime as a naive WALL CLOCK, so
51
+ * "14:30" means 14:30 to the business and there is nothing to convert; a
52
+ * calendar that re-projected it into the viewer's zone would move every
53
+ * event. What genuinely does depend on where the reader is sitting is the
54
+ * answer to "what day is it", so that is the only thing taken as a prop.
55
+ */
56
+ now?: Date;
22
57
  onEventPress?: (event: CalendarEvent<T>) => void;
23
- onDayPress?: (day: Date) => void;
24
- /** When set, month-view event chips become draggable; dropping on another day
25
- * reschedules the event (duration preserved). Receives event + new start/end. */
26
- onEventDrop?: (event: CalendarEvent<T>, newStart: Date, newEnd: Date | null) => void;
58
+ /** An empty day cell or hour slot was pressed. The calendar never writes —
59
+ * this hands you a range so your own form or drawer can. */
60
+ onSlotPress?: (start: Date, end: Date) => void;
61
+ /** Replace the contents of an event chip; the press target, the lane and the
62
+ * geometry stay the calendar's. One slot rather than a prop per decoration.
63
+ * It is called for banners and timed chips alike — branch on the exported
64
+ * `isBanner(event)` when a multi-day bar should render differently. */
65
+ renderEvent?: (event: CalendarEvent<T>) => ReactNode;
66
+ /** Compose the parts yourself — `CalendarToolbar`, `CalendarMonth`,
67
+ * `CalendarWeek`, `CalendarDay`, `CalendarAgenda`. Omit for toolbar + view. */
68
+ children?: ReactNode;
27
69
  }
28
70
 
29
71
  /**
30
- * Full calendar: a toolbar (prev / today / next + month/week/day switch) over
31
- * the month grid or the week/day time grid. Date + view are owned here so the
32
- * primitive is drop-in; the consumer only supplies events + callbacks.
72
+ * A calendar of events month grid, week/day time grid, or agenda.
73
+ *
74
+ * **Drop-in or composed.** `<CalendarView events={…} />` renders the toolbar and
75
+ * the current view. Passing children replaces that with whatever you mount,
76
+ * against the same state: an app screen already has a header band, and a
77
+ * calendar that welds its own on stacks two.
78
+ *
79
+ * **Controlled or not.** `date`/`view` with their `on*Change` callbacks let the
80
+ * surrounding screen drive — a filter chip, a route, a "jump to today" that
81
+ * lives in the app's own header. Omit them and the calendar owns its state.
82
+ *
83
+ * **It never writes.** Press an event or an empty slot and you get a callback;
84
+ * every mutation is the app's, through whatever form or workflow it uses.
85
+ *
86
+ * For rows that are RESOURCES rather than days — a channel, an owner, a vehicle
87
+ * — reach for `GanttView` instead. Both pack bars with the same layout core.
33
88
  */
34
89
  export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
35
- const { events, defaultView = "month", defaultDate, weekStartsOn = 1, locale, onEventPress, onDayPress, onEventDrop } = props;
36
- const L = { ...DEFAULT_CALENDAR_LABELS, ...props.labels };
37
- const [view, setView] = useState<CalendarViewMode>(defaultView);
38
- const [date, setDate] = useState<Date>(defaultDate ?? new Date());
90
+ const {
91
+ events, weekStartsOn = 1, locale, views = ALL_VIEWS,
92
+ dayStartHour = 0, dayEndHour = 24, compactBelow = DEFAULT_COMPACT_BELOW, now,
93
+ onEventPress, onSlotPress, renderEvent, children,
94
+ } = props;
39
95
 
40
- const step = (dir: number) =>
41
- setDate((d) => (view === "month" ? addMonths(d, dir) : addDays(d, dir * (view === "week" ? 7 : 1))));
96
+ const pack = useLoticsLocale();
97
+ const labels: CalendarViewLabels = { ...pack.calendarView, ...props.labels };
42
98
 
43
- // Drilling into a day (month "+N more" or a day cell) opens the day grid.
44
- const drillToDay = (day: Date) => {
45
- setDate(day);
46
- setView("day");
47
- onDayPress?.(day);
99
+ // One reading of the clock for the whole tree, so the badge, the now line and
100
+ // the scroll target cannot disagree across a midnight boundary mid-render.
101
+ const referenceNow = now ?? new Date();
102
+ const [ownDate, setOwnDate] = useState<Date>(() => startOfDay(props.defaultDate ?? now ?? new Date()));
103
+ const [ownView, setOwnView] = useState<CalendarViewMode>(props.defaultView ?? "month");
104
+ const date = props.date ?? ownDate;
105
+ const view = props.view ?? ownView;
106
+
107
+ const setDate = (next: Date) => {
108
+ if (props.date === undefined) setOwnDate(next);
109
+ props.onDateChange?.(next);
110
+ };
111
+ const setView = (next: CalendarViewMode) => {
112
+ if (props.view === undefined) setOwnView(next);
113
+ props.onViewChange?.(next);
48
114
  };
49
115
 
50
- return (
51
- <View style={styles.root}>
52
- <View style={styles.toolbar}>
53
- <View style={styles.navGroup}>
54
- <FocusRingPressable accessibilityRole="button" onPress={() => step(-1)} accessibilityLabel={L.previous} style={styles.iconBtn}>
55
- <Text size="lg" color="muted">‹</Text>
56
- </FocusRingPressable>
57
- <FocusRingPressable accessibilityRole="button" onPress={() => setDate(new Date())} style={styles.todayBtn}>
58
- <Text size="sm" weight="medium">{L.today}</Text>
59
- </FocusRingPressable>
60
- <FocusRingPressable accessibilityRole="button" onPress={() => step(1)} accessibilityLabel={L.next} style={styles.iconBtn}>
61
- <Text size="lg" color="muted">›</Text>
62
- </FocusRingPressable>
63
- </View>
116
+ const [size, setSize] = useState({ width: 0, height: 0 });
117
+ const onLayout = (e: LayoutChangeEvent) => {
118
+ const { width: w, height: h } = e.nativeEvent.layout;
119
+ const next = { width: Math.round(w), height: Math.round(h) };
120
+ setSize((prev) =>
121
+ Math.abs(prev.width - next.width) > 1 || Math.abs(prev.height - next.height) > 1 ? next : prev,
122
+ );
123
+ };
124
+ // 0 means "not measured yet" — assume roomy, so the first paint of a normal
125
+ // desktop calendar is not an agenda that flips to a grid a frame later.
126
+ const tooNarrow = size.width > 0 && size.width < compactBelow;
127
+ // A grid is unreadable in TWO directions, and only width was ever checked. A
128
+ // 900px-wide, 360px-tall month gave every row 20px of lane area and rendered
129
+ // nothing but "+N more" on all thirty-one days.
130
+ //
131
+ // The measurement is of the WHOLE calendar, so the chrome comes off before
132
+ // the grid is asked whether it fits — comparing the total against a grid-only
133
+ // floor let a 420px calendar through and it rendered exactly the wall of
134
+ // "+N more" this check exists to prevent.
135
+ const chromeHeight = children ? 0 : CALENDAR_TOOLBAR_HEIGHT;
136
+ const tooShort = size.height > 0 && size.height - chromeHeight < MIN_MONTH_HEIGHT;
137
+ const compact = tooNarrow || tooShort;
138
+ const effectiveView: CalendarViewMode =
139
+ compact && (view === "month" || view === "week") ? "agenda" : view;
64
140
 
65
- <Text size="lg" weight="semibold" style={styles.title} numberOfLines={1}>
66
- {viewTitle(view, date, weekStartsOn, locale)}
67
- </Text>
141
+ // Repeating events are expanded to the VIEW's own range, so a daily timetable
142
+ // costs one event rather than a year of them, and navigating a month back
143
+ // does not have to have been materialised in advance.
144
+ const days = useMemo(
145
+ () => daysInView(effectiveView, date, weekStartsOn),
146
+ [effectiveView, date, weekStartsOn],
147
+ );
148
+ const shown = useMemo(
149
+ () => expandRepeats(events, days[0], days[days.length - 1]),
150
+ [events, days],
151
+ );
68
152
 
69
- <View style={styles.viewSwitch}>
70
- {VIEW_ORDER.map((v) => (
71
- <FocusRingPressable accessibilityRole="button"
72
- key={v}
73
- onPress={() => setView(v)}
74
- style={[styles.viewBtn, view === v && styles.viewBtnActive]}
75
- >
76
- <Text size="sm" weight={view === v ? "medium" : "regular"} color={view === v ? "default" : "muted"}>
77
- {L[v]}
78
- </Text>
79
- </FocusRingPressable>
80
- ))}
81
- </View>
82
- </View>
153
+ // Resolved against the typed array, so no part ever handles a type it doesn't
154
+ // know and nothing has to be cast back on the way out. An occurrence resolves
155
+ // through its source id, so pressing the third Tuesday of a series still
156
+ // hands the caller the row the series came from.
157
+ const byId = useMemo(() => new Map(events.map((e) => [e.id, e])), [events]);
158
+ const resolve = (id: string) => byId.get(id) ?? byId.get(sourceEventId(id));
83
159
 
84
- <View style={{ flex: 1 }}>
85
- {view === "month" ? (
86
- <MonthView
87
- date={date}
88
- events={events}
89
- weekStartsOn={weekStartsOn}
90
- locale={locale}
91
- moreLabel={L.more}
92
- onEventPress={onEventPress}
93
- onDayPress={drillToDay}
94
- onEventDrop={onEventDrop}
95
- />
96
- ) : (
97
- <TimeGridView
98
- mode={view}
99
- date={date}
100
- events={events}
101
- weekStartsOn={weekStartsOn}
102
- locale={locale}
103
- allDayLabel={L.allDay}
104
- onEventPress={onEventPress}
105
- />
160
+ const value: CalendarContextValue = {
161
+ events: shown,
162
+ date,
163
+ setDate,
164
+ view: effectiveView,
165
+ setView,
166
+ views: compact ? COMPACT_VIEWS.filter((v) => views.includes(v)) : views,
167
+ weekStartsOn,
168
+ locale,
169
+ labels,
170
+ now: referenceNow,
171
+ dayStartMinutes: Math.max(0, Math.min(23, dayStartHour)) * 60,
172
+ dayEndMinutes: Math.max(1, Math.min(24, dayEndHour)) * 60,
173
+ onEventPress: onEventPress
174
+ ? (id) => {
175
+ const event = resolve(id);
176
+ if (event) onEventPress(event);
177
+ }
178
+ : undefined,
179
+ onSlotPress,
180
+ renderEvent: renderEvent
181
+ ? (event) => {
182
+ const typed = resolve(event.id);
183
+ return typed ? renderEvent(typed) : null;
184
+ }
185
+ : undefined,
186
+ };
187
+
188
+ return (
189
+ <View style={styles.root} onLayout={onLayout}>
190
+ <CalendarProvider value={value}>
191
+ {children ?? (
192
+ <>
193
+ <CalendarToolbar />
194
+ <CalendarBody />
195
+ </>
106
196
  )}
107
- </View>
197
+ </CalendarProvider>
198
+ </View>
199
+ );
200
+ }
201
+
202
+ /** The current view's grid, without the toolbar — for composing your own chrome
203
+ * over the calendar's own view state. */
204
+ export function CalendarBody() {
205
+ const { view } = useCalendar();
206
+ return (
207
+ <View style={styles.body}>
208
+ {view === "month" ? (
209
+ <CalendarMonth />
210
+ ) : view === "week" ? (
211
+ <CalendarWeek />
212
+ ) : view === "day" ? (
213
+ <CalendarDay />
214
+ ) : (
215
+ <CalendarAgenda />
216
+ )}
108
217
  </View>
109
218
  );
110
219
  }
111
220
 
112
221
  const styles = StyleSheet.create({
113
- root: { flex: 1, backgroundColor: colors.white },
114
- toolbar: {
115
- flexDirection: "row",
116
- alignItems: "center",
117
- justifyContent: "space-between",
118
- paddingHorizontal: 14,
119
- paddingVertical: 10,
120
- gap: 12,
121
- borderBottomWidth: 1,
122
- borderBottomColor: colors.border,
123
- },
124
- navGroup: { flexDirection: "row", alignItems: "center", gap: 4 },
125
- iconBtn: { width: 30, height: 30, borderRadius: 6, alignItems: "center", justifyContent: "center" },
126
- todayBtn: { paddingHorizontal: 12, height: 30, borderRadius: 6, borderWidth: 1, borderColor: colors.border, alignItems: "center", justifyContent: "center" },
127
- title: { flex: 1, textAlign: "center" },
128
- viewSwitch: { flexDirection: "row", backgroundColor: colors.zinc[100], borderRadius: 10, padding: 2 },
129
- viewBtn: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 8 },
130
- viewBtnActive: { backgroundColor: colors.white },
222
+ root: { flex: 1, backgroundColor: colors.white, minWidth: 0 },
223
+ body: { flex: 1, minWidth: 0 },
131
224
  });