@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.
- package/AGENTS.md +20 -0
- package/MIGRATION.md +58 -0
- package/docs/catalog.md +72 -13
- package/docs/composition.md +94 -6
- package/docs/reviewing.md +56 -5
- package/docs/templates.md +8 -2
- package/examples/tpl_calendar.tsx +22 -29
- package/examples/tpl_item_list.tsx +7 -2
- package/package.json +1 -1
- package/src/accordion.tsx +11 -5
- package/src/bar_chart.tsx +3 -1
- package/src/board.tsx +1 -2
- package/src/breakdown.tsx +3 -1
- 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/charge_lines.tsx +24 -5
- package/src/control_surface.ts +28 -0
- package/src/deadline.ts +10 -0
- package/src/file_row.tsx +26 -3
- package/src/finding.tsx +1 -1
- package/src/form_text_input.tsx +9 -2
- 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/inline_slot.tsx +16 -2
- package/src/inline_static.tsx +18 -4
- package/src/legend_item.tsx +14 -1
- package/src/locale.tsx +30 -0
- package/src/matrix.tsx +19 -5
- package/src/member_chip.tsx +18 -1
- package/src/menu_button.tsx +12 -2
- package/src/option_list.tsx +11 -1
- package/src/progress_bar.tsx +14 -1
- package/src/record_summary.tsx +5 -0
- package/src/stacked_bar_chart.tsx +4 -1
- package/src/table_fit.ts +18 -1
- package/src/thumbnail_stack.tsx +18 -3
- package/src/use_option_list.ts +13 -1
package/package.json
CHANGED
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
|
|
179
|
-
*
|
|
180
|
-
*
|
|
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
|
|
214
|
-
//
|
|
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">
|
|
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
|
-
|
|
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 {
|
|
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
|
});
|