@lotics/ui 1.9.0 → 1.11.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/package.json +6 -1
- package/src/calendar/calendar_view.tsx +122 -0
- package/src/calendar/dates.ts +102 -0
- package/src/calendar/index.ts +19 -0
- package/src/calendar/layout.test.ts +103 -0
- package/src/calendar/layout.ts +142 -0
- package/src/calendar/month_view.tsx +157 -0
- package/src/calendar/time_grid_view.tsx +261 -0
- package/src/calendar/types.ts +38 -0
- package/src/download.ts +48 -0
- package/src/gantt/gantt_view.tsx +131 -0
- package/src/gantt/index.ts +4 -0
- package/src/gantt/scale.test.ts +47 -0
- package/src/gantt/scale.ts +92 -0
- package/src/gantt/types.ts +36 -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/ring_gauge.tsx +72 -0
- package/src/tokens.ts +15 -5
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
7
7
|
"./colors": "./src/colors.ts",
|
|
8
8
|
"./mime": "./src/mime.ts",
|
|
9
|
+
"./download": "./src/download.ts",
|
|
9
10
|
"./file_badge": "./src/file_badge.tsx",
|
|
10
11
|
"./file_thumbnail": "./src/file_thumbnail.tsx",
|
|
11
12
|
"./file_gallery_modal": "./src/file_gallery_modal.tsx",
|
|
@@ -18,6 +19,10 @@
|
|
|
18
19
|
"./trend_chip": "./src/trend_chip.tsx",
|
|
19
20
|
"./section_card": "./src/section_card.tsx",
|
|
20
21
|
"./kpi_card": "./src/kpi_card.tsx",
|
|
22
|
+
"./kanban": "./src/kanban/index.ts",
|
|
23
|
+
"./calendar": "./src/calendar/index.ts",
|
|
24
|
+
"./gantt": "./src/gantt/index.ts",
|
|
25
|
+
"./ring_gauge": "./src/ring_gauge.tsx",
|
|
21
26
|
"./alert_row": "./src/alert_row.tsx",
|
|
22
27
|
"./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
|
|
23
28
|
"./legend_item": "./src/legend_item.tsx",
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { View, Pressable, StyleSheet } from "react-native";
|
|
3
|
+
import { Text } from "../text";
|
|
4
|
+
import { colors } from "../colors";
|
|
5
|
+
import { MonthView } from "./month_view";
|
|
6
|
+
import { TimeGridView } from "./time_grid_view";
|
|
7
|
+
import { addDays, addMonths, viewTitle } from "./dates";
|
|
8
|
+
import type { CalendarEvent, CalendarViewMode, Weekday } from "./types";
|
|
9
|
+
|
|
10
|
+
const VIEW_LABELS: Record<CalendarViewMode, string> = { month: "Tháng", week: "Tuần", day: "Ngày" };
|
|
11
|
+
const VIEW_ORDER: CalendarViewMode[] = ["month", "week", "day"];
|
|
12
|
+
|
|
13
|
+
export interface CalendarViewProps<T = unknown> {
|
|
14
|
+
events: CalendarEvent<T>[];
|
|
15
|
+
defaultView?: CalendarViewMode;
|
|
16
|
+
defaultDate?: Date;
|
|
17
|
+
weekStartsOn?: Weekday;
|
|
18
|
+
locale?: string;
|
|
19
|
+
onEventPress?: (event: CalendarEvent<T>) => void;
|
|
20
|
+
onDayPress?: (day: Date) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Full calendar: a toolbar (prev / today / next + month·week·day switch) over
|
|
25
|
+
* the month grid or the week/day time grid. Date + view are owned here so the
|
|
26
|
+
* primitive is drop-in; the consumer only supplies events + callbacks.
|
|
27
|
+
*/
|
|
28
|
+
export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
|
|
29
|
+
const { events, defaultView = "month", defaultDate, weekStartsOn = 1, locale, onEventPress, onDayPress } = props;
|
|
30
|
+
const [view, setView] = useState<CalendarViewMode>(defaultView);
|
|
31
|
+
const [date, setDate] = useState<Date>(defaultDate ?? new Date());
|
|
32
|
+
|
|
33
|
+
const step = (dir: number) =>
|
|
34
|
+
setDate((d) => (view === "month" ? addMonths(d, dir) : addDays(d, dir * (view === "week" ? 7 : 1))));
|
|
35
|
+
|
|
36
|
+
// Drilling into a day (month "+N more" or a day cell) opens the day grid.
|
|
37
|
+
const drillToDay = (day: Date) => {
|
|
38
|
+
setDate(day);
|
|
39
|
+
setView("day");
|
|
40
|
+
onDayPress?.(day);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<View style={styles.root}>
|
|
45
|
+
<View style={styles.toolbar}>
|
|
46
|
+
<View style={styles.navGroup}>
|
|
47
|
+
<Pressable onPress={() => step(-1)} accessibilityRole="button" accessibilityLabel="Trước" style={styles.iconBtn}>
|
|
48
|
+
<Text size="lg" color="muted">‹</Text>
|
|
49
|
+
</Pressable>
|
|
50
|
+
<Pressable onPress={() => setDate(new Date())} accessibilityRole="button" style={styles.todayBtn}>
|
|
51
|
+
<Text size="sm" weight="medium">Hôm nay</Text>
|
|
52
|
+
</Pressable>
|
|
53
|
+
<Pressable onPress={() => step(1)} accessibilityRole="button" accessibilityLabel="Sau" style={styles.iconBtn}>
|
|
54
|
+
<Text size="lg" color="muted">›</Text>
|
|
55
|
+
</Pressable>
|
|
56
|
+
</View>
|
|
57
|
+
|
|
58
|
+
<Text size="lg" weight="semibold" style={styles.title} numberOfLines={1}>
|
|
59
|
+
{viewTitle(view, date, weekStartsOn, locale)}
|
|
60
|
+
</Text>
|
|
61
|
+
|
|
62
|
+
<View style={styles.viewSwitch}>
|
|
63
|
+
{VIEW_ORDER.map((v) => (
|
|
64
|
+
<Pressable
|
|
65
|
+
key={v}
|
|
66
|
+
onPress={() => setView(v)}
|
|
67
|
+
accessibilityRole="button"
|
|
68
|
+
style={[styles.viewBtn, view === v && styles.viewBtnActive]}
|
|
69
|
+
>
|
|
70
|
+
<Text size="sm" weight={view === v ? "medium" : "regular"} color={view === v ? "default" : "muted"}>
|
|
71
|
+
{VIEW_LABELS[v]}
|
|
72
|
+
</Text>
|
|
73
|
+
</Pressable>
|
|
74
|
+
))}
|
|
75
|
+
</View>
|
|
76
|
+
</View>
|
|
77
|
+
|
|
78
|
+
<View style={{ flex: 1 }}>
|
|
79
|
+
{view === "month" ? (
|
|
80
|
+
<MonthView
|
|
81
|
+
date={date}
|
|
82
|
+
events={events}
|
|
83
|
+
weekStartsOn={weekStartsOn}
|
|
84
|
+
locale={locale}
|
|
85
|
+
onEventPress={onEventPress}
|
|
86
|
+
onDayPress={drillToDay}
|
|
87
|
+
/>
|
|
88
|
+
) : (
|
|
89
|
+
<TimeGridView
|
|
90
|
+
mode={view}
|
|
91
|
+
date={date}
|
|
92
|
+
events={events}
|
|
93
|
+
weekStartsOn={weekStartsOn}
|
|
94
|
+
locale={locale}
|
|
95
|
+
onEventPress={onEventPress}
|
|
96
|
+
/>
|
|
97
|
+
)}
|
|
98
|
+
</View>
|
|
99
|
+
</View>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const styles = StyleSheet.create({
|
|
104
|
+
root: { flex: 1, backgroundColor: colors.white },
|
|
105
|
+
toolbar: {
|
|
106
|
+
flexDirection: "row",
|
|
107
|
+
alignItems: "center",
|
|
108
|
+
justifyContent: "space-between",
|
|
109
|
+
paddingHorizontal: 14,
|
|
110
|
+
paddingVertical: 10,
|
|
111
|
+
gap: 12,
|
|
112
|
+
borderBottomWidth: 1,
|
|
113
|
+
borderBottomColor: colors.border,
|
|
114
|
+
},
|
|
115
|
+
navGroup: { flexDirection: "row", alignItems: "center", gap: 4 },
|
|
116
|
+
iconBtn: { width: 30, height: 30, borderRadius: 6, alignItems: "center", justifyContent: "center" },
|
|
117
|
+
todayBtn: { paddingHorizontal: 12, height: 30, borderRadius: 6, borderWidth: 1, borderColor: colors.border, alignItems: "center", justifyContent: "center" },
|
|
118
|
+
title: { flex: 1, textAlign: "center" },
|
|
119
|
+
viewSwitch: { flexDirection: "row", backgroundColor: colors.zinc[100], borderRadius: 8, padding: 2 },
|
|
120
|
+
viewBtn: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 6 },
|
|
121
|
+
viewBtnActive: { backgroundColor: colors.white },
|
|
122
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { CalendarViewMode, Weekday } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Self-contained date helpers (native `Date` + `Intl`) — no date library, so the
|
|
5
|
+
* calendar primitive adds no peer dependency to apps. All math is local-time, as
|
|
6
|
+
* a calendar displays; we never cross into UTC arithmetic.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const MS_PER_DAY = 86_400_000;
|
|
10
|
+
|
|
11
|
+
export function startOfDay(d: Date): Date {
|
|
12
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function addDays(d: Date, n: number): Date {
|
|
16
|
+
// Construct via Y/M/D so DST day-boundary shifts can't drift the calendar day.
|
|
17
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function startOfWeek(d: Date, weekStartsOn: Weekday = 1): Date {
|
|
21
|
+
const day = d.getDay();
|
|
22
|
+
const diff = (day - weekStartsOn + 7) % 7;
|
|
23
|
+
return addDays(startOfDay(d), -diff);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function startOfMonth(d: Date): Date {
|
|
27
|
+
return new Date(d.getFullYear(), d.getMonth(), 1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function endOfMonth(d: Date): Date {
|
|
31
|
+
return new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function addMonths(d: Date, n: number): Date {
|
|
35
|
+
return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isSameDay(a: Date, b: Date): boolean {
|
|
39
|
+
return (
|
|
40
|
+
a.getFullYear() === b.getFullYear() &&
|
|
41
|
+
a.getMonth() === b.getMonth() &&
|
|
42
|
+
a.getDate() === b.getDate()
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isToday(d: Date, now: Date = new Date()): boolean {
|
|
47
|
+
return isSameDay(d, now);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isSameMonth(a: Date, b: Date): boolean {
|
|
51
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whole calendar days from `a`'s day to `b`'s day (b - a), DST-safe. */
|
|
55
|
+
export function dayDiff(a: Date, b: Date): number {
|
|
56
|
+
return Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / MS_PER_DAY);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function minutesSinceMidnight(d: Date): number {
|
|
60
|
+
return d.getHours() * 60 + d.getMinutes();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The day columns a view spans: 7 for week, 1 for day, the 6-week grid for month. */
|
|
64
|
+
export function daysInView(mode: CalendarViewMode, date: Date, weekStartsOn: Weekday = 1): Date[] {
|
|
65
|
+
if (mode === "day") return [startOfDay(date)];
|
|
66
|
+
if (mode === "week") {
|
|
67
|
+
const start = startOfWeek(date, weekStartsOn);
|
|
68
|
+
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
|
69
|
+
}
|
|
70
|
+
// month: full weeks covering the month → always 6 rows for a stable grid height.
|
|
71
|
+
const gridStart = startOfWeek(startOfMonth(date), weekStartsOn);
|
|
72
|
+
return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Localized labels (Intl, browser/workspace locale) ──────────────────────
|
|
76
|
+
|
|
77
|
+
export function weekdayShort(d: Date, locale?: string): string {
|
|
78
|
+
return new Intl.DateTimeFormat(locale, { weekday: "short" }).format(d);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatTime(d: Date, locale?: string): string {
|
|
82
|
+
return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", hour12: false }).format(d);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** "0:00".."23:00" rail labels — 24-hour, unambiguous across locales. */
|
|
86
|
+
export function hourLabel(hour: number): string {
|
|
87
|
+
return `${String(hour).padStart(2, "0")}:00`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Header title: "March 2026" (month) or the week/day range. */
|
|
91
|
+
export function viewTitle(mode: CalendarViewMode, date: Date, weekStartsOn: Weekday, locale?: string): string {
|
|
92
|
+
if (mode === "month") {
|
|
93
|
+
return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(date);
|
|
94
|
+
}
|
|
95
|
+
if (mode === "day") {
|
|
96
|
+
return new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", year: "numeric" }).format(date);
|
|
97
|
+
}
|
|
98
|
+
const start = startOfWeek(date, weekStartsOn);
|
|
99
|
+
const end = addDays(start, 6);
|
|
100
|
+
const md = (x: Date) => new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" }).format(x);
|
|
101
|
+
return `${md(start)} – ${md(end)}, ${end.getFullYear()}`;
|
|
102
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { CalendarView } from "./calendar_view";
|
|
2
|
+
export type { CalendarViewProps } from "./calendar_view";
|
|
3
|
+
export { TimeGridView } from "./time_grid_view";
|
|
4
|
+
export type { TimeGridViewProps } from "./time_grid_view";
|
|
5
|
+
export { MonthView } from "./month_view";
|
|
6
|
+
export type { MonthViewProps } from "./month_view";
|
|
7
|
+
export { layoutDayColumns, packEventLanes } from "./layout";
|
|
8
|
+
export type { LaneBar } from "./layout";
|
|
9
|
+
export type { CalendarEvent, CalendarViewMode, Weekday, EventColumn } from "./types";
|
|
10
|
+
export {
|
|
11
|
+
addDays,
|
|
12
|
+
addMonths,
|
|
13
|
+
startOfWeek,
|
|
14
|
+
startOfMonth,
|
|
15
|
+
daysInView,
|
|
16
|
+
isSameDay,
|
|
17
|
+
isToday,
|
|
18
|
+
viewTitle,
|
|
19
|
+
} from "./dates";
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { layoutDayColumns, packEventLanes } from "./layout";
|
|
3
|
+
import type { CalendarEvent } from "./types";
|
|
4
|
+
|
|
5
|
+
const DAY = new Date(2026, 0, 15);
|
|
6
|
+
const at = (h: number, m = 0) => new Date(2026, 0, 15, h, m);
|
|
7
|
+
const ev = (id: string, sh: number, sm: number, eh: number, em: number): CalendarEvent => ({
|
|
8
|
+
id,
|
|
9
|
+
title: id,
|
|
10
|
+
start: at(sh, sm),
|
|
11
|
+
end: at(eh, em),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/** column/columns keyed by event id, for order-independent assertions. */
|
|
15
|
+
function geom(events: CalendarEvent[]) {
|
|
16
|
+
return Object.fromEntries(
|
|
17
|
+
layoutDayColumns(DAY, events).map((c) => [c.event.id, { column: c.column, columns: c.columns }]),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("layoutDayColumns", () => {
|
|
22
|
+
it("returns nothing for an empty day", () => {
|
|
23
|
+
expect(layoutDayColumns(DAY, [])).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("stacks non-overlapping events in a single column", () => {
|
|
27
|
+
const g = geom([ev("a", 9, 0, 10, 0), ev("b", 11, 0, 12, 0)]);
|
|
28
|
+
expect(g.a).toEqual({ column: 0, columns: 1 });
|
|
29
|
+
expect(g.b).toEqual({ column: 0, columns: 1 });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("splits two fully overlapping events into two columns", () => {
|
|
33
|
+
const g = geom([ev("a", 9, 0, 10, 0), ev("b", 9, 0, 10, 0)]);
|
|
34
|
+
expect(g.a.columns).toBe(2);
|
|
35
|
+
expect(g.b.columns).toBe(2);
|
|
36
|
+
expect(new Set([g.a.column, g.b.column])).toEqual(new Set([0, 1]));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("gives three concurrent events three columns", () => {
|
|
40
|
+
const g = geom([ev("a", 9, 0, 12, 0), ev("b", 9, 0, 12, 0), ev("c", 9, 0, 12, 0)]);
|
|
41
|
+
expect(new Set([g.a.column, g.b.column, g.c.column])).toEqual(new Set([0, 1, 2]));
|
|
42
|
+
for (const k of ["a", "b", "c"]) expect(g[k].columns).toBe(3);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reuses a freed column within a transitive cluster (A→B→C chain)", () => {
|
|
46
|
+
// A 9-10, B 9:30-11 (overlaps A), C 10:30-12 (overlaps B, not A).
|
|
47
|
+
// One cluster via B; 2 columns; C reuses A's column once A ends.
|
|
48
|
+
const g = geom([ev("a", 9, 0, 10, 0), ev("b", 9, 30, 11, 0), ev("c", 10, 30, 12, 0)]);
|
|
49
|
+
expect(g.a.columns).toBe(2);
|
|
50
|
+
expect(g.a.column).toBe(0);
|
|
51
|
+
expect(g.b.column).toBe(1);
|
|
52
|
+
expect(g.c.column).toBe(0); // freed by A
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("treats time-separated events as independent clusters", () => {
|
|
56
|
+
const g = geom([ev("morn1", 9, 0, 10, 0), ev("morn2", 9, 0, 10, 0), ev("aft", 14, 0, 15, 0)]);
|
|
57
|
+
expect(g.morn1.columns).toBe(2);
|
|
58
|
+
expect(g.morn2.columns).toBe(2);
|
|
59
|
+
expect(g.aft).toEqual({ column: 0, columns: 1 }); // own cluster
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("positions and floors height; clamps to the day window", () => {
|
|
63
|
+
const [c] = layoutDayColumns(DAY, [ev("x", 9, 0, 9, 5)]);
|
|
64
|
+
expect(c.topMinutes).toBe(540); // 9:00
|
|
65
|
+
expect(c.heightMinutes).toBeGreaterThanOrEqual(20); // 5-min event floored
|
|
66
|
+
const spill = layoutDayColumns(DAY, [
|
|
67
|
+
{ id: "y", title: "y", start: at(23, 0), end: new Date(2026, 0, 16, 2, 0) },
|
|
68
|
+
])[0];
|
|
69
|
+
expect(spill.topMinutes).toBe(1380); // 23:00
|
|
70
|
+
expect(spill.topMinutes + spill.heightMinutes).toBeLessThanOrEqual(1440); // clamped
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("packEventLanes", () => {
|
|
75
|
+
const WEEK = new Date(2026, 0, 12); // Mon
|
|
76
|
+
const allDay = (id: string, startDay: number, endDay: number): CalendarEvent => ({
|
|
77
|
+
id,
|
|
78
|
+
title: id,
|
|
79
|
+
start: new Date(2026, 0, 12 + startDay),
|
|
80
|
+
end: new Date(2026, 0, 12 + endDay),
|
|
81
|
+
allDay: true,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("places non-overlapping spans in one lane", () => {
|
|
85
|
+
const { bars, lanes } = packEventLanes([allDay("a", 0, 1), allDay("b", 3, 4)], WEEK, 7);
|
|
86
|
+
expect(lanes).toBe(1);
|
|
87
|
+
expect(bars.find((b) => b.event.id === "a")).toMatchObject({ startCol: 0, span: 2, lane: 0 });
|
|
88
|
+
expect(bars.find((b) => b.event.id === "b")).toMatchObject({ startCol: 3, span: 2, lane: 0 });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("stacks overlapping multi-day spans into separate lanes", () => {
|
|
92
|
+
const { lanes } = packEventLanes([allDay("a", 0, 3), allDay("b", 2, 5)], WEEK, 7);
|
|
93
|
+
expect(lanes).toBe(2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("clamps a span that starts before the window and filters out-of-range", () => {
|
|
97
|
+
const before: CalendarEvent = { id: "x", title: "x", start: new Date(2026, 0, 9), end: new Date(2026, 0, 13), allDay: true };
|
|
98
|
+
const after = allDay("y", 9, 10); // entirely after a 7-day window
|
|
99
|
+
const { bars } = packEventLanes([before, after], WEEK, 7);
|
|
100
|
+
expect(bars).toHaveLength(1);
|
|
101
|
+
expect(bars[0]).toMatchObject({ event: { id: "x" }, startCol: 0, span: 2 }); // clamped to days 0..1
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -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,157 @@
|
|
|
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
|
+
onEventPress?: (event: CalendarEvent<T>) => void;
|
|
19
|
+
onDayPress?: (day: Date) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function MonthView<T = unknown>(props: MonthViewProps<T>) {
|
|
23
|
+
const { date, events, weekStartsOn = 1, locale, onEventPress, onDayPress } = props;
|
|
24
|
+
const days = useMemo(() => daysInView("month", date, weekStartsOn), [date, weekStartsOn]);
|
|
25
|
+
const weeks = useMemo(() => Array.from({ length: 6 }, (_, w) => days.slice(w * 7, w * 7 + 7)), [days]);
|
|
26
|
+
const weekdayLabels = useMemo(() => days.slice(0, 7).map((d) => weekdayShort(d, locale)), [days, locale]);
|
|
27
|
+
const now = new Date();
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<View style={styles.root}>
|
|
31
|
+
<View style={styles.weekdayHeader}>
|
|
32
|
+
{weekdayLabels.map((label, i) => (
|
|
33
|
+
<View key={i} style={styles.weekdayCell}>
|
|
34
|
+
<Text size="xs" color="muted">{label.toUpperCase()}</Text>
|
|
35
|
+
</View>
|
|
36
|
+
))}
|
|
37
|
+
</View>
|
|
38
|
+
|
|
39
|
+
{weeks.map((weekDays, w) => {
|
|
40
|
+
const { bars } = packEventLanes(events, weekDays[0], 7);
|
|
41
|
+
const visible = bars.filter((b) => b.lane < MAX_LANES);
|
|
42
|
+
const overflow = Array.from({ length: 7 }, () => 0);
|
|
43
|
+
for (const b of bars) {
|
|
44
|
+
if (b.lane >= MAX_LANES) {
|
|
45
|
+
for (let c = b.startCol; c < b.startCol + b.span && c < 7; c++) overflow[c]++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<View key={w} style={styles.weekRow}>
|
|
51
|
+
{/* Date numbers */}
|
|
52
|
+
<View style={styles.dateRow}>
|
|
53
|
+
{weekDays.map((day) => {
|
|
54
|
+
const inMonth = isSameMonth(day, date);
|
|
55
|
+
const today = isToday(day, now);
|
|
56
|
+
return (
|
|
57
|
+
<Pressable
|
|
58
|
+
key={day.toISOString()}
|
|
59
|
+
onPress={onDayPress ? () => onDayPress(day) : undefined}
|
|
60
|
+
accessibilityRole={onDayPress ? "button" : undefined}
|
|
61
|
+
style={styles.dateCell}
|
|
62
|
+
>
|
|
63
|
+
<View style={[styles.dateBadge, today && { backgroundColor: colors.teal[600] }]}>
|
|
64
|
+
<Text
|
|
65
|
+
size="xs"
|
|
66
|
+
weight={today ? "semibold" : "regular"}
|
|
67
|
+
style={{ color: today ? colors.white : inMonth ? colors.zinc[800] : colors.zinc[400] }}
|
|
68
|
+
>
|
|
69
|
+
{day.getDate()}
|
|
70
|
+
</Text>
|
|
71
|
+
</View>
|
|
72
|
+
</Pressable>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
</View>
|
|
76
|
+
|
|
77
|
+
{/* Event lanes */}
|
|
78
|
+
<View style={styles.laneArea}>
|
|
79
|
+
{weekDays.map((_, i) =>
|
|
80
|
+
i === 0 ? null : (
|
|
81
|
+
<View
|
|
82
|
+
key={i}
|
|
83
|
+
style={{ position: "absolute", left: `${(i / 7) * 100}%`, top: -2, bottom: 0, borderLeftWidth: 1, borderLeftColor: colors.zinc[100] }}
|
|
84
|
+
/>
|
|
85
|
+
),
|
|
86
|
+
)}
|
|
87
|
+
{visible.map((bar) => {
|
|
88
|
+
const accent = bar.event.color || colors.teal[600];
|
|
89
|
+
const banner = bar.span > 1 || !!bar.event.allDay;
|
|
90
|
+
return (
|
|
91
|
+
<Pressable
|
|
92
|
+
key={bar.event.id}
|
|
93
|
+
onPress={onEventPress ? () => onEventPress(bar.event) : undefined}
|
|
94
|
+
accessibilityRole={onEventPress ? "button" : undefined}
|
|
95
|
+
accessibilityLabel={bar.event.title}
|
|
96
|
+
style={{
|
|
97
|
+
position: "absolute",
|
|
98
|
+
top: bar.lane * (LANE_H + LANE_GAP),
|
|
99
|
+
height: LANE_H,
|
|
100
|
+
left: `${(bar.startCol / 7) * 100}%`,
|
|
101
|
+
width: `${(bar.span / 7) * 100}%`,
|
|
102
|
+
paddingHorizontal: 2,
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<View
|
|
106
|
+
style={{
|
|
107
|
+
flex: 1,
|
|
108
|
+
borderRadius: 4,
|
|
109
|
+
backgroundColor: banner ? accent : "transparent",
|
|
110
|
+
flexDirection: "row",
|
|
111
|
+
alignItems: "center",
|
|
112
|
+
paddingHorizontal: 5,
|
|
113
|
+
gap: 5,
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
{!banner ? <View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: accent }} /> : null}
|
|
117
|
+
<Text
|
|
118
|
+
size="xs"
|
|
119
|
+
weight={banner ? "medium" : "regular"}
|
|
120
|
+
numberOfLines={1}
|
|
121
|
+
style={{ color: banner ? colors.white : colors.zinc[700], flex: 1 }}
|
|
122
|
+
>
|
|
123
|
+
{bar.event.title}
|
|
124
|
+
</Text>
|
|
125
|
+
</View>
|
|
126
|
+
</Pressable>
|
|
127
|
+
);
|
|
128
|
+
})}
|
|
129
|
+
{weekDays.map((day, col) =>
|
|
130
|
+
overflow[col] > 0 ? (
|
|
131
|
+
<Pressable
|
|
132
|
+
key={`o${col}`}
|
|
133
|
+
onPress={onDayPress ? () => onDayPress(day) : undefined}
|
|
134
|
+
style={{ position: "absolute", top: MAX_LANES * (LANE_H + LANE_GAP), left: `${(col / 7) * 100}%`, width: `${100 / 7}%`, paddingHorizontal: 6 }}
|
|
135
|
+
>
|
|
136
|
+
<Text size="xs" color="muted">+{overflow[col]} nữa</Text>
|
|
137
|
+
</Pressable>
|
|
138
|
+
) : null,
|
|
139
|
+
)}
|
|
140
|
+
</View>
|
|
141
|
+
</View>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</View>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const styles = StyleSheet.create({
|
|
149
|
+
root: { flex: 1, backgroundColor: colors.white },
|
|
150
|
+
weekdayHeader: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, paddingVertical: 6 },
|
|
151
|
+
weekdayCell: { flex: 1, alignItems: "center" },
|
|
152
|
+
weekRow: { flex: 1, borderBottomWidth: 1, borderBottomColor: colors.zinc[100] },
|
|
153
|
+
dateRow: { flexDirection: "row", paddingTop: 4 },
|
|
154
|
+
dateCell: { flex: 1, alignItems: "center" },
|
|
155
|
+
dateBadge: { minWidth: 22, height: 22, borderRadius: 11, alignItems: "center", justifyContent: "center", paddingHorizontal: 4 },
|
|
156
|
+
laneArea: { flex: 1, position: "relative", marginTop: 2 },
|
|
157
|
+
});
|