@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.
@@ -1,174 +1,124 @@
1
- import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
- import { View, ScrollView, StyleSheet, Text as RNText, type StyleProp, type ViewStyle } from "react-native";
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
- function TimeGridPressable(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 { 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 GRID_HEIGHT = 24 * HOUR_HEIGHT;
40
- const GUTTER = 56;
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
- export interface TimeGridViewProps<T = unknown> {
46
- mode: "week" | "day";
47
- date: Date;
48
- events: CalendarEvent<T>[];
49
- weekStartsOn?: Weekday;
50
- locale?: string;
51
- /** Hour to scroll to on mount. Default: an hour before now (clamped). */
52
- scrollToHour?: number;
53
- /** All-day lane label; defaults to English ("all-day"). */
54
- allDayLabel?: string;
55
- onEventPress?: (event: CalendarEvent<T>) => void;
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
- function EventBlock<T>({
59
- event,
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 TimeGridView<T = unknown>(props: TimeGridViewProps<T>) {
117
- const { mode, date, events, weekStartsOn = 1, locale, onEventPress } = props;
118
- const days = useMemo(
119
- () => daysInView(mode, date, weekStartsOn),
120
- [mode, date, weekStartsOn],
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
- // Live now-indicator: tick each minute.
124
- const [now, setNow] = useState(() => new Date());
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(() => setNow(new Date()), 60_000);
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
- const hour = props.scrollToHour ?? Math.max(0, now.getHours() - 1);
133
- const id = setTimeout(() => scrollRef.current?.scrollTo({ y: hour * HOUR_HEIGHT, animated: false }), 0);
134
- return () => clearTimeout(id);
135
- // Only on mount / mode change — not every minute.
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
- }, [mode]);
73
+ [tick, referenceNow],
74
+ );
138
75
 
139
- const { timedByDay, allDay } = useMemo(() => {
140
- const timed: CalendarEvent<T>[] = [];
141
- const banner: CalendarEvent<T>[] = [];
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, allDay: banner };
83
+ return { timedByDay: byDay, banners: bar, inView: byDay.flat() };
154
84
  }, [events, days]);
155
85
 
156
- const allDayPacked = useMemo(() => packEventLanes(allDay, days[0], days.length), [allDay, days]);
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={{ color: today ? colors.teal[600] : colors.zinc[500] }}>
168
- {weekdayShort(day, locale).toUpperCase()}
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 && { backgroundColor: colors.teal[600] }]}>
171
- <Text size="sm" weight={today ? "semibold" : "medium"} style={{ color: today ? colors.white : colors.zinc[800] }}>
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={{ width: GUTTER, justifyContent: "center" }}>
184
- <Text size="xs" style={{ color: colors.zinc[400], textAlign: "right", paddingRight: 6 }}>
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
- const accent = bar.event.color || colors.teal[600];
191
- return (
192
- <TimeGridPressable
193
- key={bar.event.id}
194
- onPress={onEventPress ? () => onEventPress(bar.event) : undefined}
195
- accessibilityLabel={bar.event.title}
196
- style={{
197
- position: "absolute",
198
- top: bar.lane * (ALLDAY_LANE_H + 2) + 3,
199
- height: ALLDAY_LANE_H,
200
- left: `${(bar.startCol / days.length) * 100}%`,
201
- width: `${(bar.span / days.length) * 100}%`,
202
- paddingHorizontal: COL_GAP,
203
- }}
204
- >
205
- <View style={{ flex: 1, borderRadius: 5, backgroundColor: accent, justifyContent: "center", paddingHorizontal: 8 }}>
206
- <Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.white }}>
207
- {bar.event.title}
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: GRID_HEIGHT }}>
220
- {/* Hour gutter */}
160
+ <View style={{ flexDirection: "row", height: gridHeight }}>
221
161
  <View style={{ width: GUTTER }}>
222
- {HOURS.map((h) =>
223
- h === 0 ? null : (
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
- {HOURS.map((h) => (
246
- <View key={h} style={{ position: "absolute", top: h * HOUR_HEIGHT, left: 0, right: 0, borderTopWidth: 1, borderTopColor: colors.zinc[100] }} />
176
+ {hours.map((h, i) => (
177
+ <View key={h} style={[styles.hourRule, { top: i * HOUR_HEIGHT }]} />
247
178
  ))}
248
- {placed.map((c) => (
249
- <EventBlock
250
- key={c.event.id}
251
- event={c.event}
252
- top={c.topMinutes * PX_PER_MIN}
253
- height={c.heightMinutes * PX_PER_MIN}
254
- leftPct={(c.column / c.columns) * 100}
255
- widthPct={(1 / c.columns) * 100}
256
- onPress={onEventPress}
257
- locale={locale}
258
- />
259
- ))}
260
- {today ? (
261
- <View style={{ position: "absolute", top: minutesSinceMidnight(now) * PX_PER_MIN, left: 0, right: 0, height: 2, backgroundColor: colors.red[500] }}>
262
- <View style={{ position: "absolute", left: -4, top: -3, width: 8, height: 8, borderRadius: 4, backgroundColor: colors.red[500] }} />
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", borderBottomWidth: 1, borderBottomColor: colors.border, paddingVertical: 6 },
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
- allDayRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, paddingVertical: 3 },
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
  });
@@ -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
- /** Exclusive end. Null/undefined a zero-length point (treated as 30 min in the time grid). */
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
- /** Hex or design-token color for the event chip. */
16
- color?: string | null;
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. Primitives default to English and take these as a
24
- * prop i18n is the consumer's responsibility (see the @lotics/ui convention in
25
- * grid/data_grid.tsx). Dates/weekday/month names come from `locale` via Intl and
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 CalendarLabels {
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
- /** Month-cell overflow chip, e.g. (3) => "+3 more". */
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 DEFAULT_CALENDAR_LABELS: CalendarLabels = {
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. */
@@ -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
- <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
  });