@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.
- package/AGENTS.md +20 -0
- package/MIGRATION.md +58 -0
- package/docs/catalog.md +38 -9
- package/docs/composition.md +52 -0
- package/docs/reviewing.md +19 -2
- package/docs/templates.md +8 -2
- package/examples/tpl_calendar.tsx +22 -29
- package/package.json +1 -1
- package/src/board.tsx +1 -2
- package/src/calendar/agenda_view.tsx +136 -0
- package/src/calendar/calendar_toolbar.tsx +84 -0
- package/src/calendar/calendar_view.tsx +196 -103
- package/src/calendar/context.ts +47 -0
- package/src/calendar/dates.ts +36 -6
- package/src/calendar/event_chip.tsx +141 -0
- package/src/calendar/index.ts +31 -8
- package/src/calendar/layout.ts +113 -11
- package/src/calendar/month_view.tsx +194 -150
- package/src/calendar/repeat.ts +174 -0
- package/src/calendar/time_grid_view.tsx +182 -202
- package/src/calendar/types.ts +37 -11
- package/src/control_surface.ts +28 -0
- package/src/deadline.ts +10 -0
- package/src/file_row.tsx +26 -3
- package/src/gantt/gantt_view.tsx +212 -119
- package/src/gantt/index.ts +2 -2
- package/src/gantt/scale.ts +38 -2
- package/src/gantt/types.ts +34 -7
- package/src/legend_item.tsx +14 -1
- package/src/locale.tsx +30 -0
- package/src/matrix.tsx +19 -5
- package/src/option_list.tsx +11 -1
- package/src/use_option_list.ts +13 -1
|
@@ -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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
21
|
-
labels?: Partial<
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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 {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
41
|
-
|
|
96
|
+
const pack = useLoticsLocale();
|
|
97
|
+
const labels: CalendarViewLabels = { ...pack.calendarView, ...props.labels };
|
|
42
98
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
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
|
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import type { CalendarEvent, CalendarViewLabels, CalendarViewMode, Weekday } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What every calendar part reads.
|
|
6
|
+
*
|
|
7
|
+
* Events are held as `CalendarEvent<unknown>` and the press/render callbacks are
|
|
8
|
+
* keyed by event **id**, not by the typed event. That is what lets the root be
|
|
9
|
+
* generic over the consumer's row type while the parts stay plain: the root
|
|
10
|
+
* closes over its own typed array and resolves the id back before calling out,
|
|
11
|
+
* so no part ever has to launder a type it does not know.
|
|
12
|
+
*/
|
|
13
|
+
export interface CalendarContextValue {
|
|
14
|
+
events: CalendarEvent<unknown>[];
|
|
15
|
+
/** The anchor date; which days are in view is each part's own business. */
|
|
16
|
+
date: Date;
|
|
17
|
+
setDate: (date: Date) => void;
|
|
18
|
+
view: CalendarViewMode;
|
|
19
|
+
setView: (view: CalendarViewMode) => void;
|
|
20
|
+
/** Which modes the toolbar offers. */
|
|
21
|
+
views: CalendarViewMode[];
|
|
22
|
+
weekStartsOn: Weekday;
|
|
23
|
+
locale?: string;
|
|
24
|
+
labels: CalendarViewLabels;
|
|
25
|
+
/** What the calendar treats as "now" — the today badge, the red line, and the
|
|
26
|
+
* hour a grid opens at. Supplied rather than read from the clock so a
|
|
27
|
+
* workspace in another zone can hand over ITS wall clock, and so a test can
|
|
28
|
+
* pin a day. */
|
|
29
|
+
now: Date;
|
|
30
|
+
/** Default visible window of a day in the time grid, in minutes from midnight.
|
|
31
|
+
* A viewport, never a filter — an event outside it still widens the grid. */
|
|
32
|
+
dayStartMinutes: number;
|
|
33
|
+
dayEndMinutes: number;
|
|
34
|
+
onEventPress?: (id: string) => void;
|
|
35
|
+
onSlotPress?: (start: Date, end: Date) => void;
|
|
36
|
+
renderEvent?: (event: CalendarEvent<unknown>) => ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const CalendarContext = createContext<CalendarContextValue | null>(null);
|
|
40
|
+
|
|
41
|
+
export const CalendarProvider = CalendarContext.Provider;
|
|
42
|
+
|
|
43
|
+
export function useCalendar(): CalendarContextValue {
|
|
44
|
+
const ctx = useContext(CalendarContext);
|
|
45
|
+
if (!ctx) throw new Error("Calendar parts must be used within a CalendarView");
|
|
46
|
+
return ctx;
|
|
47
|
+
}
|
package/src/calendar/dates.ts
CHANGED
|
@@ -32,7 +32,11 @@ export function endOfMonth(d: Date): Date {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export function addMonths(d: Date, n: number): Date {
|
|
35
|
-
|
|
35
|
+
// Clamp the day so 31 Jan + 1 month is 28/29 Feb, not 2/3 March. Stepping a
|
|
36
|
+
// month at a time through a 31-day month otherwise skips February entirely.
|
|
37
|
+
const target = new Date(d.getFullYear(), d.getMonth() + n, 1);
|
|
38
|
+
const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
|
|
39
|
+
return new Date(target.getFullYear(), target.getMonth(), Math.min(d.getDate(), lastDay));
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export function isSameDay(a: Date, b: Date): boolean {
|
|
@@ -60,16 +64,37 @@ export function minutesSinceMidnight(d: Date): number {
|
|
|
60
64
|
return d.getHours() * 60 + d.getMinutes();
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* How many week rows this month actually needs (4–6).
|
|
69
|
+
*
|
|
70
|
+
* The grid used to be a hardcoded 42 days "for a stable grid height", which buys
|
|
71
|
+
* a stable height at the price of a whole empty row in most months — June 2026
|
|
72
|
+
* rendered five weeks of dates and a sixth row of greyed-out July, eating ~17%
|
|
73
|
+
* of the calendar's height to show nothing. A month grid should be as tall as
|
|
74
|
+
* the month is; the row height absorbs the difference.
|
|
75
|
+
*/
|
|
76
|
+
export function weekRowsInMonth(date: Date, weekStartsOn: Weekday = 1): number {
|
|
77
|
+
const gridStart = startOfWeek(startOfMonth(date), weekStartsOn);
|
|
78
|
+
const last = endOfMonth(date);
|
|
79
|
+
return Math.ceil((dayDiff(gridStart, last) + 1) / 7);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The day columns a view spans: 7 for week, 1 for day, whole weeks for month/agenda. */
|
|
64
83
|
export function daysInView(mode: CalendarViewMode, date: Date, weekStartsOn: Weekday = 1): Date[] {
|
|
65
84
|
if (mode === "day") return [startOfDay(date)];
|
|
66
85
|
if (mode === "week") {
|
|
67
86
|
const start = startOfWeek(date, weekStartsOn);
|
|
68
87
|
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
|
69
88
|
}
|
|
70
|
-
|
|
89
|
+
if (mode === "agenda") {
|
|
90
|
+
// The agenda lists the month it is pointed at, so prev/next mean the same
|
|
91
|
+
// thing it does in month view and the toolbar title stays honest.
|
|
92
|
+
const start = startOfMonth(date);
|
|
93
|
+
const days = endOfMonth(date).getDate();
|
|
94
|
+
return Array.from({ length: days }, (_, i) => addDays(start, i));
|
|
95
|
+
}
|
|
71
96
|
const gridStart = startOfWeek(startOfMonth(date), weekStartsOn);
|
|
72
|
-
return Array.from({ length:
|
|
97
|
+
return Array.from({ length: weekRowsInMonth(date, weekStartsOn) * 7 }, (_, i) => addDays(gridStart, i));
|
|
73
98
|
}
|
|
74
99
|
|
|
75
100
|
// ─── Localized labels (Intl, browser/workspace locale) ──────────────────────
|
|
@@ -87,9 +112,14 @@ export function hourLabel(hour: number): string {
|
|
|
87
112
|
return `${String(hour).padStart(2, "0")}:00`;
|
|
88
113
|
}
|
|
89
114
|
|
|
90
|
-
/**
|
|
115
|
+
/** Agenda day heading: "Thu 12 March". */
|
|
116
|
+
export function dayHeading(d: Date, locale?: string): string {
|
|
117
|
+
return new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "long" }).format(d);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Header title: "March 2026" (month/agenda) or the week/day range. */
|
|
91
121
|
export function viewTitle(mode: CalendarViewMode, date: Date, weekStartsOn: Weekday, locale?: string): string {
|
|
92
|
-
if (mode === "month") {
|
|
122
|
+
if (mode === "month" || mode === "agenda") {
|
|
93
123
|
return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(date);
|
|
94
124
|
}
|
|
95
125
|
if (mode === "day") {
|