@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "1.
|
|
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": [
|
|
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": {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
"react-native-
|
|
160
|
-
|
|
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-
|
|
131
|
-
aria-
|
|
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
|
-
|
|
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}
|
package/src/back_button.tsx
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
35
|
-
|
|
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
|
-
|
|
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
|
+
});
|