@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
@@ -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 { options, onSelect, onRequestClose, enableSearch, searchPlaceholder = "Search..." } = props;
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 ref={scrollViewRef} style={styles.optionsList}>
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(url: string, filename: string): Promise<void> {
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, { cache: "no-store" });
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
  }
@@ -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
- <View style={[{ paddingBottom: 16 }, style]}>
20
- <View
21
- style={{
22
- flexDirection: "row",
23
- alignItems: "center",
24
- justifyContent: "space-between",
25
- }}
26
- >
27
- {!!label && (
28
- <Text numberOfLines={1} weight="medium">
29
- {label}
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
- {!!optional && (
33
- <Text numberOfLines={1} size="sm" color="zinc-500">
34
- {optionalLabel}
35
- </Text>
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
- {!!description && <Text color="muted">{description}</Text>}
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
  }
@@ -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 value={value} onChange={onChange} />
24
+ <Switch
25
+ value={value}
26
+ onChange={onChange}
27
+ accessibilityLabel={label}
28
+ />
22
29
  <View style={{ flex: 1 }}>
23
- <Text onPress={() => onChange?.(!value)} weight="medium" userSelect="none">
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 color="danger">{error}</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
+ }
@@ -17,6 +17,7 @@ export const SelectHeaderCell = memo(function SelectHeaderCell() {
17
17
  }}
18
18
  >
19
19
  <CheckboxInput
20
+ accessibilityLabel="Select all rows"
20
21
  testID="table-header-checkbox"
21
22
  indeterminate={isIndeterminate}
22
23
  checked={isRowSelected}