@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/src/command_menu.tsx
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { StyleSheet, View, ScrollView } from "react-native";
|
|
2
|
-
import { useState, useCallback, useMemo, useRef } from "react";
|
|
2
|
+
import { useState, useCallback, useMemo, useRef, useId } from "react";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
|
-
import { Icon } from "./icon";
|
|
5
4
|
import { TextInputField } from "./text_input_field";
|
|
6
5
|
import { MenuButton } from "./menu_button";
|
|
7
6
|
import { Text } from "./text";
|
|
@@ -20,14 +19,27 @@ interface CommandMenuProps {
|
|
|
20
19
|
enableSearch?: boolean;
|
|
21
20
|
/** Search input placeholder. Default: "Search...". Pass a translated string from the consumer. */
|
|
22
21
|
searchPlaceholder?: string;
|
|
22
|
+
/** Accessible name for the list. Default: "Options". Pass a translated string from the consumer. */
|
|
23
|
+
accessibilityLabel?: string;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function CommandMenu(props: CommandMenuProps) {
|
|
26
|
-
const {
|
|
27
|
+
const {
|
|
28
|
+
options,
|
|
29
|
+
onSelect,
|
|
30
|
+
onRequestClose,
|
|
31
|
+
enableSearch,
|
|
32
|
+
searchPlaceholder = "Search...",
|
|
33
|
+
accessibilityLabel,
|
|
34
|
+
} = props;
|
|
27
35
|
const scrollViewRef = useRef<ScrollView>(null);
|
|
28
36
|
const screenSize = useScreenSize();
|
|
29
37
|
const OPTION_HEIGHT = 40;
|
|
30
38
|
|
|
39
|
+
const baseId = useId();
|
|
40
|
+
const listboxId = `${baseId}-listbox`;
|
|
41
|
+
const optionId = (index: number) => `${baseId}-option-${index}`;
|
|
42
|
+
|
|
31
43
|
const [searchQuery, setSearchQuery] = useState("");
|
|
32
44
|
|
|
33
45
|
const filteredOptions = useMemo(() => {
|
|
@@ -83,6 +95,16 @@ export function CommandMenu(props: CommandMenuProps) {
|
|
|
83
95
|
}
|
|
84
96
|
return true;
|
|
85
97
|
}
|
|
98
|
+
case "Home":
|
|
99
|
+
setFocusedIndex(0);
|
|
100
|
+
scrollToIndex(0);
|
|
101
|
+
return true;
|
|
102
|
+
case "End": {
|
|
103
|
+
const last = filteredOptions.length - 1;
|
|
104
|
+
setFocusedIndex(last);
|
|
105
|
+
scrollToIndex(last);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
86
108
|
case "Enter":
|
|
87
109
|
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
|
|
88
110
|
const opt = filteredOptions[focusedIndex];
|
|
@@ -111,6 +133,10 @@ export function CommandMenu(props: CommandMenuProps) {
|
|
|
111
133
|
[handleKeyNavigation],
|
|
112
134
|
);
|
|
113
135
|
|
|
136
|
+
const listLabel = accessibilityLabel ?? "Options";
|
|
137
|
+
const activeOptionId =
|
|
138
|
+
focusedIndex >= 0 && focusedIndex < filteredOptions.length ? optionId(focusedIndex) : undefined;
|
|
139
|
+
|
|
114
140
|
return (
|
|
115
141
|
<View style={[styles.container, screenSize.small ? { flex: 1 } : { maxHeight: 480 }]}>
|
|
116
142
|
{enableSearch && (
|
|
@@ -124,20 +150,40 @@ export function CommandMenu(props: CommandMenuProps) {
|
|
|
124
150
|
autoCapitalize="none"
|
|
125
151
|
autoCorrect={false}
|
|
126
152
|
onKeyPress={handleSearchKeyPress}
|
|
153
|
+
accessibilityLabel={listLabel}
|
|
154
|
+
role="combobox"
|
|
155
|
+
aria-expanded
|
|
156
|
+
aria-controls={listboxId}
|
|
157
|
+
aria-activedescendant={activeOptionId}
|
|
158
|
+
aria-autocomplete="list"
|
|
127
159
|
/>
|
|
128
160
|
)}
|
|
129
|
-
<ScrollView
|
|
161
|
+
<ScrollView
|
|
162
|
+
ref={scrollViewRef}
|
|
163
|
+
style={styles.optionsList}
|
|
164
|
+
nativeID={listboxId}
|
|
165
|
+
accessibilityLabel={listLabel}
|
|
166
|
+
// Boundary adapter: `listbox` is a valid ARIA role but missing from
|
|
167
|
+
// React Native's `Role` enum, so TypeScript cannot accept the literal
|
|
168
|
+
// here. `react-native-web` forwards the attribute verbatim, which is
|
|
169
|
+
// what assistive technology reads on web.
|
|
170
|
+
role={"listbox" as "list"}
|
|
171
|
+
>
|
|
130
172
|
{filteredOptions.map((item, index) => (
|
|
131
173
|
<MenuButton
|
|
132
174
|
key={item.value}
|
|
175
|
+
nativeID={optionId(index)}
|
|
133
176
|
testID={`command-option-${item.value}`}
|
|
134
177
|
title={
|
|
135
178
|
<Text userSelect="none" numberOfLines={1}>
|
|
136
179
|
{item.label}
|
|
137
180
|
</Text>
|
|
138
181
|
}
|
|
182
|
+
accessibilityLabel={item.label}
|
|
183
|
+
role="option"
|
|
139
184
|
right={undefined}
|
|
140
185
|
focused={index === focusedIndex}
|
|
186
|
+
selected={index === focusedIndex}
|
|
141
187
|
disabled={item.disabled}
|
|
142
188
|
onPress={() => handleSelect(item.value)}
|
|
143
189
|
onHoverIn={() => setFocusedIndex(index)}
|
package/src/dialog.tsx
CHANGED
|
@@ -163,7 +163,7 @@ export function Dialog(props: DialogProps) {
|
|
|
163
163
|
<PortalHost>
|
|
164
164
|
<View testID={testID} style={[styles.dialogContainer, { borderRadius }]}>
|
|
165
165
|
<View style={[styles.closeButtonContainer, { paddingHorizontal: screenSize.small ? 16 : 24 }]}>
|
|
166
|
-
<Button icon="x" onPress={handleClose} />
|
|
166
|
+
<Button icon="x" accessibilityLabel="Close" onPress={handleClose} />
|
|
167
167
|
</View>
|
|
168
168
|
<View style={styles.container}>{children}</View>
|
|
169
169
|
</View>
|
package/src/download.ts
CHANGED
|
@@ -12,11 +12,23 @@
|
|
|
12
12
|
// No React Native / @lotics/shared imports — kept pure so both the host
|
|
13
13
|
// frontend and sandboxed custom-code apps can consume via the per-file
|
|
14
14
|
// export without dragging the wider UI surface.
|
|
15
|
+
//
|
|
16
|
+
// `credentials` defaults to `same-origin`: custom-code apps download presigned
|
|
17
|
+
// R2 URLs (cross-site, no cookie — sending one would trip R2's CORS). The host
|
|
18
|
+
// frontend downloads auth-gated proxy URLs on a sibling subdomain and passes
|
|
19
|
+
// `credentials: "include"` so the session cookie rides the cross-origin fetch.
|
|
15
20
|
|
|
16
|
-
export async function downloadFileFromUrl(
|
|
21
|
+
export async function downloadFileFromUrl(
|
|
22
|
+
url: string,
|
|
23
|
+
filename: string,
|
|
24
|
+
opts?: { credentials?: RequestCredentials },
|
|
25
|
+
): Promise<void> {
|
|
17
26
|
if (!url) throw new Error("downloadFileFromUrl: empty url");
|
|
18
27
|
|
|
19
|
-
const response = await fetch(url, {
|
|
28
|
+
const response = await fetch(url, {
|
|
29
|
+
cache: "no-store",
|
|
30
|
+
credentials: opts?.credentials ?? "same-origin",
|
|
31
|
+
});
|
|
20
32
|
if (!response.ok) {
|
|
21
33
|
throw new Error(`File download failed: ${response.status} ${response.statusText}`);
|
|
22
34
|
}
|
package/src/form_field.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createContext, useContext, useId } from "react";
|
|
1
2
|
import { StyleProp, View, ViewStyle } from "react-native";
|
|
2
3
|
import { Text } from "./text";
|
|
3
4
|
import { Spacer } from "./spacer";
|
|
@@ -12,38 +13,89 @@ export interface FormFieldProps {
|
|
|
12
13
|
optionalLabel?: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Binding emitted by `FormField` to the single input it wraps. Descendants
|
|
18
|
+
* that call `useFormField()` apply these props to the native input so screen
|
|
19
|
+
* readers know the label, error, and description apply to it.
|
|
20
|
+
*/
|
|
21
|
+
export interface FormFieldBinding {
|
|
22
|
+
inputId: string;
|
|
23
|
+
labelId: string;
|
|
24
|
+
descriptionId: string | undefined;
|
|
25
|
+
errorId: string | undefined;
|
|
26
|
+
invalid: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const FormFieldContext = createContext<FormFieldBinding | null>(null);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Returns the association IDs set by the nearest enclosing `FormField`.
|
|
33
|
+
* Inputs spread the returned props onto their underlying element so label,
|
|
34
|
+
* description, and error are announced together. Returns `null` when the
|
|
35
|
+
* input is used outside a `FormField` — callers should then provide their
|
|
36
|
+
* own `accessibilityLabel`.
|
|
37
|
+
*/
|
|
38
|
+
export function useFormField(): FormFieldBinding | null {
|
|
39
|
+
return useContext(FormFieldContext);
|
|
40
|
+
}
|
|
41
|
+
|
|
15
42
|
export function FormField(props: FormFieldProps & { children: React.ReactNode }) {
|
|
16
43
|
const { label, description, error, optional, style, children, optionalLabel = "Optional" } = props;
|
|
17
44
|
|
|
45
|
+
const inputId = useId();
|
|
46
|
+
const labelId = `${inputId}-label`;
|
|
47
|
+
const descriptionId = description ? `${inputId}-description` : undefined;
|
|
48
|
+
const errorId = error ? `${inputId}-error` : undefined;
|
|
49
|
+
|
|
50
|
+
const binding: FormFieldBinding = {
|
|
51
|
+
inputId,
|
|
52
|
+
labelId,
|
|
53
|
+
descriptionId,
|
|
54
|
+
errorId,
|
|
55
|
+
invalid: !!error,
|
|
56
|
+
};
|
|
57
|
+
|
|
18
58
|
return (
|
|
19
|
-
<
|
|
20
|
-
<View
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
{
|
|
59
|
+
<FormFieldContext.Provider value={binding}>
|
|
60
|
+
<View style={[{ paddingBottom: 16 }, style]}>
|
|
61
|
+
<View
|
|
62
|
+
style={{
|
|
63
|
+
flexDirection: "row",
|
|
64
|
+
alignItems: "center",
|
|
65
|
+
justifyContent: "space-between",
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{!!label && (
|
|
69
|
+
<Text nativeID={labelId} numberOfLines={1} weight="medium">
|
|
70
|
+
{label}
|
|
71
|
+
</Text>
|
|
72
|
+
)}
|
|
73
|
+
{!!optional && (
|
|
74
|
+
<Text numberOfLines={1} size="sm" color="zinc-500">
|
|
75
|
+
{optionalLabel}
|
|
76
|
+
</Text>
|
|
77
|
+
)}
|
|
78
|
+
</View>
|
|
79
|
+
{!!description && (
|
|
80
|
+
<Text nativeID={descriptionId} color="muted">
|
|
81
|
+
{description}
|
|
30
82
|
</Text>
|
|
31
83
|
)}
|
|
32
|
-
{
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
84
|
+
<Spacer size={8} />
|
|
85
|
+
<View>{children}</View>
|
|
86
|
+
{error && (
|
|
87
|
+
<>
|
|
88
|
+
<Spacer size={8} />
|
|
89
|
+
{/*
|
|
90
|
+
`role="alert"` + `aria-live="polite"` makes the text announce
|
|
91
|
+
when the error first appears or changes, without stealing focus.
|
|
92
|
+
*/}
|
|
93
|
+
<Text nativeID={errorId} color="danger" accessibilityRole="alert" aria-live="polite">
|
|
94
|
+
{error}
|
|
95
|
+
</Text>
|
|
96
|
+
</>
|
|
36
97
|
)}
|
|
37
98
|
</View>
|
|
38
|
-
|
|
39
|
-
<Spacer size={8} />
|
|
40
|
-
<View>{children}</View>
|
|
41
|
-
{error && (
|
|
42
|
-
<>
|
|
43
|
-
<Spacer size={8} />
|
|
44
|
-
<Text color="danger">{error}</Text>
|
|
45
|
-
</>
|
|
46
|
-
)}
|
|
47
|
-
</View>
|
|
99
|
+
</FormFieldContext.Provider>
|
|
48
100
|
);
|
|
49
101
|
}
|
package/src/form_switch.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
1
2
|
import { View } from "react-native";
|
|
2
3
|
import { Text } from "./text";
|
|
3
4
|
import { FormFieldProps } from "./form_field";
|
|
@@ -8,6 +9,8 @@ export interface FormSwitchProps extends Omit<FormFieldProps, "optional">, Switc
|
|
|
8
9
|
|
|
9
10
|
export function FormSwitch(props: FormSwitchProps) {
|
|
10
11
|
const { label, description, error, value, onChange } = props;
|
|
12
|
+
const labelId = useId();
|
|
13
|
+
const errorId = error ? `${labelId}-error` : undefined;
|
|
11
14
|
|
|
12
15
|
return (
|
|
13
16
|
<View>
|
|
@@ -18,9 +21,18 @@ export function FormSwitch(props: FormSwitchProps) {
|
|
|
18
21
|
alignItems: description ? "flex-start" : "center",
|
|
19
22
|
}}
|
|
20
23
|
>
|
|
21
|
-
<Switch
|
|
24
|
+
<Switch
|
|
25
|
+
value={value}
|
|
26
|
+
onChange={onChange}
|
|
27
|
+
accessibilityLabel={label}
|
|
28
|
+
/>
|
|
22
29
|
<View style={{ flex: 1 }}>
|
|
23
|
-
<Text
|
|
30
|
+
<Text
|
|
31
|
+
nativeID={labelId}
|
|
32
|
+
onPress={() => onChange?.(!value)}
|
|
33
|
+
weight="medium"
|
|
34
|
+
userSelect="none"
|
|
35
|
+
>
|
|
24
36
|
{label}
|
|
25
37
|
</Text>
|
|
26
38
|
{!!description && (
|
|
@@ -34,7 +46,14 @@ export function FormSwitch(props: FormSwitchProps) {
|
|
|
34
46
|
{!!error && (
|
|
35
47
|
<>
|
|
36
48
|
<Spacer size={8} />
|
|
37
|
-
<Text
|
|
49
|
+
<Text
|
|
50
|
+
nativeID={errorId}
|
|
51
|
+
color="danger"
|
|
52
|
+
accessibilityRole="alert"
|
|
53
|
+
aria-live="polite"
|
|
54
|
+
>
|
|
55
|
+
{error}
|
|
56
|
+
</Text>
|
|
38
57
|
</>
|
|
39
58
|
)}
|
|
40
59
|
<Spacer size={8} />
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { View, ScrollView, Pressable, StyleSheet } from "react-native";
|
|
3
|
+
import { Text } from "../text";
|
|
4
|
+
import { colors } from "../colors";
|
|
5
|
+
import { dayDiff } from "../calendar/dates";
|
|
6
|
+
import { axisRange, barGeometry, buildTicks, pxPerDay } from "./scale";
|
|
7
|
+
import { DEFAULT_GANTT_LABELS } from "./types";
|
|
8
|
+
import type { GanttLabels, GanttScale, GanttTask } from "./types";
|
|
9
|
+
|
|
10
|
+
const LABEL_W = 188;
|
|
11
|
+
const HEADER_H = 38;
|
|
12
|
+
const ROW_H = 40;
|
|
13
|
+
const BAR_H = 22;
|
|
14
|
+
const SCALES: GanttScale[] = ["day", "week", "month"];
|
|
15
|
+
|
|
16
|
+
export interface GanttViewProps<T = unknown> {
|
|
17
|
+
tasks: GanttTask<T>[];
|
|
18
|
+
defaultScale?: GanttScale;
|
|
19
|
+
today?: Date;
|
|
20
|
+
locale?: string;
|
|
21
|
+
/** Optional toolbar caption shown left of the zoom switch. */
|
|
22
|
+
title?: string;
|
|
23
|
+
/** User-facing chrome strings; defaults to English. */
|
|
24
|
+
labels?: Partial<GanttLabels>;
|
|
25
|
+
onTaskPress?: (task: GanttTask<T>) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Timeline / gantt: a frozen task-label column beside a horizontally-scrollable
|
|
30
|
+
* zoomable axis. Bars are positioned by {@link barGeometry}; month boundaries get
|
|
31
|
+
* a heavier gridline; a red line marks today. The axis opens scrolled to today
|
|
32
|
+
* so a long history doesn't bury the active range. Renders at its natural height
|
|
33
|
+
* — wrap in a ScrollView for very long task lists.
|
|
34
|
+
*/
|
|
35
|
+
export function GanttView<T = unknown>(props: GanttViewProps<T>) {
|
|
36
|
+
const { tasks, defaultScale = "week", today = new Date(), locale, title, onTaskPress } = props;
|
|
37
|
+
const L = { ...DEFAULT_GANTT_LABELS, ...props.labels };
|
|
38
|
+
const [scale, setScale] = useState<GanttScale>(defaultScale);
|
|
39
|
+
|
|
40
|
+
const { start: axisStart, end: axisEnd } = useMemo(() => axisRange(tasks, today), [tasks, today]);
|
|
41
|
+
const ticks = useMemo(() => buildTicks(axisStart, axisEnd, scale, locale), [axisStart, axisEnd, scale, locale]);
|
|
42
|
+
const axisWidth = useMemo(() => (dayDiff(axisStart, axisEnd) + 1) * pxPerDay(scale), [axisStart, axisEnd, scale]);
|
|
43
|
+
const todayLeft = dayDiff(axisStart, today) * pxPerDay(scale);
|
|
44
|
+
const todayInRange = today >= axisStart && today <= axisEnd;
|
|
45
|
+
|
|
46
|
+
// Open scrolled to today (a margin of recent past visible), like the time-grid
|
|
47
|
+
// scrolls to "now" — keeps the active range in view when history is long.
|
|
48
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const id = setTimeout(() => scrollRef.current?.scrollTo({ x: Math.max(0, todayLeft - 96), animated: false }), 0);
|
|
51
|
+
return () => clearTimeout(id);
|
|
52
|
+
}, [scale, todayLeft]);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<View style={styles.root}>
|
|
56
|
+
{/* Toolbar: zoom */}
|
|
57
|
+
<View style={styles.toolbar}>
|
|
58
|
+
{title ? <Text size="sm" color="muted">{title}</Text> : <View />}
|
|
59
|
+
<View style={styles.zoomSwitch}>
|
|
60
|
+
{SCALES.map((s) => (
|
|
61
|
+
<Pressable key={s} onPress={() => setScale(s)} accessibilityRole="button" style={[styles.zoomBtn, scale === s && styles.zoomBtnActive]}>
|
|
62
|
+
<Text size="sm" weight={scale === s ? "medium" : "regular"} color={scale === s ? "default" : "muted"}>
|
|
63
|
+
{L[s]}
|
|
64
|
+
</Text>
|
|
65
|
+
</Pressable>
|
|
66
|
+
))}
|
|
67
|
+
</View>
|
|
68
|
+
</View>
|
|
69
|
+
|
|
70
|
+
<View style={{ flexDirection: "row", flex: 1 }}>
|
|
71
|
+
{/* Frozen label column */}
|
|
72
|
+
<View style={{ width: LABEL_W, borderRightWidth: 1, borderRightColor: colors.border }}>
|
|
73
|
+
<View style={[styles.headerCell, { height: HEADER_H, paddingLeft: 12, justifyContent: "center" }]}>
|
|
74
|
+
<Text size="xs" color="muted" style={{ textTransform: "uppercase" }}>{L.task}</Text>
|
|
75
|
+
</View>
|
|
76
|
+
{tasks.map((t) => (
|
|
77
|
+
<View key={t.id} style={[styles.labelRow, { height: ROW_H }]}>
|
|
78
|
+
<View style={{ width: 8, height: 8, borderRadius: 2, backgroundColor: t.color || colors.teal[600] }} />
|
|
79
|
+
<Text size="sm" numberOfLines={1} style={{ flex: 1 }}>{t.label}</Text>
|
|
80
|
+
</View>
|
|
81
|
+
))}
|
|
82
|
+
</View>
|
|
83
|
+
|
|
84
|
+
{/* Scrollable timeline */}
|
|
85
|
+
<ScrollView ref={scrollRef} horizontal style={{ flex: 1 }} showsHorizontalScrollIndicator>
|
|
86
|
+
<View style={{ width: axisWidth }}>
|
|
87
|
+
{/* Header ticks */}
|
|
88
|
+
<View style={{ height: HEADER_H, flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border }}>
|
|
89
|
+
{ticks.map((tk) => (
|
|
90
|
+
<View
|
|
91
|
+
key={tk.date.toISOString()}
|
|
92
|
+
style={{ position: "absolute", left: tk.left, top: 0, bottom: 0, justifyContent: "center", paddingLeft: 4, borderLeftWidth: tk.monthStart ? 1 : 0, borderLeftColor: colors.zinc[300] }}
|
|
93
|
+
>
|
|
94
|
+
<Text size="xs" weight={tk.monthStart ? "medium" : "regular"} color={tk.monthStart ? "default" : "muted"}>
|
|
95
|
+
{tk.label}
|
|
96
|
+
</Text>
|
|
97
|
+
</View>
|
|
98
|
+
))}
|
|
99
|
+
</View>
|
|
100
|
+
|
|
101
|
+
{/* Task rows */}
|
|
102
|
+
{tasks.map((t) => {
|
|
103
|
+
const bar = barGeometry(t, axisStart, scale);
|
|
104
|
+
const accent = t.color || colors.teal[600];
|
|
105
|
+
return (
|
|
106
|
+
<View key={t.id} style={{ height: ROW_H, borderBottomWidth: 1, borderBottomColor: colors.zinc[100] }}>
|
|
107
|
+
{ticks.map((tk) =>
|
|
108
|
+
tk.monthStart ? (
|
|
109
|
+
<View key={tk.date.toISOString()} style={{ position: "absolute", left: tk.left, top: 0, bottom: 0, borderLeftWidth: 1, borderLeftColor: colors.zinc[200] }} />
|
|
110
|
+
) : null,
|
|
111
|
+
)}
|
|
112
|
+
<Pressable
|
|
113
|
+
onPress={onTaskPress ? () => onTaskPress(t) : undefined}
|
|
114
|
+
accessibilityRole={onTaskPress ? "button" : undefined}
|
|
115
|
+
accessibilityLabel={t.label}
|
|
116
|
+
style={{ position: "absolute", left: bar.left, width: bar.width, top: (ROW_H - BAR_H) / 2, height: BAR_H }}
|
|
117
|
+
>
|
|
118
|
+
<View style={{ flex: 1, borderRadius: 5, backgroundColor: accent, justifyContent: "center", paddingHorizontal: 8 }}>
|
|
119
|
+
<Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.white }}>{t.label}</Text>
|
|
120
|
+
</View>
|
|
121
|
+
</Pressable>
|
|
122
|
+
</View>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
|
|
126
|
+
{/* Today marker */}
|
|
127
|
+
{todayInRange ? (
|
|
128
|
+
<View style={{ position: "absolute", left: todayLeft, top: 0, bottom: 0, width: 2, backgroundColor: colors.red[500] }} />
|
|
129
|
+
) : null}
|
|
130
|
+
</View>
|
|
131
|
+
</ScrollView>
|
|
132
|
+
</View>
|
|
133
|
+
</View>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const styles = StyleSheet.create({
|
|
138
|
+
root: { flex: 1, backgroundColor: colors.white },
|
|
139
|
+
toolbar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 14, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: colors.border },
|
|
140
|
+
zoomSwitch: { flexDirection: "row", backgroundColor: colors.zinc[100], borderRadius: 8, padding: 2 },
|
|
141
|
+
zoomBtn: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 6 },
|
|
142
|
+
zoomBtnActive: { backgroundColor: colors.white },
|
|
143
|
+
headerCell: { borderBottomWidth: 1, borderBottomColor: colors.border },
|
|
144
|
+
labelRow: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, borderBottomWidth: 1, borderBottomColor: colors.zinc[100] },
|
|
145
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { GanttView } from "./gantt_view";
|
|
2
|
+
export type { GanttViewProps } from "./gantt_view";
|
|
3
|
+
export { barGeometry, axisRange, buildTicks, pxPerDay } from "./scale";
|
|
4
|
+
export { DEFAULT_GANTT_LABELS } from "./types";
|
|
5
|
+
export type { GanttTask, GanttScale, GanttTick, GanttBar, GanttLabels } from "./types";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { axisRange, barGeometry, buildTicks, pxPerDay } from "./scale";
|
|
3
|
+
import type { GanttTask } from "./types";
|
|
4
|
+
|
|
5
|
+
const AXIS = new Date(2026, 0, 1); // Jan 1 2026
|
|
6
|
+
const task = (id: string, s: [number, number], e?: [number, number]): GanttTask => ({
|
|
7
|
+
id,
|
|
8
|
+
label: id,
|
|
9
|
+
start: new Date(2026, s[0], s[1]),
|
|
10
|
+
end: e ? new Date(2026, e[0], e[1]) : null,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("gantt scale", () => {
|
|
14
|
+
it("zoom widths shrink as the scale widens", () => {
|
|
15
|
+
expect(pxPerDay("day")).toBeGreaterThan(pxPerDay("week"));
|
|
16
|
+
expect(pxPerDay("week")).toBeGreaterThan(pxPerDay("month"));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("positions a bar by day offset and inclusive span", () => {
|
|
20
|
+
// Jan 3 → Jan 5 inclusive = 3 days, 2 days after the axis start.
|
|
21
|
+
const b = barGeometry(task("a", [0, 3], [0, 5]), AXIS, "day");
|
|
22
|
+
expect(b.left).toBe(2 * 40);
|
|
23
|
+
expect(b.width).toBe(3 * 40);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("gives a single-day task one day of width (floored)", () => {
|
|
27
|
+
const b = barGeometry(task("m", [0, 10]), AXIS, "day");
|
|
28
|
+
expect(b.width).toBe(40);
|
|
29
|
+
const tiny = barGeometry(task("m", [0, 10]), AXIS, "month"); // 1 * 5px → floored to 8
|
|
30
|
+
expect(tiny.width).toBe(8);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("derives a padded axis window from the tasks", () => {
|
|
34
|
+
const { start, end } = axisRange([task("a", [0, 5], [0, 8]), task("b", [0, 3], [0, 20])], new Date(2026, 0, 1), 3);
|
|
35
|
+
expect(start).toEqual(new Date(2026, 0, 0)); // Jan 3 - 3 = Dec 31 (= Jan 0)
|
|
36
|
+
expect(end).toEqual(new Date(2026, 0, 23)); // Jan 20 + 3
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("emits one day-tick per day and flags month starts", () => {
|
|
40
|
+
const ticks = buildTicks(new Date(2026, 0, 30), new Date(2026, 1, 2), "day");
|
|
41
|
+
expect(ticks.map((t) => t.label)).toEqual(["30", "31", "1", "2"]);
|
|
42
|
+
expect(ticks.find((t) => t.label === "1")?.monthStart).toBe(true);
|
|
43
|
+
expect(ticks.find((t) => t.label === "30")?.monthStart).toBe(false);
|
|
44
|
+
expect(ticks[0].left).toBe(0);
|
|
45
|
+
expect(ticks[1].left).toBe(40); // one day across at day scale
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { addDays, dayDiff, startOfDay, startOfMonth, startOfWeek } from "../calendar/dates";
|
|
2
|
+
import type { Weekday } from "../calendar/types";
|
|
3
|
+
import type { GanttBar, GanttScale, GanttTask, GanttTick } from "./types";
|
|
4
|
+
|
|
5
|
+
const MIN_BAR_PX = 8;
|
|
6
|
+
|
|
7
|
+
/** Pixels per day at each zoom. Day = roomy single days; month = dense quarters. */
|
|
8
|
+
export function pxPerDay(scale: GanttScale): number {
|
|
9
|
+
switch (scale) {
|
|
10
|
+
case "day":
|
|
11
|
+
return 40;
|
|
12
|
+
case "week":
|
|
13
|
+
return 16;
|
|
14
|
+
case "month":
|
|
15
|
+
return 5;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The axis window: from the earliest task start to the latest end, padded so
|
|
20
|
+
* bars never hug the edges. Falls back to a month around `today` when empty. */
|
|
21
|
+
export function axisRange(tasks: GanttTask[], today: Date = new Date(), padDays = 3): { start: Date; end: Date } {
|
|
22
|
+
if (tasks.length === 0) {
|
|
23
|
+
return { start: addDays(today, -padDays), end: addDays(today, 30) };
|
|
24
|
+
}
|
|
25
|
+
let min = tasks[0].start;
|
|
26
|
+
let max = tasks[0].end ?? tasks[0].start;
|
|
27
|
+
for (const t of tasks) {
|
|
28
|
+
if (t.start < min) min = t.start;
|
|
29
|
+
const e = t.end ?? t.start;
|
|
30
|
+
if (e > max) max = e;
|
|
31
|
+
}
|
|
32
|
+
return { start: addDays(startOfDay(min), -padDays), end: addDays(startOfDay(max), padDays) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Bar geometry: left = days from axis start; width spans the inclusive end. */
|
|
36
|
+
export function barGeometry<T>(task: GanttTask<T>, axisStart: Date, scale: GanttScale): GanttBar<T> {
|
|
37
|
+
const ppd = pxPerDay(scale);
|
|
38
|
+
const left = dayDiff(axisStart, task.start) * ppd;
|
|
39
|
+
const endDay = task.end ?? task.start;
|
|
40
|
+
const days = Math.max(1, dayDiff(task.start, endDay) + 1); // inclusive
|
|
41
|
+
return { task, left, width: Math.max(MIN_BAR_PX, days * ppd) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Header ticks across [start, end]. Granularity follows the zoom: a tick per day
|
|
46
|
+
* (day), per week (week), or per month (month). Each tick flags a month start so
|
|
47
|
+
* the view can draw a heavier divider / month band — the monday.com two-level feel.
|
|
48
|
+
*/
|
|
49
|
+
export function buildTicks(
|
|
50
|
+
axisStart: Date,
|
|
51
|
+
axisEnd: Date,
|
|
52
|
+
scale: GanttScale,
|
|
53
|
+
locale?: string,
|
|
54
|
+
weekStartsOn: Weekday = 1,
|
|
55
|
+
): GanttTick[] {
|
|
56
|
+
const ppd = pxPerDay(scale);
|
|
57
|
+
const ticks: GanttTick[] = [];
|
|
58
|
+
const at = (date: Date, label: string): GanttTick => ({
|
|
59
|
+
date,
|
|
60
|
+
left: dayDiff(axisStart, date) * ppd,
|
|
61
|
+
label,
|
|
62
|
+
monthStart: date.getDate() === 1,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (scale === "month") {
|
|
66
|
+
let cursor = startOfMonth(axisStart);
|
|
67
|
+
const fmt = new Intl.DateTimeFormat(locale, { month: "short", year: "2-digit" });
|
|
68
|
+
while (cursor <= axisEnd) {
|
|
69
|
+
ticks.push(at(cursor, fmt.format(cursor)));
|
|
70
|
+
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);
|
|
71
|
+
}
|
|
72
|
+
return ticks;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (scale === "week") {
|
|
76
|
+
let cursor = startOfWeek(axisStart, weekStartsOn);
|
|
77
|
+
const fmt = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" });
|
|
78
|
+
while (cursor <= axisEnd) {
|
|
79
|
+
ticks.push(at(cursor, fmt.format(cursor)));
|
|
80
|
+
cursor = addDays(cursor, 7);
|
|
81
|
+
}
|
|
82
|
+
return ticks;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// day
|
|
86
|
+
let cursor = startOfDay(axisStart);
|
|
87
|
+
while (cursor <= axisEnd) {
|
|
88
|
+
ticks.push(at(cursor, String(cursor.getDate())));
|
|
89
|
+
cursor = addDays(cursor, 1);
|
|
90
|
+
}
|
|
91
|
+
return ticks;
|
|
92
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gantt / timeline primitive — data model. A task is a labelled bar spanning
|
|
3
|
+
* [start, end] (end inclusive — a one-day task has start === end's day). `data`
|
|
4
|
+
* carries the consumer's row for render/press callbacks.
|
|
5
|
+
*/
|
|
6
|
+
export interface GanttTask<T = unknown> {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
start: Date;
|
|
10
|
+
/** Inclusive end day. Null/undefined → a single-day (milestone-ish) bar. */
|
|
11
|
+
end?: Date | null;
|
|
12
|
+
color?: string | null;
|
|
13
|
+
/** Optional grouping lane label (e.g. project / assignee). */
|
|
14
|
+
group?: string;
|
|
15
|
+
data?: T;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Zoom level of the horizontal time axis. */
|
|
19
|
+
export type GanttScale = "day" | "week" | "month";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* User-facing chrome strings. Defaults to English and taken as a prop — i18n is
|
|
23
|
+
* the consumer's responsibility (see the @lotics/ui convention in
|
|
24
|
+
* grid/data_grid.tsx). Axis tick labels come from `locale` via Intl.
|
|
25
|
+
*/
|
|
26
|
+
export interface GanttLabels {
|
|
27
|
+
day: string;
|
|
28
|
+
week: string;
|
|
29
|
+
month: string;
|
|
30
|
+
/** Frozen task-column header. */
|
|
31
|
+
task: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_GANTT_LABELS: GanttLabels = { day: "Day", week: "Week", month: "Month", task: "Task" };
|
|
35
|
+
|
|
36
|
+
/** A header tick on the time axis. */
|
|
37
|
+
export interface GanttTick {
|
|
38
|
+
date: Date;
|
|
39
|
+
/** px offset from the axis start. */
|
|
40
|
+
left: number;
|
|
41
|
+
label: string;
|
|
42
|
+
/** Start of a new month — used to draw the heavier divider + month band. */
|
|
43
|
+
monthStart: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Pixel geometry of one task bar on the axis. */
|
|
47
|
+
export interface GanttBar<T = unknown> {
|
|
48
|
+
task: GanttTask<T>;
|
|
49
|
+
left: number;
|
|
50
|
+
width: number;
|
|
51
|
+
}
|