@lotics/ui 46.14.1 → 47.0.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 +38 -9
- package/docs/composition.md +52 -0
- package/docs/reviewing.md +19 -2
- package/docs/templates.md +8 -2
- package/examples/tpl_calendar.tsx +22 -29
- package/package.json +1 -1
- package/src/board.tsx +1 -2
- 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/control_surface.ts +28 -0
- package/src/deadline.ts +10 -0
- package/src/file_row.tsx +26 -3
- 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/legend_item.tsx +14 -1
- package/src/locale.tsx +30 -0
- package/src/matrix.tsx +19 -5
- package/src/option_list.tsx +11 -1
- package/src/use_option_list.ts +13 -1
|
@@ -1,174 +1,124 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState
|
|
2
|
-
import { View, ScrollView, StyleSheet, Text as RNText
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { View, ScrollView, StyleSheet, Text as RNText } from "react-native";
|
|
3
3
|
import { Text } from "../text";
|
|
4
4
|
import { colors } from "../colors";
|
|
5
5
|
import { FocusRingPressable } from "../focus_ring_pressable";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 { layoutDayColumns, packEventLanes } from "./layout";
|
|
25
|
-
import {
|
|
26
|
-
daysInView,
|
|
27
|
-
formatTime,
|
|
28
|
-
hourLabel,
|
|
29
|
-
isSameDay,
|
|
30
|
-
isToday,
|
|
31
|
-
minutesSinceMidnight,
|
|
32
|
-
startOfDay,
|
|
33
|
-
weekdayShort,
|
|
34
|
-
} from "./dates";
|
|
35
|
-
import type { CalendarEvent, Weekday } from "./types";
|
|
6
|
+
import { useCalendar } from "./context";
|
|
7
|
+
import { EventChip } from "./event_chip";
|
|
8
|
+
import { initialScrollMinutes, isBanner, layoutDayColumns, packEventLanes } from "./layout";
|
|
9
|
+
import { daysInView, formatTime, hourLabel, isToday, minutesSinceMidnight, startOfDay } from "./dates";
|
|
10
|
+
import type { CalendarEvent } from "./types";
|
|
36
11
|
|
|
37
12
|
const HOUR_HEIGHT = 44;
|
|
38
13
|
const PX_PER_MIN = HOUR_HEIGHT / 60;
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const ALLDAY_LANE_H = 24;
|
|
42
|
-
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
|
14
|
+
const GUTTER = 52;
|
|
15
|
+
const ALLDAY_LANE_H = 22;
|
|
43
16
|
const COL_GAP = 2;
|
|
44
17
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The hour window a grid actually draws.
|
|
20
|
+
*
|
|
21
|
+
* `dayStartMinutes`/`dayEndMinutes` are a VIEWPORT, never a filter: a calendar
|
|
22
|
+
* that quietly refuses to draw a 06:00 delivery because the working day was
|
|
23
|
+
* declared as 08:00–18:00 is a calendar that lies. The configured window is the
|
|
24
|
+
* floor; anything earlier or later widens it to fit.
|
|
25
|
+
*/
|
|
26
|
+
export function gridWindow(
|
|
27
|
+
events: { start: Date; end?: Date | null }[],
|
|
28
|
+
dayStartMinutes: number,
|
|
29
|
+
dayEndMinutes: number,
|
|
30
|
+
): { startMinutes: number; endMinutes: number } {
|
|
31
|
+
let lo = dayStartMinutes;
|
|
32
|
+
let hi = dayEndMinutes;
|
|
33
|
+
for (const e of events) {
|
|
34
|
+
lo = Math.min(lo, Math.floor(minutesSinceMidnight(e.start) / 60) * 60);
|
|
35
|
+
if (e.end) {
|
|
36
|
+
const endMin = minutesSinceMidnight(e.end);
|
|
37
|
+
// An end at exactly midnight belongs to the day that just closed.
|
|
38
|
+
hi = Math.max(hi, endMin === 0 ? 1440 : Math.ceil(endMin / 60) * 60);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { startMinutes: Math.max(0, Math.min(lo, hi - 60)), endMinutes: Math.min(1440, Math.max(hi, lo + 60)) };
|
|
56
42
|
}
|
|
57
43
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
top,
|
|
61
|
-
height,
|
|
62
|
-
leftPct,
|
|
63
|
-
widthPct,
|
|
64
|
-
onPress,
|
|
65
|
-
locale,
|
|
66
|
-
}: {
|
|
67
|
-
event: CalendarEvent<T>;
|
|
68
|
-
top: number;
|
|
69
|
-
height: number;
|
|
70
|
-
leftPct: number;
|
|
71
|
-
widthPct: number;
|
|
72
|
-
onPress?: (e: CalendarEvent<T>) => void;
|
|
73
|
-
locale?: string;
|
|
74
|
-
}) {
|
|
75
|
-
const accent = event.color || colors.teal[600];
|
|
76
|
-
const compact = height < 34;
|
|
77
|
-
return (
|
|
78
|
-
<FocusRingPressable
|
|
79
|
-
onPress={onPress ? () => onPress(event) : undefined}
|
|
80
|
-
accessibilityRole={onPress ? "button" : undefined}
|
|
81
|
-
accessibilityLabel={event.title}
|
|
82
|
-
style={{
|
|
83
|
-
position: "absolute",
|
|
84
|
-
top,
|
|
85
|
-
height: Math.max(height - 1, 14),
|
|
86
|
-
left: `${leftPct}%`,
|
|
87
|
-
width: `${widthPct}%`,
|
|
88
|
-
paddingHorizontal: COL_GAP,
|
|
89
|
-
}}
|
|
90
|
-
>
|
|
91
|
-
<View
|
|
92
|
-
style={{
|
|
93
|
-
flex: 1,
|
|
94
|
-
borderRadius: 6,
|
|
95
|
-
backgroundColor: accent + "1f",
|
|
96
|
-
borderLeftWidth: 3,
|
|
97
|
-
borderLeftColor: accent,
|
|
98
|
-
paddingHorizontal: 6,
|
|
99
|
-
paddingVertical: compact ? 1 : 3,
|
|
100
|
-
overflow: "hidden",
|
|
101
|
-
}}
|
|
102
|
-
>
|
|
103
|
-
<Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.zinc[800] }}>
|
|
104
|
-
{event.title}
|
|
105
|
-
</Text>
|
|
106
|
-
{!compact ? (
|
|
107
|
-
<Text size="xs" numberOfLines={1} style={{ color: colors.zinc[500] }}>
|
|
108
|
-
{formatTime(event.start, locale)}
|
|
109
|
-
</Text>
|
|
110
|
-
) : null}
|
|
111
|
-
</View>
|
|
112
|
-
</FocusRingPressable>
|
|
113
|
-
);
|
|
44
|
+
export interface TimeGridProps {
|
|
45
|
+
mode: "week" | "day";
|
|
114
46
|
}
|
|
115
47
|
|
|
116
|
-
export function
|
|
117
|
-
const { mode
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
);
|
|
48
|
+
export function CalendarTimeGrid(props: TimeGridProps) {
|
|
49
|
+
const { mode } = props;
|
|
50
|
+
const {
|
|
51
|
+
events, date, weekStartsOn, locale, labels, now: referenceNow, dayStartMinutes, dayEndMinutes,
|
|
52
|
+
onEventPress, onSlotPress, renderEvent,
|
|
53
|
+
} = useCalendar();
|
|
54
|
+
|
|
55
|
+
const days = useMemo(() => daysInView(mode, date, weekStartsOn), [mode, date, weekStartsOn]);
|
|
122
56
|
|
|
123
|
-
//
|
|
124
|
-
|
|
57
|
+
// The now-line has to move, but it must move from the reference the rest of
|
|
58
|
+
// the tree agrees on — so it advances by REAL elapsed time from that anchor
|
|
59
|
+
// rather than re-reading a clock the caller may have deliberately overridden.
|
|
60
|
+
const anchor = useRef({ reference: referenceNow.getTime(), mountedAt: Date.now() });
|
|
61
|
+
const [tick, setTick] = useState(0);
|
|
125
62
|
useEffect(() => {
|
|
126
|
-
const id = setInterval(() =>
|
|
63
|
+
const id = setInterval(() => setTick((t) => t + 1), 60_000);
|
|
127
64
|
return () => clearInterval(id);
|
|
128
65
|
}, []);
|
|
129
|
-
|
|
130
|
-
const scrollRef = useRef<ScrollView>(null);
|
|
131
66
|
useEffect(() => {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
67
|
+
anchor.current = { reference: referenceNow.getTime(), mountedAt: Date.now() };
|
|
68
|
+
}, [referenceNow]);
|
|
69
|
+
const now = useMemo(
|
|
70
|
+
() => new Date(anchor.current.reference + (Date.now() - anchor.current.mountedAt)),
|
|
71
|
+
// `tick` is the whole point — it is what re-reads the elapsed time.
|
|
136
72
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
137
|
-
|
|
73
|
+
[tick, referenceNow],
|
|
74
|
+
);
|
|
138
75
|
|
|
139
|
-
const { timedByDay,
|
|
140
|
-
const timed: CalendarEvent<
|
|
141
|
-
const
|
|
142
|
-
for (const e of events)
|
|
143
|
-
const multiDay = e.end ? !isSameDay(e.start, e.end) : false;
|
|
144
|
-
if (e.allDay || multiDay) banner.push(e);
|
|
145
|
-
else timed.push(e);
|
|
146
|
-
}
|
|
76
|
+
const { timedByDay, banners, inView } = useMemo(() => {
|
|
77
|
+
const timed: CalendarEvent<unknown>[] = [];
|
|
78
|
+
const bar: CalendarEvent<unknown>[] = [];
|
|
79
|
+
for (const e of events) (isBanner(e) ? bar : timed).push(e);
|
|
147
80
|
const byDay = days.map((day) =>
|
|
148
|
-
timed.filter((e) =>
|
|
149
|
-
const s = startOfDay(e.start);
|
|
150
|
-
return s.getTime() === day.getTime();
|
|
151
|
-
}),
|
|
81
|
+
timed.filter((e) => startOfDay(e.start).getTime() === day.getTime()),
|
|
152
82
|
);
|
|
153
|
-
return { timedByDay: byDay,
|
|
83
|
+
return { timedByDay: byDay, banners: bar, inView: byDay.flat() };
|
|
154
84
|
}, [events, days]);
|
|
155
85
|
|
|
156
|
-
const
|
|
86
|
+
const win = useMemo(
|
|
87
|
+
() => gridWindow(inView, dayStartMinutes, dayEndMinutes),
|
|
88
|
+
[inView, dayStartMinutes, dayEndMinutes],
|
|
89
|
+
);
|
|
90
|
+
const gridHeight = ((win.endMinutes - win.startMinutes) / 60) * HOUR_HEIGHT;
|
|
91
|
+
const hours = useMemo(
|
|
92
|
+
() => Array.from({ length: Math.ceil((win.endMinutes - win.startMinutes) / 60) }, (_, i) => win.startMinutes / 60 + i),
|
|
93
|
+
[win],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
97
|
+
const target = initialScrollMinutes(inView, days, now, win.startMinutes);
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const y = Math.max(0, (target - win.startMinutes) * PX_PER_MIN);
|
|
100
|
+
const id = setTimeout(() => scrollRef.current?.scrollTo({ y, animated: false }), 0);
|
|
101
|
+
return () => clearTimeout(id);
|
|
102
|
+
// Re-aimed whenever the range changes — navigating to another week must
|
|
103
|
+
// land on THAT week's content, which is the bug the old "an hour before
|
|
104
|
+
// now" rule had: any week without today in it opened on empty grid.
|
|
105
|
+
}, [target, win.startMinutes]);
|
|
106
|
+
|
|
107
|
+
const allDayPacked = useMemo(() => packEventLanes(banners, days[0], days.length), [banners, days]);
|
|
157
108
|
|
|
158
109
|
return (
|
|
159
110
|
<View style={styles.root}>
|
|
160
|
-
{/* Header: day columns */}
|
|
161
111
|
<View style={styles.headerRow}>
|
|
162
112
|
<View style={{ width: GUTTER }} />
|
|
163
113
|
{days.map((day) => {
|
|
164
114
|
const today = isToday(day, now);
|
|
165
115
|
return (
|
|
166
116
|
<View key={day.toISOString()} style={styles.headerCell}>
|
|
167
|
-
<Text size="xs" style={
|
|
168
|
-
{
|
|
117
|
+
<Text size="xs" style={today ? styles.todayInk : styles.mutedInk}>
|
|
118
|
+
{new Intl.DateTimeFormat(locale, { weekday: "short" }).format(day).toUpperCase()}
|
|
169
119
|
</Text>
|
|
170
|
-
<View style={[styles.dateBadge, today &&
|
|
171
|
-
<Text size="sm" weight={today ? "semibold" : "medium"} style={
|
|
120
|
+
<View style={[styles.dateBadge, today && styles.dateBadgeToday]}>
|
|
121
|
+
<Text size="sm" weight={today ? "semibold" : "medium"} style={today ? styles.onAccent : styles.dateInk}>
|
|
172
122
|
{day.getDate()}
|
|
173
123
|
</Text>
|
|
174
124
|
</View>
|
|
@@ -177,89 +127,99 @@ export function TimeGridView<T = unknown>(props: TimeGridViewProps<T>) {
|
|
|
177
127
|
})}
|
|
178
128
|
</View>
|
|
179
129
|
|
|
180
|
-
{/* All-day banner row */}
|
|
181
130
|
{allDayPacked.lanes > 0 ? (
|
|
182
131
|
<View style={[styles.allDayRow, { height: allDayPacked.lanes * (ALLDAY_LANE_H + 2) + 6 }]}>
|
|
183
|
-
<View style={
|
|
184
|
-
<Text size="xs" style={{
|
|
185
|
-
{props.allDayLabel ?? "all-day"}
|
|
186
|
-
</Text>
|
|
132
|
+
<View style={styles.allDayGutter}>
|
|
133
|
+
<Text size="xs" style={styles.allDayLabel}>{labels.allDay}</Text>
|
|
187
134
|
</View>
|
|
188
135
|
<View style={{ flex: 1 }}>
|
|
189
|
-
{allDayPacked.bars.map((bar) =>
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
</Text>
|
|
209
|
-
</View>
|
|
210
|
-
</TimeGridPressable>
|
|
211
|
-
);
|
|
212
|
-
})}
|
|
136
|
+
{allDayPacked.bars.map((bar) => (
|
|
137
|
+
<EventChip
|
|
138
|
+
key={bar.event.id}
|
|
139
|
+
event={bar.event}
|
|
140
|
+
variant="banner"
|
|
141
|
+
height={ALLDAY_LANE_H}
|
|
142
|
+
locale={locale}
|
|
143
|
+
render={renderEvent}
|
|
144
|
+
onPress={onEventPress ? () => onEventPress(bar.event.id) : undefined}
|
|
145
|
+
style={{
|
|
146
|
+
position: "absolute",
|
|
147
|
+
top: bar.lane * (ALLDAY_LANE_H + 2) + 3,
|
|
148
|
+
height: ALLDAY_LANE_H,
|
|
149
|
+
left: `${(bar.startCol / days.length) * 100}%`,
|
|
150
|
+
width: `${(bar.span / days.length) * 100}%`,
|
|
151
|
+
paddingHorizontal: COL_GAP,
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
))}
|
|
213
155
|
</View>
|
|
214
156
|
</View>
|
|
215
157
|
) : null}
|
|
216
158
|
|
|
217
|
-
{/* Scrollable time grid */}
|
|
218
159
|
<ScrollView ref={scrollRef} style={{ flex: 1 }} showsVerticalScrollIndicator>
|
|
219
|
-
<View style={{ flexDirection: "row", height:
|
|
220
|
-
{/* Hour gutter */}
|
|
160
|
+
<View style={{ flexDirection: "row", height: gridHeight }}>
|
|
221
161
|
<View style={{ width: GUTTER }}>
|
|
222
|
-
{
|
|
223
|
-
|
|
224
|
-
<RNText
|
|
225
|
-
key={h}
|
|
226
|
-
style={{
|
|
227
|
-
position: "absolute",
|
|
228
|
-
top: h * HOUR_HEIGHT - 7,
|
|
229
|
-
right: 6,
|
|
230
|
-
fontSize: 11,
|
|
231
|
-
color: colors.zinc[400],
|
|
232
|
-
}}
|
|
233
|
-
>
|
|
162
|
+
{hours.map((h, i) =>
|
|
163
|
+
i === 0 ? null : (
|
|
164
|
+
<RNText key={h} style={[styles.hourLabel, { top: i * HOUR_HEIGHT - 7 }]}>
|
|
234
165
|
{hourLabel(h)}
|
|
235
166
|
</RNText>
|
|
236
167
|
),
|
|
237
168
|
)}
|
|
238
169
|
</View>
|
|
239
|
-
{/* Day columns */}
|
|
240
170
|
{days.map((day, dayIdx) => {
|
|
241
171
|
const placed = layoutDayColumns(day, timedByDay[dayIdx]);
|
|
242
172
|
const today = isToday(day, now);
|
|
173
|
+
const nowOffset = (minutesSinceMidnight(now) - win.startMinutes) * PX_PER_MIN;
|
|
243
174
|
return (
|
|
244
175
|
<View key={day.toISOString()} style={styles.dayColumn}>
|
|
245
|
-
{
|
|
246
|
-
<View key={h} style={{
|
|
176
|
+
{hours.map((h, i) => (
|
|
177
|
+
<View key={h} style={[styles.hourRule, { top: i * HOUR_HEIGHT }]} />
|
|
247
178
|
))}
|
|
248
|
-
{
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
179
|
+
{/* One press target per hour. Fine-grained enough to mean
|
|
180
|
+
something, coarse enough not to put 300 Tab stops in a week. */}
|
|
181
|
+
{onSlotPress
|
|
182
|
+
? hours.map((h, i) => (
|
|
183
|
+
<FocusRingPressable
|
|
184
|
+
key={`s${h}`}
|
|
185
|
+
accessibilityRole="button"
|
|
186
|
+
accessibilityLabel={labels.addAt(
|
|
187
|
+
`${new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long" }).format(day)} ${hourLabel(h)}`,
|
|
188
|
+
)}
|
|
189
|
+
onPress={() => {
|
|
190
|
+
const start = new Date(day.getFullYear(), day.getMonth(), day.getDate(), h, 0);
|
|
191
|
+
onSlotPress(start, new Date(start.getTime() + 3_600_000));
|
|
192
|
+
}}
|
|
193
|
+
style={[styles.slot, { top: i * HOUR_HEIGHT }]}
|
|
194
|
+
/>
|
|
195
|
+
))
|
|
196
|
+
: null}
|
|
197
|
+
{placed.map((c) => {
|
|
198
|
+
const height = c.heightMinutes * PX_PER_MIN;
|
|
199
|
+
return (
|
|
200
|
+
<EventChip
|
|
201
|
+
key={c.event.id}
|
|
202
|
+
event={c.event}
|
|
203
|
+
variant="block"
|
|
204
|
+
compact={height < 34}
|
|
205
|
+
height={Math.max(height - 1, 14)}
|
|
206
|
+
locale={locale}
|
|
207
|
+
render={renderEvent}
|
|
208
|
+
onPress={onEventPress ? () => onEventPress(c.event.id) : undefined}
|
|
209
|
+
style={{
|
|
210
|
+
position: "absolute",
|
|
211
|
+
top: (c.topMinutes - win.startMinutes) * PX_PER_MIN,
|
|
212
|
+
height: Math.max(height - 1, 14),
|
|
213
|
+
left: `${(c.column / c.columns) * 100}%`,
|
|
214
|
+
width: `${(1 / c.columns) * 100}%`,
|
|
215
|
+
paddingHorizontal: COL_GAP,
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
);
|
|
219
|
+
})}
|
|
220
|
+
{today && nowOffset >= 0 && nowOffset <= gridHeight ? (
|
|
221
|
+
<View style={[styles.nowLine, { top: nowOffset }]} accessibilityLabel={formatTime(now, locale)}>
|
|
222
|
+
<View style={styles.nowDot} />
|
|
263
223
|
</View>
|
|
264
224
|
) : null}
|
|
265
225
|
</View>
|
|
@@ -271,11 +231,31 @@ export function TimeGridView<T = unknown>(props: TimeGridViewProps<T>) {
|
|
|
271
231
|
);
|
|
272
232
|
}
|
|
273
233
|
|
|
234
|
+
export function CalendarWeek() {
|
|
235
|
+
return <CalendarTimeGrid mode="week" />;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function CalendarDay() {
|
|
239
|
+
return <CalendarTimeGrid mode="day" />;
|
|
240
|
+
}
|
|
241
|
+
|
|
274
242
|
const styles = StyleSheet.create({
|
|
275
243
|
root: { flex: 1, backgroundColor: colors.white },
|
|
276
|
-
headerRow: { flexDirection: "row",
|
|
244
|
+
headerRow: { flexDirection: "row", paddingTop: 2, paddingBottom: 8 },
|
|
277
245
|
headerCell: { flex: 1, alignItems: "center", gap: 3 },
|
|
278
246
|
dateBadge: { minWidth: 26, height: 26, borderRadius: 13, alignItems: "center", justifyContent: "center", paddingHorizontal: 4 },
|
|
279
|
-
|
|
247
|
+
dateBadgeToday: { backgroundColor: colors.teal[600] },
|
|
248
|
+
todayInk: { color: colors.teal[600] },
|
|
249
|
+
mutedInk: { color: colors.zinc[500] },
|
|
250
|
+
dateInk: { color: colors.zinc[800] },
|
|
251
|
+
onAccent: { color: colors.white },
|
|
252
|
+
allDayRow: { flexDirection: "row", borderTopWidth: 1, borderTopColor: colors.zinc[100], paddingVertical: 4 },
|
|
253
|
+
allDayGutter: { width: GUTTER, justifyContent: "center" },
|
|
254
|
+
allDayLabel: { color: colors.zinc[400], textAlign: "right", paddingRight: 6 },
|
|
255
|
+
hourLabel: { position: "absolute", right: 6, fontSize: 11, color: colors.zinc[400] },
|
|
256
|
+
hourRule: { position: "absolute", left: 0, right: 0, borderTopWidth: 1, borderTopColor: colors.zinc[100] },
|
|
257
|
+
slot: { position: "absolute", left: 0, right: 0, height: HOUR_HEIGHT },
|
|
280
258
|
dayColumn: { flex: 1, borderLeftWidth: 1, borderLeftColor: colors.zinc[100] },
|
|
259
|
+
nowLine: { position: "absolute", left: 0, right: 0, height: 2, backgroundColor: colors.red[500] },
|
|
260
|
+
nowDot: { position: "absolute", left: -4, top: -3, width: 8, height: 8, borderRadius: 4, backgroundColor: colors.red[500] },
|
|
281
261
|
});
|
package/src/calendar/types.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { ColorName } from "../color_tokens";
|
|
2
|
+
import type { RepeatRule } from "./repeat";
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Calendar primitive — data model. Mirrors the minimum shape every calendar API
|
|
3
6
|
* converges on ({title, start, end}) plus the layered extras (all-day, color).
|
|
@@ -8,44 +11,67 @@ export interface CalendarEvent<T = unknown> {
|
|
|
8
11
|
title: string;
|
|
9
12
|
/** Event start (local time). For an all-day event, the day it begins. */
|
|
10
13
|
start: Date;
|
|
11
|
-
/**
|
|
14
|
+
/** INCLUSIVE end day for an all-day event; exclusive instant for a timed one.
|
|
15
|
+
* Null/undefined → a zero-length point (floored in the time grid). */
|
|
12
16
|
end?: Date | null;
|
|
13
17
|
/** All-day / multi-day banner event (rendered in the all-day row, not the time grid). */
|
|
14
18
|
allDay?: boolean;
|
|
15
|
-
/**
|
|
16
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Design-token colour for the event. A TOKEN, not a hex string: the views
|
|
21
|
+
* derive a fill, a tint and a border from it, and a raw hex cannot be tinted
|
|
22
|
+
* without string surgery — the previous version concatenated `"1f"` onto the
|
|
23
|
+
* value, which silently produced garbage for any colour that was not a
|
|
24
|
+
* 6-digit hex.
|
|
25
|
+
*/
|
|
26
|
+
color?: ColorName;
|
|
27
|
+
/** Secondary line — a room, an owner, a status. Shown where there is room. */
|
|
28
|
+
meta?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Repeat this event. The calendar expands it to the occurrences that fall in
|
|
31
|
+
* the view's own range, so a daily timetable costs one event, not a year of
|
|
32
|
+
* them. An occurrence's `id` encodes its source (see `sourceEventId`), so
|
|
33
|
+
* `onEventPress` still hands you the row the series came from.
|
|
34
|
+
*/
|
|
35
|
+
repeat?: RepeatRule;
|
|
17
36
|
data?: T;
|
|
18
37
|
}
|
|
19
38
|
|
|
20
|
-
export type CalendarViewMode = "month" | "week" | "day";
|
|
39
|
+
export type CalendarViewMode = "month" | "week" | "day" | "agenda";
|
|
21
40
|
|
|
22
41
|
/**
|
|
23
|
-
* User-facing chrome strings.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* are not part of this set.
|
|
42
|
+
* User-facing chrome strings. Resolution is **prop → `LoticsLocale.calendarView`
|
|
43
|
+
* → English default**, the kit-wide rule; `labels` overrides one instance.
|
|
44
|
+
* Dates/weekday/month names come from `locale` via Intl and are not part of this set.
|
|
27
45
|
*/
|
|
28
|
-
export interface
|
|
46
|
+
export interface CalendarViewLabels {
|
|
29
47
|
today: string;
|
|
30
48
|
month: string;
|
|
31
49
|
week: string;
|
|
32
50
|
day: string;
|
|
51
|
+
agenda: string;
|
|
33
52
|
previous: string;
|
|
34
53
|
next: string;
|
|
35
54
|
allDay: string;
|
|
36
|
-
/**
|
|
55
|
+
/** Day-cell overflow chip, e.g. (3) => "+3 more". */
|
|
37
56
|
more: (count: number) => string;
|
|
57
|
+
/** Agenda / empty range. */
|
|
58
|
+
noEvents: string;
|
|
59
|
+
/** Accessible name of an empty time slot's press target. */
|
|
60
|
+
addAt: (when: string) => string;
|
|
38
61
|
}
|
|
39
62
|
|
|
40
|
-
export const
|
|
63
|
+
export const DEFAULT_CALENDAR_VIEW_LABELS: CalendarViewLabels = {
|
|
41
64
|
today: "Today",
|
|
42
65
|
month: "Month",
|
|
43
66
|
week: "Week",
|
|
44
67
|
day: "Day",
|
|
68
|
+
agenda: "Agenda",
|
|
45
69
|
previous: "Previous",
|
|
46
70
|
next: "Next",
|
|
47
71
|
allDay: "all-day",
|
|
48
72
|
more: (n) => `+${n} more`,
|
|
73
|
+
noEvents: "Nothing scheduled",
|
|
74
|
+
addAt: (when) => `Add at ${when}`,
|
|
49
75
|
};
|
|
50
76
|
|
|
51
77
|
/** 0 = Sunday … 6 = Saturday. */
|
package/src/control_surface.ts
CHANGED
|
@@ -69,6 +69,34 @@ export const MIN_VALUE_WIDTH = 200;
|
|
|
69
69
|
* (avatars, dots, switches, sliders, progress, icon buttons) stay full. */
|
|
70
70
|
export const CONTROL_RADIUS = 10;
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The radius ladder's container rung — a surface that HOLDS content rather than
|
|
74
|
+
* one you press: a board card, a calendar's event chip, a panel.
|
|
75
|
+
*
|
|
76
|
+
* Two rungs, and the question that picks one is what the thing IS, never how
|
|
77
|
+
* big it is: `CONTROL_RADIUS` for something you press directly, this for
|
|
78
|
+
* something with content inside it. A whole card being pressable does not make
|
|
79
|
+
* it a control.
|
|
80
|
+
*/
|
|
81
|
+
export const CARD_RADIUS = 16;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The radius a SHORT surface should take — proportionate to its height, capped
|
|
85
|
+
* at the control rung.
|
|
86
|
+
*
|
|
87
|
+
* A fixed radius does not survive a component whose height is data: at 22px,
|
|
88
|
+
* `CONTROL_RADIUS` is half the box and the thing renders as a pill, while the
|
|
89
|
+
* same 10 on a two-hour calendar block is barely a corner. Two boxes of the
|
|
90
|
+
* same family then look like two different vocabularies. Scaling by height and
|
|
91
|
+
* capping keeps one corner treatment across a chip, a bar and a block.
|
|
92
|
+
*
|
|
93
|
+
* The ratio is under a third, so the straight edge always dominates — a corner
|
|
94
|
+
* that eats half the side has stopped being a rounded rectangle.
|
|
95
|
+
*/
|
|
96
|
+
export function proportionalRadius(height: number): number {
|
|
97
|
+
return Math.round(Math.min(CARD_RADIUS, Math.max(0, height) * 0.3));
|
|
98
|
+
}
|
|
99
|
+
|
|
72
100
|
/**
|
|
73
101
|
* HOW FAR A ROW'S WASH EXTENDS PAST ITS CONTENT — and the invariant that goes
|
|
74
102
|
* with it: **a row that paints a hover/selection wash BLEEDS it outward; it
|
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
|
});
|