@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
|
@@ -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/charge_lines.tsx
CHANGED
|
@@ -120,6 +120,11 @@ export interface ChargeLinesProps {
|
|
|
120
120
|
* not the sum of something visible is the one figure a reader cannot check. */
|
|
121
121
|
total: number;
|
|
122
122
|
formatMoney: (n: number) => string;
|
|
123
|
+
/** Format a QUANTITY. Band-level for the same reason `formatMoney` is: a
|
|
124
|
+
* locale that writes `28,9` must not have the one figure the kit renders
|
|
125
|
+
* itself print `28.9` while every number the app formats beside it reads
|
|
126
|
+
* correctly. Defaults to the platform's own `String`. */
|
|
127
|
+
formatQuantity?: (n: number) => string;
|
|
123
128
|
/** Sits in the closing row beside the total — the issue/collect action. */
|
|
124
129
|
action?: ReactNode;
|
|
125
130
|
/** Shown in place of the lines when there are none. */
|
|
@@ -129,7 +134,12 @@ export interface ChargeLinesProps {
|
|
|
129
134
|
const QTY_W = 76;
|
|
130
135
|
const OP_W = 14;
|
|
131
136
|
const PRICE_W = 132;
|
|
132
|
-
|
|
137
|
+
// 48 fitted an integer and clipped `28,9` — 33px of text in the 30 left after the
|
|
138
|
+
// inset and border, on 4 of 7 lines of a real record. A quantity with one decimal
|
|
139
|
+
// is the ordinary case wherever goods are sold by weight, and `formatQuantity`
|
|
140
|
+
// now makes the separator a comma in the locales that use one, which is no wider
|
|
141
|
+
// but no narrower either. 60 seats four glyphs and a unit's worth of slack.
|
|
142
|
+
const QTY_W_NARROW = 60;
|
|
133
143
|
const PRICE_W_NARROW = 92;
|
|
134
144
|
const AMOUNT_W = 108;
|
|
135
145
|
const ACTION_W = 32;
|
|
@@ -138,7 +148,7 @@ const ACTION_W = 32;
|
|
|
138
148
|
const FORK_AT = 520;
|
|
139
149
|
|
|
140
150
|
export function ChargeLines(props: ChargeLinesProps) {
|
|
141
|
-
const { children, totalLabel, total, formatMoney, action, empty } = props;
|
|
151
|
+
const { children, totalLabel, total, formatMoney, formatQuantity, action, empty } = props;
|
|
142
152
|
const [width, setWidth] = useState(0);
|
|
143
153
|
const narrow = width > 0 && width < FORK_AT;
|
|
144
154
|
const onLayout = useCallback((e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width), []);
|
|
@@ -146,7 +156,7 @@ export function ChargeLines(props: ChargeLinesProps) {
|
|
|
146
156
|
const isEmpty = Array.isArray(rows) ? rows.length === 0 : !rows;
|
|
147
157
|
|
|
148
158
|
return (
|
|
149
|
-
<ChargeLinesContext.Provider value={{ narrow, formatMoney }}>
|
|
159
|
+
<ChargeLinesContext.Provider value={{ narrow, formatMoney, formatQuantity }}>
|
|
150
160
|
<View onLayout={onLayout} style={styles.band}>
|
|
151
161
|
{isEmpty ? (
|
|
152
162
|
<View style={styles.empty}>{empty}</View>
|
|
@@ -188,6 +198,10 @@ export function ChargeLine(props: ChargeLineProps) {
|
|
|
188
198
|
const band = useChargeLines();
|
|
189
199
|
const words = useLoticsLocale().chargeLines;
|
|
190
200
|
const money = props.formatMoney ?? band.formatMoney;
|
|
201
|
+
const qty = band.formatQuantity ?? ((n: number) => String(n));
|
|
202
|
+
const fmtQty = band.formatQuantity
|
|
203
|
+
? (v: number | null) => (v == null ? "" : qty(v))
|
|
204
|
+
: undefined;
|
|
191
205
|
const priced = quantity !== undefined || unitPrice !== undefined;
|
|
192
206
|
const amount = priced ? (quantity ?? 0) * (unitPrice ?? 0) : (props.amount ?? 0);
|
|
193
207
|
/** The row's money verb — the unit price's on a priced line, the amount's on a
|
|
@@ -212,12 +226,13 @@ export function ChargeLine(props: ChargeLineProps) {
|
|
|
212
226
|
<View style={{ width: band.narrow ? QTY_W_NARROW : QTY_W }}>
|
|
213
227
|
{locked || !onQuantityChange ? (
|
|
214
228
|
<Text size="sm" tabular style={styles.inset}>
|
|
215
|
-
{quantity
|
|
229
|
+
{quantity == null ? "—" : qty(quantity)}
|
|
216
230
|
</Text>
|
|
217
231
|
) : (
|
|
218
232
|
<InlineNumberInput
|
|
219
233
|
value={quantity ?? null}
|
|
220
234
|
onSave={onQuantityChange}
|
|
235
|
+
format={fmtQty}
|
|
221
236
|
min={0}
|
|
222
237
|
placeholder="1"
|
|
223
238
|
align="right"
|
|
@@ -340,7 +355,11 @@ export function ChargeLine(props: ChargeLineProps) {
|
|
|
340
355
|
);
|
|
341
356
|
}
|
|
342
357
|
|
|
343
|
-
const ChargeLinesContext = createContext<{
|
|
358
|
+
const ChargeLinesContext = createContext<{
|
|
359
|
+
narrow: boolean;
|
|
360
|
+
formatMoney: (n: number) => string;
|
|
361
|
+
formatQuantity?: (n: number) => string;
|
|
362
|
+
}>({
|
|
344
363
|
narrow: false,
|
|
345
364
|
formatMoney: (n) => String(n),
|
|
346
365
|
});
|
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
|