@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.
- package/AGENTS.md +20 -0
- package/MIGRATION.md +58 -0
- package/docs/catalog.md +72 -13
- package/docs/composition.md +94 -6
- package/docs/reviewing.md +56 -5
- package/docs/templates.md +8 -2
- package/examples/tpl_calendar.tsx +22 -29
- package/examples/tpl_item_list.tsx +7 -2
- package/package.json +1 -1
- package/src/accordion.tsx +11 -5
- package/src/bar_chart.tsx +3 -1
- package/src/board.tsx +1 -2
- package/src/breakdown.tsx +3 -1
- package/src/calendar/agenda_view.tsx +136 -0
- package/src/calendar/calendar_toolbar.tsx +84 -0
- package/src/calendar/calendar_view.tsx +196 -103
- package/src/calendar/context.ts +47 -0
- package/src/calendar/dates.ts +36 -6
- package/src/calendar/event_chip.tsx +141 -0
- package/src/calendar/index.ts +31 -8
- package/src/calendar/layout.ts +113 -11
- package/src/calendar/month_view.tsx +194 -150
- package/src/calendar/repeat.ts +174 -0
- package/src/calendar/time_grid_view.tsx +182 -202
- package/src/calendar/types.ts +37 -11
- package/src/charge_lines.tsx +24 -5
- package/src/control_surface.ts +28 -0
- package/src/deadline.ts +10 -0
- package/src/file_row.tsx +26 -3
- package/src/finding.tsx +1 -1
- package/src/form_text_input.tsx +9 -2
- package/src/gantt/gantt_view.tsx +212 -119
- package/src/gantt/index.ts +2 -2
- package/src/gantt/scale.ts +38 -2
- package/src/gantt/types.ts +34 -7
- package/src/inline_slot.tsx +16 -2
- package/src/inline_static.tsx +18 -4
- package/src/legend_item.tsx +14 -1
- package/src/locale.tsx +30 -0
- package/src/matrix.tsx +19 -5
- package/src/member_chip.tsx +18 -1
- package/src/menu_button.tsx +12 -2
- package/src/option_list.tsx +11 -1
- package/src/progress_bar.tsx +14 -1
- package/src/record_summary.tsx +5 -0
- package/src/stacked_bar_chart.tsx +4 -1
- package/src/table_fit.ts +18 -1
- package/src/thumbnail_stack.tsx +18 -3
- 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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
306
|
+
// TRẠNG THÁI là 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: "
|
|
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
|
package/src/form_text_input.tsx
CHANGED
|
@@ -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
|
|
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
|
);
|
package/src/gantt/gantt_view.tsx
CHANGED
|
@@ -1,176 +1,160 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState
|
|
2
|
-
import { View, ScrollView, StyleSheet
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
62
|
-
const
|
|
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
|
|
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(
|
|
77
|
-
|
|
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(
|
|
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
|
-
<
|
|
95
|
-
{
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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={
|
|
108
|
-
<View style={
|
|
109
|
-
<Text size="xs" color="muted" style={
|
|
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
|
-
{
|
|
112
|
-
|
|
113
|
-
<View
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
{
|
|
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={{
|
|
129
|
+
style={[styles.tick, { left: tk.left, borderLeftWidth: tk.monthStart ? 1 : 0 }]}
|
|
128
130
|
>
|
|
129
|
-
<Text
|
|
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
|
-
{
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
});
|
package/src/gantt/index.ts
CHANGED
|
@@ -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";
|
package/src/gantt/scale.ts
CHANGED
|
@@ -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
|
package/src/gantt/types.ts
CHANGED
|
@@ -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
|
|
12
|
+
/** Inclusive end day. Null/undefined → a single-day bar. */
|
|
11
13
|
end?: Date | null;
|
|
12
|
-
|
|
13
|
-
|
|
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.
|
|
23
|
-
* the
|
|
24
|
-
*
|
|
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 = {
|
|
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 };
|