@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.
Files changed (55) hide show
  1. package/package.json +24 -7
  2. package/src/alert.tsx +35 -5
  3. package/src/avatar.tsx +28 -3
  4. package/src/back_button.tsx +4 -2
  5. package/src/button.tsx +35 -5
  6. package/src/calendar/calendar_view.tsx +127 -0
  7. package/src/calendar/dates.ts +102 -0
  8. package/src/calendar/index.ts +20 -0
  9. package/src/calendar/layout.test.ts +103 -0
  10. package/src/calendar/layout.ts +142 -0
  11. package/src/calendar/month_view.tsx +159 -0
  12. package/src/calendar/time_grid_view.tsx +263 -0
  13. package/src/calendar/types.ts +67 -0
  14. package/src/checkbox_input.tsx +9 -3
  15. package/src/command_menu.tsx +50 -4
  16. package/src/dialog.tsx +1 -1
  17. package/src/download.ts +14 -2
  18. package/src/form_field.tsx +77 -25
  19. package/src/form_switch.tsx +22 -3
  20. package/src/gantt/gantt_view.tsx +145 -0
  21. package/src/gantt/index.ts +5 -0
  22. package/src/gantt/scale.test.ts +47 -0
  23. package/src/gantt/scale.ts +92 -0
  24. package/src/gantt/types.ts +51 -0
  25. package/src/grid/select_header_cell.tsx +1 -0
  26. package/src/icon.tsx +14 -8
  27. package/src/icon_button.tsx +10 -4
  28. package/src/index.css +11 -0
  29. package/src/kanban/constants.ts +18 -0
  30. package/src/kanban/default_renderers.tsx +160 -0
  31. package/src/kanban/drag_preview.tsx +157 -0
  32. package/src/kanban/index.ts +13 -0
  33. package/src/kanban/insert_card_zone.tsx +135 -0
  34. package/src/kanban/kanban_board.tsx +616 -0
  35. package/src/kanban/kanban_card.tsx +312 -0
  36. package/src/kanban/kanban_column.tsx +487 -0
  37. package/src/kanban/placeholders.tsx +54 -0
  38. package/src/kanban/types.ts +116 -0
  39. package/src/landmark.tsx +34 -0
  40. package/src/menu_button.tsx +21 -0
  41. package/src/menu_list_item.tsx +3 -0
  42. package/src/number_input.tsx +10 -1
  43. package/src/pill_button.tsx +1 -0
  44. package/src/popover.tsx +47 -2
  45. package/src/popover_header.tsx +4 -2
  46. package/src/pressable_highlight.tsx +24 -0
  47. package/src/radio_picker.tsx +63 -5
  48. package/src/section_heading.tsx +5 -3
  49. package/src/skip_link.tsx +46 -0
  50. package/src/switch.tsx +9 -1
  51. package/src/switch_button.tsx +3 -0
  52. package/src/tabs.tsx +81 -19
  53. package/src/text.tsx +33 -0
  54. package/src/text_input_field.tsx +31 -0
  55. package/src/tooltip.tsx +43 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -19,6 +19,9 @@
19
19
  "./trend_chip": "./src/trend_chip.tsx",
20
20
  "./section_card": "./src/section_card.tsx",
21
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",
22
25
  "./ring_gauge": "./src/ring_gauge.tsx",
23
26
  "./alert_row": "./src/alert_row.tsx",
24
27
  "./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
@@ -115,6 +118,8 @@
115
118
  "./command_menu": "./src/command_menu.tsx",
116
119
  "./switch_button": "./src/switch_button.tsx",
117
120
  "./alert": "./src/alert.tsx",
121
+ "./landmark": "./src/landmark.tsx",
122
+ "./skip_link": "./src/skip_link.tsx",
118
123
  "./text_utils": "./src/text_utils.ts",
119
124
  "./cell_text": "./src/cell_text.tsx",
120
125
  "./cell_number": "./src/cell_number.tsx",
@@ -130,7 +135,9 @@
130
135
  "./grid/data_grid_context": "./src/grid/data_grid_context.ts",
131
136
  "./grid/search_highlight": "./src/grid/search_highlight.ts"
132
137
  },
133
- "files": ["src"],
138
+ "files": [
139
+ "src"
140
+ ],
134
141
  "publishConfig": {
135
142
  "access": "public"
136
143
  },
@@ -153,11 +160,21 @@
153
160
  "recharts": ">=3.0.0"
154
161
  },
155
162
  "peerDependenciesMeta": {
156
- "expo-image": { "optional": true },
157
- "@react-native-picker/picker": { "optional": true },
158
- "lucide-react-native": { "optional": true },
159
- "react-native-svg": { "optional": true },
160
- "recharts": { "optional": true }
163
+ "expo-image": {
164
+ "optional": true
165
+ },
166
+ "@react-native-picker/picker": {
167
+ "optional": true
168
+ },
169
+ "lucide-react-native": {
170
+ "optional": true
171
+ },
172
+ "react-native-svg": {
173
+ "optional": true
174
+ },
175
+ "recharts": {
176
+ "optional": true
177
+ }
161
178
  },
162
179
  "scripts": {
163
180
  "typecheck": "tsgo --noEmit",
package/src/alert.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useState, useCallback } from "react";
1
+ import React, { useEffect, useId, useRef, useState, useCallback } from "react";
2
2
  import { createRoot, Root } from "react-dom/client";
3
3
  import "./alert.css";
4
4
  import { Text } from "./text";
@@ -81,6 +81,11 @@ class Alert {
81
81
 
82
82
  const AlertComponent = () => {
83
83
  const [visible, setVisible] = useState(true);
84
+ const dialogId = useId();
85
+ const titleId = `${dialogId}-title`;
86
+ const messageId = `${dialogId}-message`;
87
+ const dialogRef = useRef<HTMLDivElement>(null);
88
+ const returnFocusRef = useRef<HTMLElement | null>(null);
84
89
 
85
90
  useOverlayScope(visible);
86
91
 
@@ -106,6 +111,29 @@ class Alert {
106
111
  }
107
112
  }, [visible]);
108
113
 
114
+ // Focus management: remember what had focus so it can be restored when
115
+ // the dialog dismisses. Move focus to the preferred button on mount so
116
+ // keyboard users do not land outside the dialog.
117
+ useEffect(() => {
118
+ returnFocusRef.current = document.activeElement as HTMLElement | null;
119
+ const frame = requestAnimationFrame(() => {
120
+ // The primary/positive button is conventionally rightmost in the
121
+ // row, so default focus lands there. Users can Shift-Tab to the
122
+ // cancel/neutral buttons if they need them.
123
+ const buttons = dialogRef.current?.querySelectorAll<HTMLElement>(
124
+ ".lotics-alert-buttons [role='button']",
125
+ );
126
+ const preferred = buttons ? buttons[buttons.length - 1] : undefined;
127
+ preferred?.focus();
128
+ });
129
+ return () => {
130
+ cancelAnimationFrame(frame);
131
+ const target = returnFocusRef.current;
132
+ returnFocusRef.current = null;
133
+ if (target && typeof target.focus === "function") target.focus();
134
+ };
135
+ }, []);
136
+
109
137
  useEffect(() => {
110
138
  const handleEscape = (e: KeyboardEvent) => {
111
139
  if (e.key === "Escape" && alertOptions.cancelable !== false) {
@@ -124,19 +152,21 @@ class Alert {
124
152
  return (
125
153
  <div className="lotics-alert-overlay" onClick={handleDismiss}>
126
154
  <div
155
+ ref={dialogRef}
127
156
  className="lotics-alert-dialog"
128
157
  onClick={(e) => e.stopPropagation()}
129
158
  role="alertdialog"
130
- aria-labelledby={title && title.trim() ? "alert-title" : undefined}
131
- aria-describedby={message ? "alert-message" : undefined}
159
+ aria-modal="true"
160
+ aria-labelledby={title && title.trim() ? titleId : undefined}
161
+ aria-describedby={message ? messageId : undefined}
132
162
  >
133
163
  {title && title.trim() && (
134
- <Text size="md" weight="medium">
164
+ <Text nativeID={titleId} size="md" weight="medium">
135
165
  {title}
136
166
  </Text>
137
167
  )}
138
168
  {message && (
139
- <Text size="sm" color="zinc-500">
169
+ <Text nativeID={messageId} size="sm" color="zinc-500">
140
170
  {message}
141
171
  </Text>
142
172
  )}
package/src/avatar.tsx CHANGED
@@ -10,21 +10,44 @@ interface AvatarProps {
10
10
  name?: string;
11
11
  style?: StyleProp<ViewStyle | ImageStyle>;
12
12
  contentFit?: ImageContentFit;
13
+ /**
14
+ * When true, the avatar announces its `name` to assistive tech. Default
15
+ * false because avatars almost always appear adjacent to the name text —
16
+ * announcing the image as well would double-read. Pass `announce` when the
17
+ * avatar is standalone (with no visible name nearby).
18
+ */
19
+ announce?: boolean;
13
20
  }
14
21
 
15
22
  export function Avatar(props: AvatarProps) {
16
- const { source, size = 32, name = "Unknown", style, contentFit } = props;
23
+ const { source, size = 32, name = "Unknown", style, contentFit, announce } = props;
24
+ const decorative = !announce;
17
25
 
18
26
  if (!source || !source.uri) {
19
27
  return (
20
28
  <View
29
+ accessible={!decorative}
30
+ accessibilityLabel={decorative ? undefined : name}
31
+ accessibilityElementsHidden={decorative}
32
+ importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
33
+ aria-hidden={decorative || undefined}
21
34
  style={[
22
35
  styles.base,
23
36
  { backgroundColor: colors.blue["600"], width: size, height: size },
24
37
  style,
25
38
  ]}
26
39
  >
27
- <Text userSelect="none" size="xs" weight="medium" color="inverted">
40
+ {/* Initials are a visual shorthand for the name; the accessible name is
41
+ on the container so the SR does not read "HM" in addition. */}
42
+ <Text
43
+ userSelect="none"
44
+ size="xs"
45
+ weight="medium"
46
+ color="inverted"
47
+ accessibilityElementsHidden
48
+ importantForAccessibility="no-hide-descendants"
49
+ aria-hidden
50
+ >
28
51
  {getInitials(name, size)}
29
52
  </Text>
30
53
  </View>
@@ -33,7 +56,9 @@ export function Avatar(props: AvatarProps) {
33
56
 
34
57
  return (
35
58
  <Image
36
- alt={name}
59
+ alt={decorative ? "" : name}
60
+ accessibilityElementsHidden={decorative}
61
+ importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
37
62
  style={[styles.base, { width: size, height: size }, style as ImageStyle]}
38
63
  source={source}
39
64
  contentFit={contentFit}
@@ -1,18 +1,20 @@
1
1
  import { Button } from "./button";
2
- import { Text } from "./text";
3
2
  import { View } from "react-native";
4
3
 
5
4
  interface BackButtonProps {
6
5
  onPress: () => void;
6
+ /** Accessible name. Default: "Back". Pass a translated string from the consumer. */
7
+ accessibilityLabel?: string;
7
8
  }
8
9
 
9
10
  export function BackButton(props: BackButtonProps) {
10
- const { onPress } = props;
11
+ const { onPress, accessibilityLabel = "Back" } = props;
11
12
  return (
12
13
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
13
14
  <Button
14
15
  icon="chevron-left"
15
16
  color="secondary"
17
+ accessibilityLabel={accessibilityLabel}
16
18
  onPress={onPress}
17
19
  style={{ alignSelf: "flex-start" }}
18
20
  />
package/src/button.tsx CHANGED
@@ -16,10 +16,9 @@ import { useTooltip, UseTooltipOptions } from "./tooltip";
16
16
  export type ButtonColor = "primary" | "secondary" | "danger" | "muted" | "danger-secondary";
17
17
  export type ButtonIconPosition = "left" | "right";
18
18
 
19
- export interface ButtonProps {
19
+ interface ButtonPropsBase {
20
20
  ref?: Ref<View>;
21
21
  testID?: string;
22
- title?: string | boolean;
23
22
  icon?: IconName;
24
23
  alignSelf?: "flex-start" | "flex-end" | "center" | "stretch" | "auto";
25
24
  color?: ButtonColor;
@@ -30,9 +29,34 @@ export interface ButtonProps {
30
29
  style?: StyleProp<ViewStyle>;
31
30
  }
32
31
 
32
+ /**
33
+ * A button must have an accessible name. The compiler enforces it by
34
+ * requiring at least one of: a string `title`, an explicit
35
+ * `accessibilityLabel`, or a `tooltip` that carries a visible name. The
36
+ * runtime derives `accessibilityLabel` in that precedence order.
37
+ */
38
+ export type ButtonProps = ButtonPropsBase &
39
+ (
40
+ | { title: string; accessibilityLabel?: string; tooltip?: string | UseTooltipOptions }
41
+ | { title?: boolean | undefined; accessibilityLabel: string; tooltip?: string | UseTooltipOptions }
42
+ | { title?: boolean | undefined; accessibilityLabel?: string; tooltip: string | UseTooltipOptions }
43
+ );
44
+
33
45
  export function Button(props: ButtonProps) {
34
- const { ref, icon, alignSelf, title, color, style, disabled, tooltip, loading, onPress, testID } =
35
- props;
46
+ const {
47
+ ref,
48
+ icon,
49
+ alignSelf,
50
+ title,
51
+ color,
52
+ style,
53
+ disabled,
54
+ tooltip,
55
+ loading,
56
+ onPress,
57
+ testID,
58
+ accessibilityLabel,
59
+ } = props;
36
60
 
37
61
  const disabledOrLoading = disabled || loading;
38
62
  const tooltipProps = useTooltip(tooltip);
@@ -74,7 +98,13 @@ export function Button(props: ButtonProps) {
74
98
  ref={ref}
75
99
  testID={testID}
76
100
  accessibilityRole="button"
77
- accessibilityState={{ disabled: disabledOrLoading }}
101
+ accessibilityLabel={
102
+ accessibilityLabel ||
103
+ (typeof title === "string" ? title : "") ||
104
+ (typeof tooltip === "string" ? tooltip : tooltip?.text) ||
105
+ undefined
106
+ }
107
+ accessibilityState={{ disabled: disabledOrLoading, busy: loading }}
78
108
  disabled={disabledOrLoading}
79
109
  // @ts-ignore hovered is a react-native-web extension not in base RN types
80
110
  style={({ pressed, hovered }) => {
@@ -0,0 +1,127 @@
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 { DEFAULT_CALENDAR_LABELS } from "./types";
9
+ import type { CalendarEvent, CalendarLabels, CalendarViewMode, Weekday } from "./types";
10
+
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
+ /** User-facing chrome strings; defaults to English. */
20
+ labels?: Partial<CalendarLabels>;
21
+ onEventPress?: (event: CalendarEvent<T>) => void;
22
+ onDayPress?: (day: Date) => void;
23
+ }
24
+
25
+ /**
26
+ * Full calendar: a toolbar (prev / today / next + month·week·day switch) over
27
+ * the month grid or the week/day time grid. Date + view are owned here so the
28
+ * primitive is drop-in; the consumer only supplies events + callbacks.
29
+ */
30
+ export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
31
+ const { events, defaultView = "month", defaultDate, weekStartsOn = 1, locale, onEventPress, onDayPress } = props;
32
+ const L = { ...DEFAULT_CALENDAR_LABELS, ...props.labels };
33
+ const [view, setView] = useState<CalendarViewMode>(defaultView);
34
+ const [date, setDate] = useState<Date>(defaultDate ?? new Date());
35
+
36
+ const step = (dir: number) =>
37
+ setDate((d) => (view === "month" ? addMonths(d, dir) : addDays(d, dir * (view === "week" ? 7 : 1))));
38
+
39
+ // Drilling into a day (month "+N more" or a day cell) opens the day grid.
40
+ const drillToDay = (day: Date) => {
41
+ setDate(day);
42
+ setView("day");
43
+ onDayPress?.(day);
44
+ };
45
+
46
+ return (
47
+ <View style={styles.root}>
48
+ <View style={styles.toolbar}>
49
+ <View style={styles.navGroup}>
50
+ <Pressable onPress={() => step(-1)} accessibilityRole="button" accessibilityLabel={L.previous} style={styles.iconBtn}>
51
+ <Text size="lg" color="muted">‹</Text>
52
+ </Pressable>
53
+ <Pressable onPress={() => setDate(new Date())} accessibilityRole="button" style={styles.todayBtn}>
54
+ <Text size="sm" weight="medium">{L.today}</Text>
55
+ </Pressable>
56
+ <Pressable onPress={() => step(1)} accessibilityRole="button" accessibilityLabel={L.next} style={styles.iconBtn}>
57
+ <Text size="lg" color="muted">›</Text>
58
+ </Pressable>
59
+ </View>
60
+
61
+ <Text size="lg" weight="semibold" style={styles.title} numberOfLines={1}>
62
+ {viewTitle(view, date, weekStartsOn, locale)}
63
+ </Text>
64
+
65
+ <View style={styles.viewSwitch}>
66
+ {VIEW_ORDER.map((v) => (
67
+ <Pressable
68
+ key={v}
69
+ onPress={() => setView(v)}
70
+ accessibilityRole="button"
71
+ style={[styles.viewBtn, view === v && styles.viewBtnActive]}
72
+ >
73
+ <Text size="sm" weight={view === v ? "medium" : "regular"} color={view === v ? "default" : "muted"}>
74
+ {L[v]}
75
+ </Text>
76
+ </Pressable>
77
+ ))}
78
+ </View>
79
+ </View>
80
+
81
+ <View style={{ flex: 1 }}>
82
+ {view === "month" ? (
83
+ <MonthView
84
+ date={date}
85
+ events={events}
86
+ weekStartsOn={weekStartsOn}
87
+ locale={locale}
88
+ moreLabel={L.more}
89
+ onEventPress={onEventPress}
90
+ onDayPress={drillToDay}
91
+ />
92
+ ) : (
93
+ <TimeGridView
94
+ mode={view}
95
+ date={date}
96
+ events={events}
97
+ weekStartsOn={weekStartsOn}
98
+ locale={locale}
99
+ allDayLabel={L.allDay}
100
+ onEventPress={onEventPress}
101
+ />
102
+ )}
103
+ </View>
104
+ </View>
105
+ );
106
+ }
107
+
108
+ const styles = StyleSheet.create({
109
+ root: { flex: 1, backgroundColor: colors.white },
110
+ toolbar: {
111
+ flexDirection: "row",
112
+ alignItems: "center",
113
+ justifyContent: "space-between",
114
+ paddingHorizontal: 14,
115
+ paddingVertical: 10,
116
+ gap: 12,
117
+ borderBottomWidth: 1,
118
+ borderBottomColor: colors.border,
119
+ },
120
+ navGroup: { flexDirection: "row", alignItems: "center", gap: 4 },
121
+ iconBtn: { width: 30, height: 30, borderRadius: 6, alignItems: "center", justifyContent: "center" },
122
+ todayBtn: { paddingHorizontal: 12, height: 30, borderRadius: 6, borderWidth: 1, borderColor: colors.border, alignItems: "center", justifyContent: "center" },
123
+ title: { flex: 1, textAlign: "center" },
124
+ viewSwitch: { flexDirection: "row", backgroundColor: colors.zinc[100], borderRadius: 8, padding: 2 },
125
+ viewBtn: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 6 },
126
+ viewBtnActive: { backgroundColor: colors.white },
127
+ });
@@ -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,20 @@
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 { DEFAULT_CALENDAR_LABELS } from "./types";
10
+ export type { CalendarEvent, CalendarViewMode, Weekday, EventColumn, CalendarLabels } from "./types";
11
+ export {
12
+ addDays,
13
+ addMonths,
14
+ startOfWeek,
15
+ startOfMonth,
16
+ daysInView,
17
+ isSameDay,
18
+ isToday,
19
+ viewTitle,
20
+ } 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
+ });