@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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { View, StyleSheet, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import { Text } from "../text";
|
|
4
|
+
import { colors, type ColorName } from "../colors";
|
|
5
|
+
import { proportionalRadius } from "../control_surface";
|
|
6
|
+
import { FocusRingPressable } from "../focus_ring_pressable";
|
|
7
|
+
import { formatTime } from "./dates";
|
|
8
|
+
import type { CalendarEvent } from "./types";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_COLOR: ColorName = "teal";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The ground and ink one event colour resolves to.
|
|
14
|
+
*
|
|
15
|
+
* **A wash with matching ink, never a saturated fill with white on it.** The
|
|
16
|
+
* pairing is `Badge`'s tonal one — the palette's own light ground and dark ink
|
|
17
|
+
* of a single family — so a calendar reads as the same product as everything
|
|
18
|
+
* beside it, and a month with a dozen colours in it stays a surface you can
|
|
19
|
+
* look at. A `solid()` bar per event turns the grid into a paint chart, which
|
|
20
|
+
* is what "the colours are ugly" actually names.
|
|
21
|
+
*
|
|
22
|
+
* The dot is the one place the solid shade still belongs: it is 6px, it carries
|
|
23
|
+
* identity rather than area, and it needs the contrast.
|
|
24
|
+
*/
|
|
25
|
+
function chipColors(name: ColorName): { ground: string; hovered: string; ink: string; dot: string } {
|
|
26
|
+
const hue = colors[name];
|
|
27
|
+
// `hovered` is the SAME ground one rung deeper, never a different colour:
|
|
28
|
+
// what has to change is only "this responds to me", and the chip must still
|
|
29
|
+
// read as the event it was a moment ago.
|
|
30
|
+
return { ground: hue[100], hovered: hue[200], ink: hue[900], dot: hue[500] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The three ways an event is drawn.
|
|
35
|
+
*
|
|
36
|
+
* - `banner` — a bar. A thing that occupies whole DAYS: an all-day marker, a
|
|
37
|
+
* multi-day campaign. Reads as continuous across day boundaries.
|
|
38
|
+
* - `dot` — a coloured dot beside plain text. A timed event inside a month
|
|
39
|
+
* cell, where a filled bar per meeting turns the month into a colour chart
|
|
40
|
+
* and the dates stop being findable.
|
|
41
|
+
* - `block` — a body sized to its duration, for the time grid, where vertical
|
|
42
|
+
* extent carries the meaning.
|
|
43
|
+
*
|
|
44
|
+
* **None of them draws a border.** A washed body already states its own edge;
|
|
45
|
+
* a rule on top of it is the same edge asserted twice, on a surface that is
|
|
46
|
+
* already full of grid lines. (`board.tsx` states the same law for its cards.)
|
|
47
|
+
*/
|
|
48
|
+
export type EventChipVariant = "banner" | "dot" | "block";
|
|
49
|
+
|
|
50
|
+
export interface EventChipProps {
|
|
51
|
+
event: CalendarEvent<unknown>;
|
|
52
|
+
variant: EventChipVariant;
|
|
53
|
+
onPress?: () => void;
|
|
54
|
+
/** Replaces the chip's contents; the press target and geometry stay ours. */
|
|
55
|
+
render?: (event: CalendarEvent<unknown>) => ReactNode;
|
|
56
|
+
/** Show the start time before the title (month cells, where there is no rail). */
|
|
57
|
+
showTime?: boolean;
|
|
58
|
+
locale?: string;
|
|
59
|
+
style?: StyleProp<ViewStyle>;
|
|
60
|
+
/** Below this the block drops its second line rather than clipping it. */
|
|
61
|
+
compact?: boolean;
|
|
62
|
+
/** The chip's rendered height, so its corners stay proportionate to it — a
|
|
63
|
+
* fixed radius turns a 20px lane chip into a pill and barely marks a tall
|
|
64
|
+
* block. */
|
|
65
|
+
height: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function EventChip(props: EventChipProps) {
|
|
69
|
+
const { event, variant, onPress, render, showTime, locale, style, compact, height } = props;
|
|
70
|
+
const { ground, hovered, ink, dot } = chipColors(event.color ?? DEFAULT_COLOR);
|
|
71
|
+
const radius = proportionalRadius(height);
|
|
72
|
+
|
|
73
|
+
const label = showTime ? `${formatTime(event.start, locale)} ${event.title}` : event.title;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<FocusRingPressable
|
|
77
|
+
onPress={onPress}
|
|
78
|
+
accessibilityRole={onPress ? "button" : undefined}
|
|
79
|
+
accessibilityLabel={accessibleName(event, locale)}
|
|
80
|
+
style={[styles.press, style]}
|
|
81
|
+
>
|
|
82
|
+
{({ hovered: over }) => {
|
|
83
|
+
// Nothing lights up without a handler, so a read-only calendar never
|
|
84
|
+
// advertises a press that does nothing.
|
|
85
|
+
const lit = !!onPress && over;
|
|
86
|
+
return render ? (
|
|
87
|
+
render(event)
|
|
88
|
+
) : variant === "banner" ? (
|
|
89
|
+
<View style={[styles.banner, { backgroundColor: lit ? hovered : ground, borderRadius: radius }]}>
|
|
90
|
+
<Text size="xs" weight="medium" numberOfLines={1} style={{ color: ink }}>
|
|
91
|
+
{event.title}
|
|
92
|
+
</Text>
|
|
93
|
+
</View>
|
|
94
|
+
) : variant === "dot" ? (
|
|
95
|
+
// The dot row has no ground of its own, so its hover is a NEUTRAL
|
|
96
|
+
// wash: the row is mostly plain text, and tinting it with the event's
|
|
97
|
+
// own hue would read as the event changing state rather than as the
|
|
98
|
+
// pointer being over it.
|
|
99
|
+
<View style={[styles.dotRow, lit ? { backgroundColor: colors.zinc[100], borderRadius: radius } : null]}>
|
|
100
|
+
<View style={[styles.dot, { backgroundColor: dot }]} />
|
|
101
|
+
<Text size="xs" numberOfLines={1} style={styles.dotLabel}>
|
|
102
|
+
{label}
|
|
103
|
+
</Text>
|
|
104
|
+
</View>
|
|
105
|
+
) : (
|
|
106
|
+
<View style={[styles.block, { backgroundColor: lit ? hovered : ground, borderRadius: radius }]}>
|
|
107
|
+
<Text size="xs" weight="medium" numberOfLines={1} style={{ color: ink }}>
|
|
108
|
+
{event.title}
|
|
109
|
+
</Text>
|
|
110
|
+
{!compact ? (
|
|
111
|
+
<Text size="xs" numberOfLines={1} style={{ color: ink, opacity: 0.7 }}>
|
|
112
|
+
{event.meta ?? formatTime(event.start, locale)}
|
|
113
|
+
</Text>
|
|
114
|
+
) : null}
|
|
115
|
+
</View>
|
|
116
|
+
);
|
|
117
|
+
}}
|
|
118
|
+
</FocusRingPressable>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** What a screen reader hears: the title, then when it is — never a bare title,
|
|
123
|
+
* which in a grid of forty of them says nothing about which one this is. */
|
|
124
|
+
export function accessibleName(event: CalendarEvent<unknown>, locale?: string): string {
|
|
125
|
+
if (event.allDay) return event.title;
|
|
126
|
+
const start = formatTime(event.start, locale);
|
|
127
|
+
const end = event.end ? formatTime(event.end, locale) : null;
|
|
128
|
+
return end ? `${event.title}, ${start}–${end}` : `${event.title}, ${start}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const styles = StyleSheet.create({
|
|
132
|
+
press: { minWidth: 0 },
|
|
133
|
+
banner: { flex: 1, justifyContent: "center", paddingHorizontal: 8, minWidth: 0 },
|
|
134
|
+
dotRow: { flex: 1, flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: 4, minWidth: 0 },
|
|
135
|
+
dot: { width: 6, height: 6, borderRadius: 3, flexShrink: 0 },
|
|
136
|
+
// `flexShrink: 1` + `minWidth: 0` on the label, not just the row: React Native
|
|
137
|
+
// defaults flexShrink to 0, so without it the title holds its full width and
|
|
138
|
+
// pushes the dot out of the cell instead of ellipsing.
|
|
139
|
+
dotLabel: { color: colors.zinc[700], flexShrink: 1, minWidth: 0 },
|
|
140
|
+
block: { flex: 1, paddingHorizontal: 8, paddingVertical: 2, overflow: "hidden", minWidth: 0 },
|
|
141
|
+
});
|
package/src/calendar/index.ts
CHANGED
|
@@ -1,19 +1,42 @@
|
|
|
1
|
-
export { CalendarView } from "./calendar_view";
|
|
1
|
+
export { CalendarView, CalendarBody } from "./calendar_view";
|
|
2
2
|
export type { CalendarViewProps } from "./calendar_view";
|
|
3
|
-
export {
|
|
4
|
-
export type {
|
|
5
|
-
export {
|
|
6
|
-
export
|
|
7
|
-
export {
|
|
3
|
+
export { CalendarToolbar } from "./calendar_toolbar";
|
|
4
|
+
export type { CalendarToolbarProps } from "./calendar_toolbar";
|
|
5
|
+
export { CalendarMonth } from "./month_view";
|
|
6
|
+
export { CalendarWeek, CalendarDay, gridWindow } from "./time_grid_view";
|
|
7
|
+
export { CalendarAgenda } from "./agenda_view";
|
|
8
|
+
export { EventChip } from "./event_chip";
|
|
9
|
+
export type { EventChipProps, EventChipVariant } from "./event_chip";
|
|
10
|
+
export { useCalendar } from "./context";
|
|
11
|
+
export type { CalendarContextValue } from "./context";
|
|
12
|
+
export {
|
|
13
|
+
layoutDayColumns,
|
|
14
|
+
packEventLanes,
|
|
15
|
+
compareEvents,
|
|
16
|
+
fitLanes,
|
|
17
|
+
initialScrollMinutes,
|
|
18
|
+
isBanner,
|
|
19
|
+
} from "./layout";
|
|
8
20
|
export type { LaneBar } from "./layout";
|
|
9
|
-
export {
|
|
10
|
-
export type {
|
|
21
|
+
export { expandRepeats, occurrenceDays, occurrenceId, sourceEventId } from "./repeat";
|
|
22
|
+
export type { RepeatRule } from "./repeat";
|
|
23
|
+
export { DEFAULT_CALENDAR_VIEW_LABELS } from "./types";
|
|
24
|
+
export type {
|
|
25
|
+
CalendarEvent,
|
|
26
|
+
CalendarViewMode,
|
|
27
|
+
Weekday,
|
|
28
|
+
EventColumn,
|
|
29
|
+
CalendarViewLabels,
|
|
30
|
+
} from "./types";
|
|
11
31
|
export {
|
|
12
32
|
addDays,
|
|
13
33
|
addMonths,
|
|
14
34
|
startOfWeek,
|
|
15
35
|
startOfMonth,
|
|
36
|
+
endOfMonth,
|
|
16
37
|
daysInView,
|
|
38
|
+
weekRowsInMonth,
|
|
39
|
+
dayHeading,
|
|
17
40
|
isSameDay,
|
|
18
41
|
isToday,
|
|
19
42
|
viewTitle,
|
package/src/calendar/layout.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { dayDiff } from "./dates";
|
|
1
|
+
import { dayDiff, minutesSinceMidnight, startOfDay } from "./dates";
|
|
2
2
|
import type { CalendarEvent, EventColumn } from "./types";
|
|
3
3
|
|
|
4
4
|
/** A horizontal banner placement: which day column it starts on, how many days
|
|
@@ -11,38 +11,112 @@ export interface LaneBar<T = unknown> {
|
|
|
11
11
|
lane: number;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** True for an event that occupies whole days rather than a slot in one. */
|
|
15
|
+
export function isBanner<T>(event: CalendarEvent<T>): boolean {
|
|
16
|
+
if (event.allDay) return true;
|
|
17
|
+
return event.end ? dayDiff(event.start, event.end) > 0 : false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The order events take within a day, and the order lanes are handed out.
|
|
22
|
+
*
|
|
23
|
+
* Banners lead — a bar that runs Monday to Friday must sit ABOVE the meetings
|
|
24
|
+
* inside those days, or it gets a different lane every week row and stops
|
|
25
|
+
* reading as one continuous thing. Longest first among them, so the widest bar
|
|
26
|
+
* anchors the top lane. Timed events then follow in clock order, which is the
|
|
27
|
+
* rule the previous version was missing entirely: it sorted by day only, so a
|
|
28
|
+
* day's events appeared in whatever order the consumer's array happened to be
|
|
29
|
+
* in — 16:00 above 09:00 as often as not.
|
|
30
|
+
*/
|
|
31
|
+
export function compareEvents<T>(a: CalendarEvent<T>, b: CalendarEvent<T>): number {
|
|
32
|
+
const ab = isBanner(a);
|
|
33
|
+
const bb = isBanner(b);
|
|
34
|
+
if (ab !== bb) return ab ? -1 : 1;
|
|
35
|
+
const byDay = startOfDay(a.start).getTime() - startOfDay(b.start).getTime();
|
|
36
|
+
if (byDay !== 0) return byDay;
|
|
37
|
+
if (ab) {
|
|
38
|
+
const len = (e: CalendarEvent<T>) => (e.end ? dayDiff(e.start, e.end) : 0);
|
|
39
|
+
const byLen = len(b) - len(a);
|
|
40
|
+
if (byLen !== 0) return byLen;
|
|
41
|
+
} else {
|
|
42
|
+
const byTime = minutesSinceMidnight(a.start) - minutesSinceMidnight(b.start);
|
|
43
|
+
if (byTime !== 0) return byTime;
|
|
44
|
+
}
|
|
45
|
+
// Ties break on id so the layout is deterministic across renders.
|
|
46
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
14
49
|
/**
|
|
15
50
|
* Pack day-spanning events into horizontal lanes across a `numDays` window
|
|
16
51
|
* starting at `rangeStart`. Greedy first-fit: an event takes the topmost lane
|
|
17
52
|
* whose last bar ends before this event starts, else a new lane. Multi-day
|
|
18
53
|
* events become wide bars; single-day events are span-1 bars. Deterministic.
|
|
54
|
+
*
|
|
55
|
+
* Pure over the list it is handed, which is what keeps a per-resource split
|
|
56
|
+
* (one call per group, rendered as sub-columns) a caller-side concern rather
|
|
57
|
+
* than a second code path in here.
|
|
19
58
|
*/
|
|
20
59
|
export function packEventLanes<T>(
|
|
21
60
|
events: CalendarEvent<T>[],
|
|
22
61
|
rangeStart: Date,
|
|
23
62
|
numDays: number,
|
|
24
63
|
): { bars: LaneBar<T>[]; lanes: number } {
|
|
25
|
-
const items = events
|
|
64
|
+
const items = [...events]
|
|
65
|
+
.sort(compareEvents)
|
|
26
66
|
.map((event) => ({
|
|
27
67
|
event,
|
|
28
68
|
startCol: Math.max(0, dayDiff(rangeStart, event.start)),
|
|
29
69
|
endCol: Math.min(numDays - 1, dayDiff(rangeStart, event.end ?? event.start)),
|
|
30
70
|
}))
|
|
31
|
-
.filter((it) => it.endCol >= 0 && it.startCol <= numDays - 1 && it.endCol >= it.startCol)
|
|
32
|
-
.sort((a, b) => a.startCol - b.startCol || b.endCol - a.endCol);
|
|
71
|
+
.filter((it) => it.endCol >= 0 && it.startCol <= numDays - 1 && it.endCol >= it.startCol);
|
|
33
72
|
|
|
34
|
-
|
|
73
|
+
// Occupancy per lane is a set of COLUMNS, not a single "last end column".
|
|
74
|
+
// With one end per lane, a bar late in the week blocks that lane for every
|
|
75
|
+
// day BEFORE it too — a Saturday holiday banner took lane 0 across the whole
|
|
76
|
+
// row and pushed Monday's first meeting down a lane, which on a short row is
|
|
77
|
+
// the difference between seeing it and reading "+2 more".
|
|
78
|
+
const laneCols: boolean[][] = [];
|
|
79
|
+
const free = (lane: boolean[], from: number, to: number) => {
|
|
80
|
+
for (let c = from; c <= to; c++) if (lane[c]) return false;
|
|
81
|
+
return true;
|
|
82
|
+
};
|
|
35
83
|
const bars = items.map((it) => {
|
|
36
|
-
let lane =
|
|
84
|
+
let lane = laneCols.findIndex((cols) => free(cols, it.startCol, it.endCol));
|
|
37
85
|
if (lane === -1) {
|
|
38
|
-
lane =
|
|
39
|
-
|
|
40
|
-
} else {
|
|
41
|
-
laneEnds[lane] = it.endCol;
|
|
86
|
+
lane = laneCols.length;
|
|
87
|
+
laneCols.push(Array.from({ length: numDays }, () => false));
|
|
42
88
|
}
|
|
89
|
+
for (let c = it.startCol; c <= it.endCol; c++) laneCols[lane][c] = true;
|
|
43
90
|
return { event: it.event, startCol: it.startCol, span: it.endCol - it.startCol + 1, lane };
|
|
44
91
|
});
|
|
45
|
-
return { bars, lanes:
|
|
92
|
+
return { bars, lanes: laneCols.length };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How many event lanes fit in a measured week row, and whether the row must
|
|
97
|
+
* give one back to the "+N more" chip.
|
|
98
|
+
*
|
|
99
|
+
* The lane cap used to be the constant 3 while the row itself was `flex: 1` —
|
|
100
|
+
* so a tall calendar wasted the space it had and a short one drew three lanes
|
|
101
|
+
* into a 20px row, straight over the next week's dates. A cap has to be a
|
|
102
|
+
* function of the height actually measured.
|
|
103
|
+
*/
|
|
104
|
+
export function fitLanes(
|
|
105
|
+
availableHeight: number,
|
|
106
|
+
laneHeight: number,
|
|
107
|
+
laneGap: number,
|
|
108
|
+
neededLanes: number,
|
|
109
|
+
overflowChipHeight: number,
|
|
110
|
+
): { visibleLanes: number; overflows: boolean } {
|
|
111
|
+
const per = laneHeight + laneGap;
|
|
112
|
+
// The last lane needs no trailing gap, so the gap is added back before
|
|
113
|
+
// dividing — without it a row that fits exactly N lanes reports N-1.
|
|
114
|
+
const fit = (h: number) => Math.max(0, Math.floor((h + laneGap) / per));
|
|
115
|
+
if (neededLanes <= fit(availableHeight)) return { visibleLanes: neededLanes, overflows: false };
|
|
116
|
+
// Overflowing costs the row the chip's height, NOT a whole lane: the chip is
|
|
117
|
+
// one line of small text, and charging it a full lane threw away an event
|
|
118
|
+
// that had room to be shown.
|
|
119
|
+
return { visibleLanes: fit(availableHeight - overflowChipHeight), overflows: true };
|
|
46
120
|
}
|
|
47
121
|
|
|
48
122
|
const MINUTES_PER_DAY = 1440;
|
|
@@ -54,6 +128,34 @@ function minutesInto(day: Date, t: Date): number {
|
|
|
54
128
|
return ms / 60000;
|
|
55
129
|
}
|
|
56
130
|
|
|
131
|
+
/**
|
|
132
|
+
* The minute window a time grid should open on.
|
|
133
|
+
*
|
|
134
|
+
* The previous rule was "an hour before now", unconditionally — so navigating to
|
|
135
|
+
* any week whose events sit outside the current wall-clock hour opened on an
|
|
136
|
+
* empty grid. A week of 09:00 meetings, viewed at 13:00, looked like a week with
|
|
137
|
+
* nothing in it. What a reader wants is the content: the earliest event in view,
|
|
138
|
+
* and only when there is none does the clock (or the configured day start) decide.
|
|
139
|
+
*/
|
|
140
|
+
export function initialScrollMinutes(
|
|
141
|
+
events: { start: Date }[],
|
|
142
|
+
daysInRange: Date[],
|
|
143
|
+
now: Date,
|
|
144
|
+
dayStartMinutes: number,
|
|
145
|
+
): number {
|
|
146
|
+
let earliest: number | null = null;
|
|
147
|
+
for (const e of events) {
|
|
148
|
+
const inRange = daysInRange.some((d) => startOfDay(e.start).getTime() === d.getTime());
|
|
149
|
+
if (!inRange) continue;
|
|
150
|
+
const m = minutesSinceMidnight(e.start);
|
|
151
|
+
if (earliest === null || m < earliest) earliest = m;
|
|
152
|
+
}
|
|
153
|
+
if (earliest !== null) return Math.max(0, earliest - 30);
|
|
154
|
+
const todayInRange = daysInRange.some((d) => startOfDay(now).getTime() === d.getTime());
|
|
155
|
+
if (todayInRange) return Math.max(0, minutesSinceMidnight(now) - 60);
|
|
156
|
+
return dayStartMinutes;
|
|
157
|
+
}
|
|
158
|
+
|
|
57
159
|
/**
|
|
58
160
|
* Position timed events for a single grid day into side-by-side columns.
|
|
59
161
|
*
|