@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.
- package/package.json +24 -7
- package/src/alert.tsx +35 -5
- package/src/avatar.tsx +28 -3
- package/src/back_button.tsx +4 -2
- package/src/button.tsx +35 -5
- package/src/calendar/calendar_view.tsx +127 -0
- package/src/calendar/dates.ts +102 -0
- package/src/calendar/index.ts +20 -0
- package/src/calendar/layout.test.ts +103 -0
- package/src/calendar/layout.ts +142 -0
- package/src/calendar/month_view.tsx +159 -0
- package/src/calendar/time_grid_view.tsx +263 -0
- package/src/calendar/types.ts +67 -0
- package/src/checkbox_input.tsx +9 -3
- package/src/command_menu.tsx +50 -4
- package/src/dialog.tsx +1 -1
- package/src/download.ts +14 -2
- package/src/form_field.tsx +77 -25
- package/src/form_switch.tsx +22 -3
- package/src/gantt/gantt_view.tsx +145 -0
- package/src/gantt/index.ts +5 -0
- package/src/gantt/scale.test.ts +47 -0
- package/src/gantt/scale.ts +92 -0
- package/src/gantt/types.ts +51 -0
- package/src/grid/select_header_cell.tsx +1 -0
- package/src/icon.tsx +14 -8
- package/src/icon_button.tsx +10 -4
- package/src/index.css +11 -0
- package/src/kanban/constants.ts +18 -0
- package/src/kanban/default_renderers.tsx +160 -0
- package/src/kanban/drag_preview.tsx +157 -0
- package/src/kanban/index.ts +13 -0
- package/src/kanban/insert_card_zone.tsx +135 -0
- package/src/kanban/kanban_board.tsx +616 -0
- package/src/kanban/kanban_card.tsx +312 -0
- package/src/kanban/kanban_column.tsx +487 -0
- package/src/kanban/placeholders.tsx +54 -0
- package/src/kanban/types.ts +116 -0
- package/src/landmark.tsx +34 -0
- package/src/menu_button.tsx +21 -0
- package/src/menu_list_item.tsx +3 -0
- package/src/number_input.tsx +10 -1
- package/src/pill_button.tsx +1 -0
- package/src/popover.tsx +47 -2
- package/src/popover_header.tsx +4 -2
- package/src/pressable_highlight.tsx +24 -0
- package/src/radio_picker.tsx +63 -5
- package/src/section_heading.tsx +5 -3
- package/src/skip_link.tsx +46 -0
- package/src/switch.tsx +9 -1
- package/src/switch_button.tsx +3 -0
- package/src/tabs.tsx +81 -19
- package/src/text.tsx +33 -0
- package/src/text_input_field.tsx +31 -0
- 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
|
+
}
|
package/src/checkbox_input.tsx
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
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>
|