@lotics/ui 46.14.1 → 47.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +20 -0
  2. package/MIGRATION.md +58 -0
  3. package/docs/catalog.md +72 -13
  4. package/docs/composition.md +94 -6
  5. package/docs/reviewing.md +56 -5
  6. package/docs/templates.md +8 -2
  7. package/examples/tpl_calendar.tsx +22 -29
  8. package/examples/tpl_item_list.tsx +7 -2
  9. package/package.json +1 -1
  10. package/src/accordion.tsx +11 -5
  11. package/src/bar_chart.tsx +3 -1
  12. package/src/board.tsx +1 -2
  13. package/src/breakdown.tsx +3 -1
  14. package/src/calendar/agenda_view.tsx +136 -0
  15. package/src/calendar/calendar_toolbar.tsx +84 -0
  16. package/src/calendar/calendar_view.tsx +196 -103
  17. package/src/calendar/context.ts +47 -0
  18. package/src/calendar/dates.ts +36 -6
  19. package/src/calendar/event_chip.tsx +141 -0
  20. package/src/calendar/index.ts +31 -8
  21. package/src/calendar/layout.ts +113 -11
  22. package/src/calendar/month_view.tsx +194 -150
  23. package/src/calendar/repeat.ts +174 -0
  24. package/src/calendar/time_grid_view.tsx +182 -202
  25. package/src/calendar/types.ts +37 -11
  26. package/src/charge_lines.tsx +24 -5
  27. package/src/control_surface.ts +28 -0
  28. package/src/deadline.ts +10 -0
  29. package/src/file_row.tsx +26 -3
  30. package/src/finding.tsx +1 -1
  31. package/src/form_text_input.tsx +9 -2
  32. package/src/gantt/gantt_view.tsx +212 -119
  33. package/src/gantt/index.ts +2 -2
  34. package/src/gantt/scale.ts +38 -2
  35. package/src/gantt/types.ts +34 -7
  36. package/src/inline_slot.tsx +16 -2
  37. package/src/inline_static.tsx +18 -4
  38. package/src/legend_item.tsx +14 -1
  39. package/src/locale.tsx +30 -0
  40. package/src/matrix.tsx +19 -5
  41. package/src/member_chip.tsx +18 -1
  42. package/src/menu_button.tsx +12 -2
  43. package/src/option_list.tsx +11 -1
  44. package/src/progress_bar.tsx +14 -1
  45. package/src/record_summary.tsx +5 -0
  46. package/src/stacked_bar_chart.tsx +4 -1
  47. package/src/table_fit.ts +18 -1
  48. package/src/thumbnail_stack.tsx +18 -3
  49. package/src/use_option_list.ts +13 -1
package/src/deadline.ts CHANGED
@@ -120,6 +120,16 @@ export function countdownLabel(days: number, labels: DeadlineLabels): string {
120
120
  * same thing in a vocabulary the rest of the field grid does not speak.
121
121
  *
122
122
  * <DetailRow label="SI cut-off" {...deadlineAnnotation(days, words.deadline)}>
123
+ *
124
+ * **Spread it BEFORE the row's own `description`, never after.** Outside the
125
+ * warning window this returns `{ description }`, so spreading it last silently
126
+ * overwrites a `description=` the caller wrote — and only on the rows that are
127
+ * NOT urgent, which is most of them. Nothing renders empty and nothing throws;
128
+ * the field's standing rule sentence simply stops appearing, and the screen
129
+ * that lost it looks finished. Put the spread first and a caller's own
130
+ * `description` wins, which is the order a reader expects anyway:
131
+ *
132
+ * <DetailRow label="Đến ngày" {...deadlineAnnotation(d, w)} description="…">
123
133
  */
124
134
  export function deadlineAnnotation(
125
135
  days: number,
package/src/file_row.tsx CHANGED
@@ -172,7 +172,16 @@ export function FileRow({
172
172
  const body = (
173
173
  <>
174
174
  <View style={styles.text}>
175
- <Text size="sm" weight="medium" numberOfLines={1}>
175
+ {/* HAI dòng, không một. Ở một hàng chật không cách sắp xếp nào nhét vừa
176
+ một cái tên 280px vào 79px trên một dòng — nên lựa chọn thật sự là
177
+ cắt nó hay cho nó xuống dòng, và cái bị cắt ở đây là thứ NHẬN DIỆN
178
+ cả hàng. Chiều dọc thì không mất gì: tên ngắn vẫn một dòng, chỉ tên
179
+ dài mới cao thêm.
180
+
181
+ Cho khe `status` `flexShrink` là chưa đủ và đã thử: cái nút bên trong
182
+ khe ấy do người gọi dựng, nó cứng, nên co cái vỏ không làm nội dung
183
+ hẹp lại. */}
184
+ <Text size="sm" weight="medium" numberOfLines={2}>
176
185
  {name}
177
186
  </Text>
178
187
  {/* A STRING keeps the muted single-line treatment; a node renders as
@@ -294,6 +303,20 @@ const styles = StyleSheet.create({
294
303
  rowPressed: { backgroundColor: colors.zinc["100"] },
295
304
  // The accessible "Open" door — fills the row left of the trailing sibling.
296
305
  door: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12, cursor: CURSOR_DEFAULT },
297
- status: { marginLeft: "auto", paddingLeft: 12 },
298
- text: { flex: 1, gap: 1 },
306
+ // TRẠNG THÁI bên nhường chỗ, không phải tên tệp.
307
+ //
308
+ // `flexShrink` trong React Native mặc định là 0 — khác web — nên ô trạng thái
309
+ // giữ nguyên bề ngang tự nhiên của nó mãi mãi, còn `text` (flex: 1, tức basis
310
+ // 0) nuốt trọn phần bị ép. Kết quả ở màn hẹp: một huy hiệu "Còn hiệu lực"
311
+ // chiếm 137 trên 202 pixel và cái bị cắt là TÊN TỆP — thứ nhận diện cả hàng.
312
+ // Hai đợt rà soát ứng dụng độc lập đều đâm vào chỗ này và đều phải lách ở
313
+ // phía ứng dụng.
314
+ //
315
+ // Bộ giao diện có sẵn luật cho việc này: một hàng phải nói rõ bên nào GIỮ.
316
+ // Bên giữ là tên; trạng thái co lại trước. Ở bề ngang rộng không có gì co cả
317
+ // — chỉ khi hàng chật thì thứ tự nhường mới có ý nghĩa.
318
+ status: { marginLeft: "auto", paddingLeft: 12, flexShrink: 1, minWidth: 0 },
319
+ // `minWidth: 0` là thứ cho một con flex thật sự nhỏ hơn nội dung của nó; thiếu
320
+ // nó thì `numberOfLines` không bao giờ có cơ hội cắt vì hộp không chịu hẹp lại.
321
+ text: { flex: 1, gap: 1, minWidth: 0 },
299
322
  });
package/src/finding.tsx CHANGED
@@ -220,7 +220,7 @@ const styles = StyleSheet.create({
220
220
  // being read aloud, the same defect `CommentsButton` was fixed for. It also
221
221
  // wrapped: at phone width a line could BEGIN with the separator. Stacking pairs
222
222
  // each source with its own value at every width and needs no separator at all.
223
- readings: { flexDirection: "column", alignItems: "flex-start", gap: 4 },
223
+ readings: { flexDirection: "column", alignItems: "stretch", gap: 4 },
224
224
  reading: { flexDirection: "row", alignItems: "baseline", gap: 5, minWidth: 0 },
225
225
  // The same split as `title`/`delta` one row up, for the same reason: the SOURCE
226
226
  // is a label and gives way, the VALUE is the figure and never does. Both halves
@@ -7,10 +7,17 @@ export interface FormTextInputProps extends Omit<FormFieldProps, "style">, TextI
7
7
  /** `FormField` (label / description / warning / error / optional) wrapping a `TextInputField` — one
8
8
  * labeled text field. For a 2-col grid cell, prefer a bare `FormField style={half}` around the input. */
9
9
  export function FormTextInput(props: FormTextInputProps) {
10
- const { label, description, warning, optional, error, ...textInputProps } = props;
10
+ const { label, description, warning, optional, optionalLabel, error, ...textInputProps } = props;
11
11
 
12
12
  return (
13
- <FormField label={label} description={description} warning={warning} error={error} optional={optional}>
13
+ <FormField
14
+ label={label}
15
+ description={description}
16
+ warning={warning}
17
+ error={error}
18
+ optional={optional}
19
+ optionalLabel={optionalLabel}
20
+ >
14
21
  <TextInputField {...textInputProps} />
15
22
  </FormField>
16
23
  );
@@ -1,176 +1,160 @@
1
- import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
- import { View, ScrollView, StyleSheet, type StyleProp, type ViewStyle } from "react-native";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { View, ScrollView, StyleSheet } from "react-native";
3
3
  import { Text } from "../text";
4
- import { colors } from "../colors";
4
+ import { colors, solid } from "../colors";
5
+ import { proportionalRadius } from "../control_surface";
5
6
  import { FocusRingPressable } from "../focus_ring_pressable";
6
-
7
- function GanttButton(props: {
8
- onPress?: () => void;
9
- accessibilityLabel?: string;
10
- style: StyleProp<ViewStyle>;
11
- children: ReactNode;
12
- }) {
13
- return (
14
- <FocusRingPressable
15
- onPress={props.onPress}
16
- accessibilityRole={props.onPress ? "button" : undefined}
17
- accessibilityLabel={props.accessibilityLabel}
18
- style={props.style}
19
- >
20
- {props.children}
21
- </FocusRingPressable>
22
- );
23
- }
24
- import { addDays, dayDiff } from "../calendar/dates";
25
- import { usePointerDrag } from "../use_pointer_drag";
26
- import { axisRange, barGeometry, buildTicks, pxPerDay } from "./scale";
27
- import { DEFAULT_GANTT_LABELS } from "./types";
7
+ import { SegmentedControl, type SegmentOption } from "../segmented_control";
8
+ import { useLoticsLocale } from "../locale";
9
+ import { dayDiff } from "../calendar/dates";
10
+ import { axisRange, barGeometry, buildRows, buildTicks, pxPerDay } from "./scale";
28
11
  import type { GanttLabels, GanttScale, GanttTask } from "./types";
29
12
 
30
13
  const LABEL_W = 188;
31
14
  const HEADER_H = 38;
32
15
  const ROW_H = 40;
16
+ const GROUP_H = 30;
33
17
  const BAR_H = 22;
34
- const HANDLE_W = 8;
35
- const MIN_BAR = 8;
18
+ const MILESTONE = 14;
36
19
  const SCALES: GanttScale[] = ["day", "week", "month"];
37
20
 
38
21
  export interface GanttViewProps<T = unknown> {
39
22
  tasks: GanttTask<T>[];
40
23
  defaultScale?: GanttScale;
24
+ /** What counts as today for the marker and the opening scroll position. */
41
25
  today?: Date;
42
26
  locale?: string;
43
27
  /** Optional toolbar caption shown left of the zoom switch. */
44
28
  title?: string;
45
- /** User-facing chrome strings; defaults to English. */
29
+ /** Per-instance overrides; the rest resolve from `LoticsLocale.gantt`. */
46
30
  labels?: Partial<GanttLabels>;
47
31
  onTaskPress?: (task: GanttTask<T>) => void;
48
- /** When set, each bar gets a right-edge resize handle; dragging it adjusts the
49
- * task's end. Receives the task + the new inclusive end day (clamped ≥ start). */
50
- onTaskResize?: (task: GanttTask<T>, newEnd: Date) => void;
51
32
  }
52
33
 
53
34
  /**
54
- * Timeline / gantt: a frozen task-label column beside a horizontally-scrollable
55
- * zoomable axis. Bars are positioned by {@link barGeometry}; month boundaries get
56
- * a heavier gridline; a red line marks today. The axis opens scrolled to today
57
- * so a long history doesn't bury the active range. Renders at its natural height
58
- * wrap in a ScrollView for very long task lists.
35
+ * A Gantt chart: a frozen label column beside a zoomable, horizontally
36
+ * scrollable axis, with tasks in lanes and dependency arrows between them.
37
+ *
38
+ * **Reach for this over `CalendarView` when the ROWS are the subject** a
39
+ * phase, an owner, a vehicle, a channel and dates are the horizontal axis. A
40
+ * calendar answers "what happens on the 14th"; this answers "what is this team
41
+ * doing, and what is waiting on what".
42
+ *
43
+ * **Lanes** come from `group`, **milestones** render as diamonds, and `progress`
44
+ * fills a portion of the bar. There are deliberately no dependency connectors:
45
+ * lane order and bar position already carry the sequence, and elbow arrows over
46
+ * a ruled grid add line noise to say it a second time.
47
+ *
48
+ * Renders at its natural height — wrap in a `ScrollView` for very long lists.
59
49
  */
60
50
  export function GanttView<T = unknown>(props: GanttViewProps<T>) {
61
- const { tasks, defaultScale = "week", today = new Date(), locale, title, onTaskPress, onTaskResize } = props;
62
- const L = { ...DEFAULT_GANTT_LABELS, ...props.labels };
51
+ const { tasks, defaultScale = "week", today = new Date(), locale, title, onTaskPress } = props;
52
+ const pack = useLoticsLocale();
53
+ const L: GanttLabels = { ...pack.gantt, ...props.labels };
63
54
  const [scale, setScale] = useState<GanttScale>(defaultScale);
64
55
 
65
- const taskById = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
66
- const { live, bind } = usePointerDrag((id, _pointer, delta) => {
67
- const t = taskById.get(id);
68
- if (!t || !onTaskResize) return;
69
- const days = Math.round(delta.dx / pxPerDay(scale));
70
- if (days === 0) return;
71
- const newEnd = addDays(t.end ?? t.start, days);
72
- onTaskResize(t, newEnd < t.start ? t.start : newEnd);
73
- });
74
-
56
+ const rows = useMemo(() => buildRows(tasks), [tasks]);
75
57
  const { start: axisStart, end: axisEnd } = useMemo(() => axisRange(tasks, today), [tasks, today]);
76
- const ticks = useMemo(() => buildTicks(axisStart, axisEnd, scale, locale), [axisStart, axisEnd, scale, locale]);
77
- const axisWidth = useMemo(() => (dayDiff(axisStart, axisEnd) + 1) * pxPerDay(scale), [axisStart, axisEnd, scale]);
58
+ const ticks = useMemo(
59
+ () => buildTicks(axisStart, axisEnd, scale, locale),
60
+ [axisStart, axisEnd, scale, locale],
61
+ );
62
+ const axisWidth = useMemo(
63
+ () => (dayDiff(axisStart, axisEnd) + 1) * pxPerDay(scale),
64
+ [axisStart, axisEnd, scale],
65
+ );
78
66
  const todayLeft = dayDiff(axisStart, today) * pxPerDay(scale);
79
67
  const todayInRange = today >= axisStart && today <= axisEnd;
80
68
 
81
- // Open scrolled to today (a margin of recent past visible), like the time-grid
82
- // scrolls to "now" — keeps the active range in view when history is long.
83
69
  const scrollRef = useRef<ScrollView>(null);
84
70
  useEffect(() => {
85
- const id = setTimeout(() => scrollRef.current?.scrollTo({ x: Math.max(0, todayLeft - 96), animated: false }), 0);
71
+ const id = setTimeout(
72
+ () => scrollRef.current?.scrollTo({ x: Math.max(0, todayLeft - 96), animated: false }),
73
+ 0,
74
+ );
86
75
  return () => clearTimeout(id);
87
76
  }, [scale, todayLeft]);
88
77
 
78
+ const options: SegmentOption<GanttScale>[] = SCALES.map((s) => ({ label: L[s], value: s }));
79
+
80
+ if (tasks.length === 0) {
81
+ return (
82
+ <View style={styles.empty}>
83
+ <Text size="sm" color="muted">{L.empty}</Text>
84
+ </View>
85
+ );
86
+ }
87
+
89
88
  return (
90
89
  <View style={styles.root}>
91
- {/* Toolbar: zoom */}
92
90
  <View style={styles.toolbar}>
93
91
  {title ? <Text size="sm" color="muted">{title}</Text> : <View />}
94
- <View style={styles.zoomSwitch}>
95
- {SCALES.map((s) => (
96
- <GanttButton key={s} onPress={() => setScale(s)} style={[styles.zoomBtn, scale === s && styles.zoomBtnActive]}>
97
- <Text size="sm" weight={scale === s ? "medium" : "regular"} color={scale === s ? "default" : "muted"}>
98
- {L[s]}
99
- </Text>
100
- </GanttButton>
101
- ))}
102
- </View>
92
+ <SegmentedControl<GanttScale>
93
+ accessibilityLabel={L.task}
94
+ options={options}
95
+ value={scale}
96
+ onValueChange={setScale}
97
+ />
103
98
  </View>
104
99
 
105
100
  <View style={{ flexDirection: "row", flex: 1 }}>
106
101
  {/* Frozen label column */}
107
- <View style={{ width: LABEL_W, borderRightWidth: 1, borderRightColor: colors.border }}>
108
- <View style={[styles.headerCell, { height: HEADER_H, paddingLeft: 12, justifyContent: "center" }]}>
109
- <Text size="xs" color="muted" style={{ textTransform: "uppercase" }}>{L.task}</Text>
102
+ <View style={styles.labelColumn}>
103
+ <View style={styles.labelHeader}>
104
+ <Text size="xs" color="muted" style={styles.uppercase}>{L.task}</Text>
110
105
  </View>
111
- {tasks.map((t) => (
112
- <View key={t.id} style={[styles.labelRow, { height: ROW_H }]}>
113
- <View style={{ width: 8, height: 8, borderRadius: 2, backgroundColor: t.color || colors.teal[600] }} />
114
- <Text size="sm" numberOfLines={1} style={{ flex: 1 }}>{t.label}</Text>
115
- </View>
116
- ))}
106
+ {rows.map((row) =>
107
+ row.kind === "group" ? (
108
+ <View key={`g${row.group}`} style={styles.groupLabel}>
109
+ <Text size="xs" weight="semibold" color="muted" numberOfLines={1} style={styles.uppercase}>
110
+ {row.group}
111
+ </Text>
112
+ </View>
113
+ ) : (
114
+ <View key={row.task.id} style={styles.labelRow}>
115
+ <View style={[styles.swatch, { backgroundColor: solid(row.task.color ?? "teal") }]} />
116
+ <Text size="sm" numberOfLines={1} style={styles.labelText}>{row.task.label}</Text>
117
+ </View>
118
+ ),
119
+ )}
117
120
  </View>
118
121
 
119
122
  {/* Scrollable timeline */}
120
123
  <ScrollView ref={scrollRef} horizontal style={{ flex: 1 }} showsHorizontalScrollIndicator>
121
124
  <View style={{ width: axisWidth }}>
122
- {/* Header ticks */}
123
- <View style={{ height: HEADER_H, flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border }}>
125
+ <View style={styles.tickHeader}>
124
126
  {ticks.map((tk) => (
125
127
  <View
126
128
  key={tk.date.toISOString()}
127
- style={{ position: "absolute", left: tk.left, top: 0, bottom: 0, justifyContent: "center", paddingLeft: 4, borderLeftWidth: tk.monthStart ? 1 : 0, borderLeftColor: colors.zinc[300] }}
129
+ style={[styles.tick, { left: tk.left, borderLeftWidth: tk.monthStart ? 1 : 0 }]}
128
130
  >
129
- <Text size="xs" weight={tk.monthStart ? "medium" : "regular"} color={tk.monthStart ? "default" : "muted"}>
131
+ <Text
132
+ size="xs"
133
+ weight={tk.monthStart ? "medium" : "regular"}
134
+ color={tk.monthStart ? "default" : "muted"}
135
+ >
130
136
  {tk.label}
131
137
  </Text>
132
138
  </View>
133
139
  ))}
134
140
  </View>
135
141
 
136
- {/* Task rows */}
137
- {tasks.map((t) => {
138
- const base = barGeometry(t, axisStart, scale);
139
- const dragging = live && live.id === t.id ? live : null;
140
- const width = dragging ? Math.max(MIN_BAR, base.width + dragging.dx) : base.width;
141
- const accent = t.color || colors.teal[600];
142
- return (
143
- <View key={t.id} style={{ height: ROW_H, borderBottomWidth: 1, borderBottomColor: colors.zinc[100] }}>
144
- {ticks.map((tk) =>
145
- tk.monthStart ? (
146
- <View key={tk.date.toISOString()} style={{ position: "absolute", left: tk.left, top: 0, bottom: 0, borderLeftWidth: 1, borderLeftColor: colors.zinc[200] }} />
147
- ) : null,
148
- )}
149
- <GanttButton
150
- onPress={onTaskPress ? () => onTaskPress(t) : undefined}
151
- accessibilityLabel={t.label}
152
- style={{ position: "absolute", left: base.left, width, top: (ROW_H - BAR_H) / 2, height: BAR_H }}
153
- >
154
- <View style={{ flex: 1, borderRadius: 5, backgroundColor: accent, justifyContent: "center", paddingHorizontal: 8, opacity: dragging ? 0.85 : 1 }}>
155
- <Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.white }}>{t.label}</Text>
156
- </View>
157
- </GanttButton>
158
- {onTaskResize ? (
159
- <View
160
- ref={bind(t.id, "ew-resize")}
161
- style={{ position: "absolute", left: base.left + width - HANDLE_W, width: HANDLE_W + 6, top: (ROW_H - BAR_H) / 2, height: BAR_H, alignItems: "center", justifyContent: "center", zIndex: 2 }}
162
- >
163
- <View style={{ width: 3, height: 11, borderRadius: 2, backgroundColor: "rgba(255,255,255,0.75)" }} />
164
- </View>
165
- ) : null}
166
- </View>
167
- );
168
- })}
142
+ {rows.map((row) =>
143
+ row.kind === "group" ? (
144
+ <View key={`g${row.group}`} style={styles.groupBand} />
145
+ ) : (
146
+ <TaskRow
147
+ key={row.task.id}
148
+ task={row.task}
149
+ axisStart={axisStart}
150
+ scale={scale}
151
+ monthTicks={ticks}
152
+ onPress={onTaskPress ? () => onTaskPress(row.task) : undefined}
153
+ />
154
+ ),
155
+ )}
169
156
 
170
- {/* Today marker */}
171
- {todayInRange ? (
172
- <View style={{ position: "absolute", left: todayLeft, top: 0, bottom: 0, width: 2, backgroundColor: colors.red[500] }} />
173
- ) : null}
157
+ {todayInRange ? <View style={[styles.todayLine, { left: todayLeft }]} /> : null}
174
158
  </View>
175
159
  </ScrollView>
176
160
  </View>
@@ -178,12 +162,121 @@ export function GanttView<T = unknown>(props: GanttViewProps<T>) {
178
162
  );
179
163
  }
180
164
 
165
+ function TaskRow<T>(props: {
166
+ task: GanttTask<T>;
167
+ axisStart: Date;
168
+ scale: GanttScale;
169
+ monthTicks: { date: Date; left: number; monthStart: boolean }[];
170
+ onPress?: () => void;
171
+ }) {
172
+ const { task, axisStart, scale, monthTicks, onPress } = props;
173
+ const bar = barGeometry(task, axisStart, scale);
174
+ const hue = colors[task.color ?? "teal"];
175
+ // The same wash-and-ink discipline the calendar's chips use: the track is the
176
+ // family's lightest ground and the progress a step up from it, never the
177
+ // saturated 500 — a row of solid bars is the paint chart this moved away from.
178
+ // The milestone keeps the solid shade: it is 14px and carries identity.
179
+ const accent = solid(task.color ?? "teal");
180
+ // Same rule as the calendar's chips: the corner is a proportion of the box,
181
+ // so a 22px bar is not rendered as a pill.
182
+ const radius = proportionalRadius(BAR_H);
183
+
184
+ return (
185
+ <View style={styles.taskRow}>
186
+ {monthTicks.map((tk) =>
187
+ tk.monthStart ? (
188
+ <View key={tk.date.toISOString()} style={[styles.monthRule, { left: tk.left }]} />
189
+ ) : null,
190
+ )}
191
+ {task.milestone ? (
192
+ <FocusRingPressable
193
+ onPress={onPress}
194
+ accessibilityRole={onPress ? "button" : undefined}
195
+ accessibilityLabel={task.label}
196
+ style={[styles.milestoneHit, { left: bar.left - MILESTONE / 2 }]}
197
+ >
198
+ {/* A rotated square: a moment has no duration, and drawing it as a
199
+ one-day bar reads as a day of work that was never scheduled. */}
200
+ <View style={[styles.milestone, { backgroundColor: accent }]} />
201
+ </FocusRingPressable>
202
+ ) : (
203
+ <FocusRingPressable
204
+ onPress={onPress}
205
+ accessibilityRole={onPress ? "button" : undefined}
206
+ accessibilityLabel={task.label}
207
+ style={[styles.barHit, { left: bar.left, width: bar.width }]}
208
+ >
209
+ {({ hovered }) => (
210
+ <View style={[styles.bar, { backgroundColor: onPress && hovered ? hue[200] : hue[100], borderRadius: radius }]}>
211
+ {task.progress != null ? (
212
+ <View
213
+ style={[
214
+ styles.progress,
215
+ { backgroundColor: hue[400], width: `${Math.max(0, Math.min(1, task.progress)) * 100}%` },
216
+ ]}
217
+ />
218
+ ) : (
219
+ <View style={[styles.progress, { backgroundColor: hue[300], width: "100%" }]} />
220
+ )}
221
+ </View>
222
+ )}
223
+ </FocusRingPressable>
224
+ )}
225
+ </View>
226
+ );
227
+ }
228
+
181
229
  const styles = StyleSheet.create({
182
230
  root: { flex: 1, backgroundColor: colors.white },
183
- toolbar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 14, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: colors.border },
184
- zoomSwitch: { flexDirection: "row", backgroundColor: colors.zinc[100], borderRadius: 10, padding: 2 },
185
- zoomBtn: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 8 },
186
- zoomBtnActive: { backgroundColor: colors.white },
187
- headerCell: { borderBottomWidth: 1, borderBottomColor: colors.border },
188
- labelRow: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, borderBottomWidth: 1, borderBottomColor: colors.zinc[100] },
231
+ empty: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.white },
232
+ toolbar: {
233
+ flexDirection: "row",
234
+ alignItems: "center",
235
+ justifyContent: "space-between",
236
+ paddingHorizontal: 14,
237
+ paddingVertical: 10,
238
+ gap: 12,
239
+ },
240
+ // No rule down the column: the frozen half is set off by the axis content
241
+ // beginning, and a border here is one more line on a surface already ruled
242
+ // horizontally by every row.
243
+ labelColumn: { width: LABEL_W },
244
+ labelHeader: { height: HEADER_H, paddingLeft: 12, justifyContent: "center" },
245
+ uppercase: { textTransform: "uppercase" },
246
+ groupLabel: {
247
+ height: GROUP_H,
248
+ justifyContent: "flex-end",
249
+ paddingHorizontal: 12,
250
+ paddingBottom: 6,
251
+ },
252
+ labelRow: { height: ROW_H, flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12 },
253
+ swatch: { width: 8, height: 8, borderRadius: 2, flexShrink: 0 },
254
+ labelText: { flexShrink: 1, minWidth: 0 },
255
+ tickHeader: { height: HEADER_H, flexDirection: "row" },
256
+ tick: {
257
+ position: "absolute",
258
+ top: 0,
259
+ bottom: 0,
260
+ justifyContent: "center",
261
+ paddingLeft: 4,
262
+ borderLeftColor: colors.zinc[300],
263
+ },
264
+ groupBand: { height: GROUP_H },
265
+ taskRow: { height: ROW_H },
266
+ monthRule: { position: "absolute", top: 0, bottom: 0, borderLeftWidth: 1, borderLeftColor: colors.zinc[200] },
267
+ // The bar carries no label: the frozen column beside it already names the
268
+ // task, and repeating it inside every bar is the same string twice on one line.
269
+ barHit: { position: "absolute", top: (ROW_H - BAR_H) / 2, height: BAR_H },
270
+ bar: { flex: 1, overflow: "hidden" },
271
+ progress: { position: "absolute", left: 0, top: 0, bottom: 0 },
272
+ milestoneHit: {
273
+ position: "absolute",
274
+ top: (ROW_H - MILESTONE) / 2 - 3,
275
+ width: MILESTONE + 6,
276
+ height: MILESTONE + 6,
277
+ alignItems: "center",
278
+ justifyContent: "center",
279
+ },
280
+ milestone: { width: MILESTONE, height: MILESTONE, transform: [{ rotate: "45deg" }], borderRadius: 4 },
281
+ todayLine: { position: "absolute", top: 0, bottom: 0, width: 2, backgroundColor: colors.red[500] },
189
282
  });
@@ -1,5 +1,5 @@
1
1
  export { GanttView } from "./gantt_view";
2
2
  export type { GanttViewProps } from "./gantt_view";
3
- export { barGeometry, axisRange, buildTicks, pxPerDay } from "./scale";
3
+ export { barGeometry, axisRange, buildTicks, buildRows, pxPerDay } from "./scale";
4
4
  export { DEFAULT_GANTT_LABELS } from "./types";
5
- export type { GanttTask, GanttScale, GanttTick, GanttBar, GanttLabels } from "./types";
5
+ export type { GanttTask, GanttScale, GanttTick, GanttBar, GanttRow, GanttLabels } from "./types";
@@ -1,6 +1,6 @@
1
1
  import { addDays, dayDiff, startOfDay, startOfMonth, startOfWeek } from "../calendar/dates";
2
2
  import type { Weekday } from "../calendar/types";
3
- import type { GanttBar, GanttScale, GanttTask, GanttTick } from "./types";
3
+ import type { GanttBar, GanttRow, GanttScale, GanttTask, GanttTick } from "./types";
4
4
 
5
5
  const MIN_BAR_PX = 8;
6
6
 
@@ -32,15 +32,51 @@ export function axisRange(tasks: GanttTask[], today: Date = new Date(), padDays
32
32
  return { start: addDays(startOfDay(min), -padDays), end: addDays(startOfDay(max), padDays) };
33
33
  }
34
34
 
35
- /** Bar geometry: left = days from axis start; width spans the inclusive end. */
35
+ /** Bar geometry: left = days from axis start; width spans the inclusive end.
36
+ * A milestone has no span — it is a marker centred on its day. */
36
37
  export function barGeometry<T>(task: GanttTask<T>, axisStart: Date, scale: GanttScale): GanttBar<T> {
37
38
  const ppd = pxPerDay(scale);
38
39
  const left = dayDiff(axisStart, task.start) * ppd;
40
+ if (task.milestone) return { task, left, width: 0 };
39
41
  const endDay = task.end ?? task.start;
40
42
  const days = Math.max(1, dayDiff(task.start, endDay) + 1); // inclusive
41
43
  return { task, left, width: Math.max(MIN_BAR_PX, days * ppd) };
42
44
  }
43
45
 
46
+ /**
47
+ * The chart's lines, in render order: each lane's heading followed by its tasks.
48
+ *
49
+ * `group` was declared on `GanttTask` and read by NOTHING — the type documented
50
+ * lanes, the gallery passed them, and every task rendered in one flat list. This
51
+ * is what makes the promise real. Ungrouped tasks lead, so a chart that uses no
52
+ * groups is unchanged; lanes then appear in the order they are first seen,
53
+ * which keeps the order the caller's own array states rather than sorting
54
+ * alphabetically behind their back.
55
+ */
56
+ export function buildRows<T>(tasks: GanttTask<T>[]): GanttRow<T>[] {
57
+ const ungrouped = tasks.filter((t) => !t.group);
58
+ const order: string[] = [];
59
+ const byGroup = new Map<string, GanttTask<T>[]>();
60
+ for (const t of tasks) {
61
+ if (!t.group) continue;
62
+ const existing = byGroup.get(t.group);
63
+ if (existing) existing.push(t);
64
+ else {
65
+ order.push(t.group);
66
+ byGroup.set(t.group, [t]);
67
+ }
68
+ }
69
+
70
+ const rows: GanttRow<T>[] = [];
71
+ let index = 0;
72
+ for (const task of ungrouped) rows.push({ kind: "task", task, index: index++ });
73
+ for (const group of order) {
74
+ rows.push({ kind: "group", group, index: index++ });
75
+ for (const task of byGroup.get(group) ?? []) rows.push({ kind: "task", task, index: index++ });
76
+ }
77
+ return rows;
78
+ }
79
+
44
80
  /**
45
81
  * Header ticks across [start, end]. Granularity follows the zoom: a tick per day
46
82
  * (day), per week (week), or per month (month). Each tick flags a month start so
@@ -1,3 +1,5 @@
1
+ import type { ColorName } from "../color_tokens";
2
+
1
3
  /**
2
4
  * Gantt / timeline primitive — data model. A task is a labelled bar spanning
3
5
  * [start, end] (end inclusive — a one-day task has start === end's day). `data`
@@ -7,11 +9,23 @@ export interface GanttTask<T = unknown> {
7
9
  id: string;
8
10
  label: string;
9
11
  start: Date;
10
- /** Inclusive end day. Null/undefined → a single-day (milestone-ish) bar. */
12
+ /** Inclusive end day. Null/undefined → a single-day bar. */
11
13
  end?: Date | null;
12
- color?: string | null;
13
- /** Optional grouping lane label (e.g. project / assignee). */
14
+ /**
15
+ * Design-token colour, matching `CalendarEvent.color`. A TOKEN, not a hex:
16
+ * the bar derives a fill, a progress overlay and a swatch from it.
17
+ */
18
+ color?: ColorName;
19
+ /**
20
+ * Lane this task belongs to — a phase, an owner, a vehicle. Tasks sharing a
21
+ * `group` render under one heading, in the order first seen; tasks without
22
+ * one lead, ungrouped.
23
+ */
14
24
  group?: string;
25
+ /** A moment rather than a span — drawn as a diamond, no width. `end` is ignored. */
26
+ milestone?: boolean;
27
+ /** 0–1. Draws a solid portion inside the bar; omit for no progress overlay. */
28
+ progress?: number;
15
29
  data?: T;
16
30
  }
17
31
 
@@ -19,9 +33,9 @@ export interface GanttTask<T = unknown> {
19
33
  export type GanttScale = "day" | "week" | "month";
20
34
 
21
35
  /**
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.
36
+ * User-facing chrome strings. Resolution is **prop `LoticsLocale.gantt`
37
+ * English default**, the kit-wide rule; `labels` overrides one instance.
38
+ * Axis tick labels come from `locale` via Intl.
25
39
  */
26
40
  export interface GanttLabels {
27
41
  day: string;
@@ -29,9 +43,17 @@ export interface GanttLabels {
29
43
  month: string;
30
44
  /** Frozen task-column header. */
31
45
  task: string;
46
+ /** Shown when there is nothing to chart. */
47
+ empty: string;
32
48
  }
33
49
 
34
- export const DEFAULT_GANTT_LABELS: GanttLabels = { day: "Day", week: "Week", month: "Month", task: "Task" };
50
+ export const DEFAULT_GANTT_LABELS: GanttLabels = {
51
+ day: "Day",
52
+ week: "Week",
53
+ month: "Month",
54
+ task: "Task",
55
+ empty: "Nothing scheduled",
56
+ };
35
57
 
36
58
  /** A header tick on the time axis. */
37
59
  export interface GanttTick {
@@ -49,3 +71,8 @@ export interface GanttBar<T = unknown> {
49
71
  left: number;
50
72
  width: number;
51
73
  }
74
+
75
+ /** One rendered line of the chart: a lane heading, or a task at a row index. */
76
+ export type GanttRow<T = unknown> =
77
+ | { kind: "group"; group: string; index: number }
78
+ | { kind: "task"; task: GanttTask<T>; index: number };