@rapidmx/web-client 0.28.0 → 0.30.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/README.md +3 -0
- package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +22 -10
- package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +60 -0
- package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +108 -0
- package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +25 -1
- package/apps/shared/components/calendar/CalendarSearchBox.tsx +83 -0
- package/apps/shared/components/calendar/EventMatchNav.tsx +72 -0
- package/apps/shared/components/calendar/EventModal.tsx +33 -12
- package/apps/shared/components/calendar/ListView.tsx +207 -0
- package/apps/shared/components/calendar/MonthView.tsx +14 -2
- package/apps/shared/components/calendar/SlideIn.tsx +47 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +5 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +23 -12
- package/apps/shared/components/calendar/activeOccurrence.ts +28 -0
- package/apps/shared/components/calendar/allDay.ts +16 -1
- package/apps/shared/components/calendar/calendarView.ts +41 -0
- package/apps/shared/components/layout/AppShell.tsx +13 -6
- package/apps/shared/components/mail/ConversationList.tsx +38 -5
- package/apps/shared/components/mail/ConversationThreadPane.tsx +15 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +117 -55
- package/apps/shared/components/mail/MessageDetailPane.tsx +3 -0
- package/apps/shared/components/mail/SwipeRow.tsx +26 -4
- package/apps/shared/components/mail/compose/ComposeContext.tsx +193 -35
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +62 -11
- package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +21 -10
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +2 -1
- package/apps/shared/components/mail/compose/quotedBody.ts +45 -2
- package/apps/shared/components/mail/labelMenu.tsx +4 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +9 -2
- package/apps/shared/components/mail/reading/MessageBody.tsx +1 -1
- package/apps/shared/components/mail/reading/bodyContent.ts +15 -13
- package/apps/shared/components/mail/reading/bodyHtml.ts +12 -7
- package/apps/shared/gestures/useEnterSlide.ts +1 -1
- package/apps/shared/gestures/useLongPress.ts +138 -0
- package/apps/shared/keyboard/keymap.ts +2 -0
- package/apps/shared/mail/rangeSelection.ts +45 -0
- package/apps/shared/mail/rowExit.ts +39 -0
- package/apps/www/calendar/index.tsx +251 -53
- package/apps/www/index.tsx +570 -358
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +3 -3
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +15 -9
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +15 -0
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +16 -0
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +20 -0
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +31 -0
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +20 -1
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +7 -0
- package/dist/apps/shared/components/calendar/CalendarSearchBox.d.ts +22 -0
- package/dist/apps/shared/components/calendar/CalendarSearchBox.js +24 -0
- package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +17 -0
- package/dist/apps/shared/components/calendar/EventMatchNav.js +37 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +17 -1
- package/dist/apps/shared/components/calendar/EventModal.js +13 -11
- package/dist/apps/shared/components/calendar/ListView.d.ts +30 -0
- package/dist/apps/shared/components/calendar/ListView.js +111 -0
- package/dist/apps/shared/components/calendar/MonthView.js +12 -3
- package/dist/apps/shared/components/calendar/SlideIn.d.ts +16 -0
- package/dist/apps/shared/components/calendar/SlideIn.js +32 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +5 -1
- package/dist/apps/shared/components/calendar/TimeGridView.js +11 -3
- package/dist/apps/shared/components/calendar/activeOccurrence.d.ts +15 -0
- package/dist/apps/shared/components/calendar/activeOccurrence.js +21 -0
- package/dist/apps/shared/components/calendar/allDay.d.ts +4 -0
- package/dist/apps/shared/components/calendar/allDay.js +14 -1
- package/dist/apps/shared/components/calendar/calendarView.d.ts +16 -0
- package/dist/apps/shared/components/calendar/calendarView.js +36 -0
- package/dist/apps/shared/components/layout/AppShell.js +8 -4
- package/dist/apps/shared/components/mail/ConversationList.d.ts +16 -3
- package/dist/apps/shared/components/mail/ConversationList.js +10 -2
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +9 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
- package/dist/apps/shared/components/mail/MessageDetailPane.js +3 -0
- package/dist/apps/shared/components/mail/SwipeRow.d.ts +5 -1
- package/dist/apps/shared/components/mail/SwipeRow.js +17 -3
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +31 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +123 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +6 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +37 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +5 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +10 -6
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +2 -1
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +43 -1
- package/dist/apps/shared/components/mail/labelMenu.d.ts +3 -1
- package/dist/apps/shared/components/mail/labelMenu.js +2 -2
- package/dist/apps/shared/components/mail/layout/MailShell.js +7 -1
- package/dist/apps/shared/components/mail/reading/MessageBody.js +1 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +5 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.js +11 -11
- package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +3 -2
- package/dist/apps/shared/components/mail/reading/bodyHtml.js +8 -4
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +2 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +1 -1
- package/dist/apps/shared/gestures/useLongPress.d.ts +41 -0
- package/dist/apps/shared/gestures/useLongPress.js +112 -0
- package/dist/apps/shared/keyboard/keymap.d.ts +12 -0
- package/dist/apps/shared/keyboard/keymap.js +2 -0
- package/dist/apps/shared/mail/rangeSelection.d.ts +20 -0
- package/dist/apps/shared/mail/rangeSelection.js +34 -0
- package/dist/apps/shared/mail/rowExit.d.ts +12 -0
- package/dist/apps/shared/mail/rowExit.js +33 -0
- package/dist/apps/www/calendar/index.js +177 -18
- package/dist/apps/www/index.js +257 -73
- package/dist/lib/calendar/eventSearch.d.ts +42 -0
- package/dist/lib/calendar/eventSearch.js +152 -0
- package/dist/lib/components/navigation/BottomTabBar.d.ts +15 -3
- package/dist/lib/components/navigation/BottomTabBar.js +52 -15
- package/dist/lib/components/navigation/NavBadge.d.ts +13 -0
- package/dist/lib/components/navigation/NavBadge.js +18 -0
- package/dist/lib/mail/compose/quotedImages.d.ts +21 -0
- package/dist/lib/mail/compose/quotedImages.js +41 -0
- package/dist/lib/mail/inlineImages.d.ts +38 -0
- package/dist/lib/mail/inlineImages.js +90 -0
- package/dist/lib/mail/mailApi.d.ts +8 -1
- package/dist/lib/mail/mailApi.js +57 -5
- package/lib/calendar/eventSearch.ts +172 -0
- package/lib/components/navigation/BottomTabBar.tsx +107 -22
- package/lib/components/navigation/NavBadge.tsx +33 -0
- package/lib/mail/compose/quotedImages.ts +49 -0
- package/lib/mail/inlineImages.ts +104 -0
- package/lib/mail/mailApi.ts +64 -5
- package/package.json +1 -1
- package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +0 -36
- package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +0 -10
- package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +0 -11
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useMemo, useRef } from "react";
|
|
6
|
+
import { addDays, format, isToday, parseISO, startOfDay } from "date-fns";
|
|
7
|
+
import { CalendarOccurrence } from "../../../../lib/calendar/recurrence.js";
|
|
8
|
+
import { allDayDateKey, localDateKey, startsOnDay } from "./allDay.js";
|
|
9
|
+
import { occurrenceMarker, useActiveOccurrenceKey } from "./activeOccurrence.js";
|
|
10
|
+
|
|
11
|
+
/** The most days one event is listed on, so an event that runs for years does not become thousands of rows. */
|
|
12
|
+
export const LIST_MAX_DAYS_PER_EVENT = 366;
|
|
13
|
+
|
|
14
|
+
/** A step through the list's months: `nonce` makes a second press in the same direction a new step. */
|
|
15
|
+
export interface ListStep {
|
|
16
|
+
direction: 1 | -1;
|
|
17
|
+
nonce: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ListViewProps {
|
|
21
|
+
/** Every occurrence to list (or, while a search runs, only its matches) - see `eventSearch.ts`'s `allOccurrences()`. Only today and later is shown. */
|
|
22
|
+
occurrences: CalendarOccurrence[];
|
|
23
|
+
/** Each occurrence's own calendar color, keyed by `folderUid` - see `calendarColors.ts`. */
|
|
24
|
+
folderColors: Record<string, string>;
|
|
25
|
+
onSelectEvent: (occurrence: CalendarOccurrence) => void;
|
|
26
|
+
/** The day the list scrolls to (the first day from it that has events - the top for one in the past -, the last day when none does) on opening and whenever `jumpNonce` changes. */
|
|
27
|
+
focusDate: Date;
|
|
28
|
+
jumpNonce: number;
|
|
29
|
+
/** Scrolls to the first day of the previous or next month (from the one at the top) that has events. */
|
|
30
|
+
step: ListStep | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** When an occurrence is on show on `day`: its time range, "All day", or - on a later day of a multi-day event - that it continues. */
|
|
34
|
+
function timeLabel(occurrence: CalendarOccurrence, day: Date): string {
|
|
35
|
+
if (occurrence.allDay) {
|
|
36
|
+
return "All day";
|
|
37
|
+
}
|
|
38
|
+
if (!startsOnDay(occurrence, day)) {
|
|
39
|
+
return "Continues";
|
|
40
|
+
}
|
|
41
|
+
const start = new Date(occurrence.startDate);
|
|
42
|
+
const end = new Date(occurrence.endDate);
|
|
43
|
+
return start.getTime() === end.getTime() ? format(start, "h:mma") : `${format(start, "h:mma")} – ${format(end, "h:mma")}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The days (local midnights) `occurrence` is on show on: from its first to its last, at most `LIST_MAX_DAYS_PER_EVENT`. */
|
|
47
|
+
function daysOf(occurrence: CalendarOccurrence): Date[] {
|
|
48
|
+
let first: Date;
|
|
49
|
+
let last: Date;
|
|
50
|
+
if (occurrence.allDay) {
|
|
51
|
+
// Date-only, the end exclusive (see `allDay.ts`), and at least the start day.
|
|
52
|
+
first = parseISO(allDayDateKey(occurrence.startDate));
|
|
53
|
+
last = addDays(parseISO(allDayDateKey(occurrence.endDate)), -1);
|
|
54
|
+
} else {
|
|
55
|
+
const start = new Date(occurrence.startDate);
|
|
56
|
+
const end = new Date(occurrence.endDate);
|
|
57
|
+
first = startOfDay(start);
|
|
58
|
+
// An event that ends at midnight does not reach into that day.
|
|
59
|
+
last = startOfDay(end > start ? new Date(end.getTime() - 1) : start);
|
|
60
|
+
}
|
|
61
|
+
const days = [first];
|
|
62
|
+
for (let day = addDays(first, 1); day <= last && days.length < LIST_MAX_DAYS_PER_EVENT; day = addDays(day, 1)) {
|
|
63
|
+
days.push(day);
|
|
64
|
+
}
|
|
65
|
+
return days;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The day's rows in the order they are read: the all-day events and those carried over from an earlier day, then the timed ones as they start. */
|
|
69
|
+
function sortedForDay(occurrences: CalendarOccurrence[], day: Date): CalendarOccurrence[] {
|
|
70
|
+
const rank = (occurrence: CalendarOccurrence) => (occurrence.allDay || !startsOnDay(occurrence, day) ? 0 : 1);
|
|
71
|
+
return occurrences.sort((a, b) => rank(a) - rank(b) || new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Every event from today on as one scrollable list, in date order, with a heading and a list for each day that has any (days without events are left out, and
|
|
76
|
+
* nothing at all says so). A multi-day event is listed on every day it covers, as the month grid shows it. Each row is a full-width button that
|
|
77
|
+
* opens the event's card, with the time, title, place (or organizer) and its calendar's colour.
|
|
78
|
+
*
|
|
79
|
+
* Every day is rendered (an event is a cheap row, and a series is bounded to 500 occurrences - see `eventSearch.ts`); the days are grouped once per
|
|
80
|
+
* change of `occurrences`, not per render. The list scrolls itself to `focusDate` and, on a `step`, to the neighbouring month.
|
|
81
|
+
*/
|
|
82
|
+
export default function ListView({ occurrences, folderColors, onSelectEvent, focusDate, jumpNonce, step }: ListViewProps) {
|
|
83
|
+
const activeKey = useActiveOccurrenceKey();
|
|
84
|
+
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
85
|
+
const groups = useMemo(() => {
|
|
86
|
+
const byDay = new Map<string, { day: Date; items: CalendarOccurrence[] }>();
|
|
87
|
+
const todayKey = localDateKey(new Date());
|
|
88
|
+
for (const occurrence of occurrences) {
|
|
89
|
+
for (const day of daysOf(occurrence)) {
|
|
90
|
+
const key = localDateKey(day);
|
|
91
|
+
// The days an event spent before today are not listed.
|
|
92
|
+
if (key < todayKey) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const group = byDay.get(key) ?? { day, items: [] };
|
|
96
|
+
group.items.push(occurrence);
|
|
97
|
+
byDay.set(key, group);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return Array.from(byDay.entries())
|
|
101
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
102
|
+
.map(([key, { day, items }]) => ({ key, day, items: sortedForDay(items, day) }));
|
|
103
|
+
}, [occurrences]);
|
|
104
|
+
|
|
105
|
+
const dayElements = () => Array.from(scrollerRef.current!.querySelectorAll<HTMLElement>("[data-day]"));
|
|
106
|
+
|
|
107
|
+
// On opening and on a jump (Today, a date picked in the mini calendar): the first day from `focusDate` that has events, else the last one.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const wanted = localDateKey(focusDate);
|
|
110
|
+
const days = dayElements();
|
|
111
|
+
(days.find((el) => el.dataset.day! >= wanted) ?? days[days.length - 1])?.scrollIntoView({ block: "start" });
|
|
112
|
+
}, [jumpNonce]);
|
|
113
|
+
|
|
114
|
+
// A step: from the month of the day at the top of the list to the first day of the previous or next month that has any.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const days = dayElements();
|
|
117
|
+
if (!step || days.length === 0) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const top = scrollerRef.current!.getBoundingClientRect().top;
|
|
121
|
+
const current = days.find((el) => el.getBoundingClientRect().bottom > top) ?? days[days.length - 1];
|
|
122
|
+
const months = Array.from(new Set(days.map((el) => el.dataset.day!.slice(0, 7))));
|
|
123
|
+
const month = months[months.indexOf(current.dataset.day!.slice(0, 7)) + step.direction];
|
|
124
|
+
// Past the first or last month with events there is nowhere to go.
|
|
125
|
+
days.find((el) => el.dataset.day!.startsWith(month))?.scrollIntoView({ block: "start" });
|
|
126
|
+
}, [step]);
|
|
127
|
+
|
|
128
|
+
const thisYear = new Date().getFullYear();
|
|
129
|
+
return (
|
|
130
|
+
<div ref={scrollerRef} role="region" aria-label="List" data-calendar-scroller className="flex-1 min-h-0 overflow-y-auto">
|
|
131
|
+
{groups.length === 0 ? (
|
|
132
|
+
<p className="p-4 text-sm text-text-muted">No upcoming events</p>
|
|
133
|
+
) : (
|
|
134
|
+
groups.map(({ key, day, items }) => (
|
|
135
|
+
<section key={key} data-day={key} aria-labelledby={`list-day-${key}`}>
|
|
136
|
+
<h3
|
|
137
|
+
id={`list-day-${key}`}
|
|
138
|
+
// Today is marked, not shouted: the same opaque bar as every day, with a primary edge and a small pill.
|
|
139
|
+
aria-current={isToday(day) ? "date" : undefined}
|
|
140
|
+
className={[
|
|
141
|
+
"sticky top-0 z-10 px-4 py-2 text-sm font-semibold border-b border-border bg-surface-alt text-text border-l-4",
|
|
142
|
+
isToday(day) ? "border-l-primary" : "border-l-transparent",
|
|
143
|
+
].join(" ")}
|
|
144
|
+
>
|
|
145
|
+
{format(day, day.getFullYear() === thisYear ? "EEEE, MMMM d" : "EEEE, MMMM d, yyyy")}
|
|
146
|
+
{isToday(day) && (
|
|
147
|
+
<span aria-hidden="true" className="ml-2 px-1.5 py-0.5 rounded-full bg-primary/15 text-primary-dark text-xs font-medium">
|
|
148
|
+
Today
|
|
149
|
+
</span>
|
|
150
|
+
)}
|
|
151
|
+
</h3>
|
|
152
|
+
<ul>
|
|
153
|
+
{items.map((occurrence) => (
|
|
154
|
+
<ListRow
|
|
155
|
+
key={occurrence.occurrenceKey}
|
|
156
|
+
occurrence={occurrence}
|
|
157
|
+
day={day}
|
|
158
|
+
color={folderColors[occurrence.folderUid]}
|
|
159
|
+
onSelect={onSelectEvent}
|
|
160
|
+
activeKey={activeKey}
|
|
161
|
+
/>
|
|
162
|
+
))}
|
|
163
|
+
</ul>
|
|
164
|
+
</section>
|
|
165
|
+
))
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function ListRow({
|
|
172
|
+
occurrence,
|
|
173
|
+
day,
|
|
174
|
+
color,
|
|
175
|
+
onSelect,
|
|
176
|
+
activeKey,
|
|
177
|
+
}: {
|
|
178
|
+
occurrence: CalendarOccurrence;
|
|
179
|
+
day: Date;
|
|
180
|
+
color: string;
|
|
181
|
+
onSelect: (occurrence: CalendarOccurrence) => void;
|
|
182
|
+
activeKey: string | null;
|
|
183
|
+
}) {
|
|
184
|
+
const marker = occurrenceMarker(activeKey, occurrence);
|
|
185
|
+
const place = occurrence.location || occurrence.organizer.displayName || occurrence.organizer.address;
|
|
186
|
+
return (
|
|
187
|
+
<li className="border-b border-border/50">
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
{...marker.attrs}
|
|
191
|
+
onClick={() => onSelect(occurrence)}
|
|
192
|
+
className={["w-full min-h-14 px-4 py-2 flex items-center gap-3 text-left hover:bg-surface-alt", marker.className].join(" ")}
|
|
193
|
+
>
|
|
194
|
+
<span className="w-28 shrink-0 text-xs text-text-muted">{timeLabel(occurrence, day)}</span>
|
|
195
|
+
<span
|
|
196
|
+
aria-hidden="true"
|
|
197
|
+
className={["w-3 h-3 rounded-full shrink-0", occurrence.busyStatus === "free" ? "border-2" : ""].join(" ")}
|
|
198
|
+
style={occurrence.busyStatus === "free" ? { borderColor: color } : { backgroundColor: color }}
|
|
199
|
+
/>
|
|
200
|
+
<span className="flex-1 min-w-0">
|
|
201
|
+
<span className="block text-sm font-medium truncate">{occurrence.title}</span>
|
|
202
|
+
<span className="block text-xs text-text-muted truncate">{place}</span>
|
|
203
|
+
</span>
|
|
204
|
+
</button>
|
|
205
|
+
</li>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -8,6 +8,7 @@ import { addDays, eachDayOfInterval, endOfMonth, endOfWeek, format, isSameMonth,
|
|
|
8
8
|
import { dayDropId, eventDragId } from "../../../../lib/calendar/calendarDragIds.js";
|
|
9
9
|
import { CalendarOccurrence } from "../../../../lib/calendar/recurrence.js";
|
|
10
10
|
import { occursOnDay, startsOnDay } from "./allDay.js";
|
|
11
|
+
import { occurrenceMarker, useActiveOccurrenceKey } from "./activeOccurrence.js";
|
|
11
12
|
import { EventAnchor, anchorOf } from "./EventShell.js";
|
|
12
13
|
|
|
13
14
|
const MAX_CHIPS_PER_DAY = 3;
|
|
@@ -63,7 +64,13 @@ interface DayCellProps {
|
|
|
63
64
|
|
|
64
65
|
function DayCell({ day, inCurrentMonth, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }: DayCellProps) {
|
|
65
66
|
const { setNodeRef, isOver } = useDroppable({ id: dayDropId(day) });
|
|
66
|
-
const
|
|
67
|
+
const activeKey = useActiveOccurrenceKey();
|
|
68
|
+
// The search's current result is always shown, even when it would be one of the "+N more".
|
|
69
|
+
const activeIndex = occurrences.findIndex((occ) => occ.occurrenceKey === activeKey);
|
|
70
|
+
const visible =
|
|
71
|
+
activeIndex >= MAX_CHIPS_PER_DAY
|
|
72
|
+
? [...occurrences.slice(0, MAX_CHIPS_PER_DAY - 1), occurrences[activeIndex]]
|
|
73
|
+
: occurrences.slice(0, MAX_CHIPS_PER_DAY);
|
|
67
74
|
const overflowCount = occurrences.length - visible.length;
|
|
68
75
|
|
|
69
76
|
return (
|
|
@@ -136,10 +143,12 @@ function EventChip({
|
|
|
136
143
|
}) {
|
|
137
144
|
const { setNodeRef, listeners, attributes, transform, isDragging } = useDraggable({ id: eventDragId(occurrence) });
|
|
138
145
|
const isFree = occurrence.busyStatus === "free";
|
|
146
|
+
const marker = occurrenceMarker(useActiveOccurrenceKey(), occurrence);
|
|
139
147
|
|
|
140
148
|
return (
|
|
141
149
|
<button
|
|
142
150
|
ref={setNodeRef}
|
|
151
|
+
{...marker.attrs}
|
|
143
152
|
type="button"
|
|
144
153
|
onClick={() => onSelect(occurrence)}
|
|
145
154
|
style={{
|
|
@@ -150,6 +159,7 @@ function EventChip({
|
|
|
150
159
|
"text-xs text-left truncate rounded-sm px-1.5 py-0.5 shrink-0",
|
|
151
160
|
isFree ? "bg-surface-alt text-text-muted" : "",
|
|
152
161
|
isDragging ? "opacity-50" : "",
|
|
162
|
+
marker.className,
|
|
153
163
|
].join(" ")}
|
|
154
164
|
{...listeners}
|
|
155
165
|
{...attributes}
|
|
@@ -172,12 +182,14 @@ function ContinuationChip({
|
|
|
172
182
|
onSelect: (occurrence: CalendarOccurrence) => void;
|
|
173
183
|
}) {
|
|
174
184
|
const isFree = occurrence.busyStatus === "free";
|
|
185
|
+
const marker = occurrenceMarker(useActiveOccurrenceKey(), occurrence);
|
|
175
186
|
return (
|
|
176
187
|
<button
|
|
177
188
|
type="button"
|
|
189
|
+
{...marker.attrs}
|
|
178
190
|
onClick={() => onSelect(occurrence)}
|
|
179
191
|
style={isFree ? undefined : { backgroundColor: color, color: "#fff" }}
|
|
180
|
-
className={["text-xs text-left truncate rounded-sm px-1.5 py-0.5 shrink-0", isFree ? "bg-surface-alt text-text-muted" : ""].join(" ")}
|
|
192
|
+
className={["text-xs text-left truncate rounded-sm px-1.5 py-0.5 shrink-0", isFree ? "bg-surface-alt text-text-muted" : "", marker.className].join(" ")}
|
|
181
193
|
>
|
|
182
194
|
{occurrence.title}
|
|
183
195
|
</button>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { ReactNode, useLayoutEffect, useRef } from "react";
|
|
6
|
+
import { prefersReducedMotion } from "../../gestures/useEnterSlide.js";
|
|
7
|
+
|
|
8
|
+
/** The side a card slides in from: the Previous match's comes from the left, the Next match's from the right. */
|
|
9
|
+
export type SlideFrom = "left" | "right";
|
|
10
|
+
|
|
11
|
+
/** How long the card takes to settle, and how far off it starts, in px. */
|
|
12
|
+
export const SLIDE_IN_MS = 220;
|
|
13
|
+
const SLIDE_IN_OFFSET_PX = 48;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Slides what it holds in from `from` (and fades it in) as it mounts - mount it afresh (a `key`) for each card. Without `from` (the card was just
|
|
17
|
+
* opened, not stepped to) nothing moves. The animation is the Web Animations API's, so nothing is left behind in the styles, and it is skipped
|
|
18
|
+
* for someone who asked for less motion and where the browser has no `Element.animate()`. `data-slide-from` says which way it came, for tests and
|
|
19
|
+
* styling. Its own clip (`overflow-x-clip`, which makes no scroller) keeps the sliding card inside the dialog's edges; the dialog itself, the
|
|
20
|
+
* backdrop and the buttons beside the card are not moved.
|
|
21
|
+
*/
|
|
22
|
+
export default function SlideIn({ from, children }: { from?: SlideFrom; children: ReactNode }) {
|
|
23
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
24
|
+
|
|
25
|
+
useLayoutEffect(() => {
|
|
26
|
+
const element = ref.current!;
|
|
27
|
+
if (!from || typeof element.animate !== "function" || prefersReducedMotion()) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const offset = from === "left" ? -SLIDE_IN_OFFSET_PX : SLIDE_IN_OFFSET_PX;
|
|
31
|
+
element.animate(
|
|
32
|
+
[
|
|
33
|
+
{ transform: `translateX(${offset}px)`, opacity: 0 },
|
|
34
|
+
{ transform: "translateX(0)", opacity: 1 },
|
|
35
|
+
],
|
|
36
|
+
{ duration: SLIDE_IN_MS, easing: "ease-out" },
|
|
37
|
+
);
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="overflow-x-clip">
|
|
42
|
+
<div ref={ref} data-slide-from={from}>
|
|
43
|
+
{children}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -6,6 +6,7 @@ import React from "react";
|
|
|
6
6
|
import { addDays, addMinutes, format, startOfDay } from "date-fns";
|
|
7
7
|
import { CalendarOccurrence } from "../../../../lib/calendar/recurrence.js";
|
|
8
8
|
import { occursOnDay } from "./allDay.js";
|
|
9
|
+
import { occurrenceMarker, useActiveOccurrenceKey } from "./activeOccurrence.js";
|
|
9
10
|
import { EventAnchor, anchorOf } from "./EventShell.js";
|
|
10
11
|
|
|
11
12
|
const HOUR_HEIGHT_PX = 48;
|
|
@@ -91,6 +92,7 @@ function SplitColumn({
|
|
|
91
92
|
onSelectEvent: (occurrence: CalendarOccurrence) => void;
|
|
92
93
|
onSelectSlot: (start: Date, end: Date, folderUid: string, anchor: EventAnchor) => void;
|
|
93
94
|
}) {
|
|
95
|
+
const activeKey = useActiveOccurrenceKey();
|
|
94
96
|
return (
|
|
95
97
|
<div className="flex-1 min-w-0 relative border-l border-border">
|
|
96
98
|
{Array.from({ length: SLOTS_PER_DAY }, (_, i) => {
|
|
@@ -117,9 +119,11 @@ function SplitColumn({
|
|
|
117
119
|
const top = ((visibleStart - dayStart.getTime()) / 60_000 / 60) * HOUR_HEIGHT_PX;
|
|
118
120
|
const height = Math.max(((visibleEnd - visibleStart) / 60_000 / 60) * HOUR_HEIGHT_PX, 16);
|
|
119
121
|
const isFree = occurrence.busyStatus === "free";
|
|
122
|
+
const marker = occurrenceMarker(activeKey, occurrence);
|
|
120
123
|
return (
|
|
121
124
|
<div
|
|
122
125
|
key={occurrence.occurrenceKey}
|
|
126
|
+
{...marker.attrs}
|
|
123
127
|
onClick={() => onSelectEvent(occurrence)}
|
|
124
128
|
style={{
|
|
125
129
|
position: "absolute",
|
|
@@ -132,6 +136,7 @@ function SplitColumn({
|
|
|
132
136
|
className={[
|
|
133
137
|
"rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer",
|
|
134
138
|
isFree ? "bg-surface-alt text-text-muted" : "",
|
|
139
|
+
marker.className,
|
|
135
140
|
].join(" ")}
|
|
136
141
|
>
|
|
137
142
|
<div className="font-medium truncate">{occurrence.title}</div>
|
|
@@ -8,6 +8,7 @@ import { addDays, addMinutes, format, isToday, startOfDay } from "date-fns";
|
|
|
8
8
|
import { dayDropId, eventDragId, resizeDragId, slotDropId } from "../../../../lib/calendar/calendarDragIds.js";
|
|
9
9
|
import { CalendarOccurrence } from "../../../../lib/calendar/recurrence.js";
|
|
10
10
|
import { occursOnDay, startsOnDay } from "./allDay.js";
|
|
11
|
+
import { occurrenceMarker, useActiveOccurrenceKey } from "./activeOccurrence.js";
|
|
11
12
|
import { EventAnchor, anchorOf } from "./EventShell.js";
|
|
12
13
|
|
|
13
14
|
const HOUR_HEIGHT_PX = 48;
|
|
@@ -34,6 +35,7 @@ export interface TimeGridViewProps {
|
|
|
34
35
|
export default function TimeGridView({ days, occurrences, folderColors, onSelectEvent, onSelectSlot }: TimeGridViewProps) {
|
|
35
36
|
const allDayEvents = occurrences.filter((occ) => occ.allDay);
|
|
36
37
|
const timedEvents = occurrences.filter((occ) => !occ.allDay);
|
|
38
|
+
const activeKey = useActiveOccurrenceKey();
|
|
37
39
|
|
|
38
40
|
return (
|
|
39
41
|
<div data-calendar-scroller className="flex-1 flex flex-col min-h-0 overflow-y-auto">
|
|
@@ -55,17 +57,21 @@ export default function TimeGridView({ days, occurrences, folderColors, onSelect
|
|
|
55
57
|
<div key={day.toISOString()} className="flex-1 min-w-0 p-1 flex flex-col gap-0.5 border-l border-border">
|
|
56
58
|
{allDayEvents
|
|
57
59
|
.filter((occ) => occursOnDay(occ, day))
|
|
58
|
-
.map((occ) =>
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
.map((occ) => {
|
|
61
|
+
const marker = occurrenceMarker(activeKey, occ);
|
|
62
|
+
return (
|
|
63
|
+
<button
|
|
64
|
+
key={occ.occurrenceKey}
|
|
65
|
+
type="button"
|
|
66
|
+
{...marker.attrs}
|
|
67
|
+
onClick={() => onSelectEvent(occ)}
|
|
68
|
+
style={{ backgroundColor: folderColors[occ.folderUid], color: "#fff" }}
|
|
69
|
+
className={["text-xs text-left truncate rounded-sm px-1.5 py-0.5", marker.className].join(" ")}
|
|
70
|
+
>
|
|
71
|
+
{occ.title}
|
|
72
|
+
</button>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
69
75
|
</div>
|
|
70
76
|
))}
|
|
71
77
|
</div>
|
|
@@ -175,11 +181,13 @@ function ContinuationBlock({
|
|
|
175
181
|
}) {
|
|
176
182
|
const isFree = occurrence.busyStatus === "free";
|
|
177
183
|
const { top, height } = blockGeometry(occurrence, dayStart);
|
|
184
|
+
const marker = occurrenceMarker(useActiveOccurrenceKey(), occurrence);
|
|
178
185
|
return (
|
|
179
186
|
<div
|
|
187
|
+
{...marker.attrs}
|
|
180
188
|
onClick={() => onSelect(occurrence)}
|
|
181
189
|
style={{ position: "absolute", top, height, left: 2, right: 2, ...(isFree ? undefined : { backgroundColor: color, color: "#fff" }) }}
|
|
182
|
-
className={["rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer", isFree ? "bg-surface-alt text-text-muted" : ""].join(" ")}
|
|
190
|
+
className={["rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer", isFree ? "bg-surface-alt text-text-muted" : "", marker.className].join(" ")}
|
|
183
191
|
>
|
|
184
192
|
<div className="font-medium truncate">{occurrence.title}</div>
|
|
185
193
|
</div>
|
|
@@ -207,6 +215,7 @@ function EventBlock({
|
|
|
207
215
|
|
|
208
216
|
const start = new Date(occurrence.startDate);
|
|
209
217
|
const { top, height } = blockGeometry(occurrence, dayStart);
|
|
218
|
+
const marker = occurrenceMarker(useActiveOccurrenceKey(), occurrence);
|
|
210
219
|
// The resize handle's own drag position has no live pixel preview (it only applies its ns-resize
|
|
211
220
|
// affordance) — the actual new end time is computed from whichever slot it's dropped onto (see
|
|
212
221
|
// `resolveDragAction`'s "resize" case), snapped to the grid rather than following the pointer
|
|
@@ -215,6 +224,7 @@ function EventBlock({
|
|
|
215
224
|
return (
|
|
216
225
|
<div
|
|
217
226
|
ref={setNodeRef}
|
|
227
|
+
{...marker.attrs}
|
|
218
228
|
onClick={() => onSelect(occurrence)}
|
|
219
229
|
style={{
|
|
220
230
|
position: "absolute",
|
|
@@ -230,6 +240,7 @@ function EventBlock({
|
|
|
230
240
|
"rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer",
|
|
231
241
|
isFree ? "bg-surface-alt text-text-muted" : "",
|
|
232
242
|
isDragging ? "opacity-50" : "",
|
|
243
|
+
marker.className,
|
|
233
244
|
].join(" ")}
|
|
234
245
|
{...listeners}
|
|
235
246
|
{...attributes}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { createContext, useContext } from "react";
|
|
6
|
+
import { CalendarOccurrence } from "../../../../lib/calendar/recurrence.js";
|
|
7
|
+
|
|
8
|
+
/** The `occurrenceKey` of the search result the calendar is pointing at (`null`: none), for the views to mark - a context, so the month, week and split
|
|
9
|
+
* views and their many kinds of chip need no prop threaded through each. */
|
|
10
|
+
export const ActiveOccurrenceContext = createContext<string | null>(null);
|
|
11
|
+
|
|
12
|
+
/** The key of the occurrence the calendar's search is on. */
|
|
13
|
+
export function useActiveOccurrenceKey(): string | null {
|
|
14
|
+
return useContext(ActiveOccurrenceContext);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** What an occurrence's chip or block spreads on itself: `data-occurrence-key` (how the page finds it to scroll to) and, when it is the
|
|
18
|
+
* `activeKey` one, a ring and `data-active-match`. */
|
|
19
|
+
export function occurrenceMarker(
|
|
20
|
+
activeKey: string | null,
|
|
21
|
+
occurrence: CalendarOccurrence,
|
|
22
|
+
): { attrs: { "data-occurrence-key": string; "data-active-match"?: "true" }; className: string } {
|
|
23
|
+
const active = activeKey === occurrence.occurrenceKey;
|
|
24
|
+
return {
|
|
25
|
+
attrs: { "data-occurrence-key": occurrence.occurrenceKey, ...(active ? { "data-active-match": "true" as const } : undefined) },
|
|
26
|
+
className: active ? "ring-2 ring-text ring-offset-1 ring-offset-surface" : "",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import { format } from "date-fns";
|
|
5
|
+
import { format, parseISO, startOfDay } from "date-fns";
|
|
6
6
|
import { WeekdayCode } from "../../../../lib/calendar/calendarApi.js";
|
|
7
7
|
import { CalendarOccurrence, toEventWallClock } from "../../../../lib/calendar/recurrence.js";
|
|
8
8
|
|
|
@@ -112,6 +112,21 @@ export function occursOnDay(occurrence: CalendarOccurrence, day: Date): boolean
|
|
|
112
112
|
return start < dayEnd && end > dayStart;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/** Whether `occurrence` is on show on `today` (a local day) or later: it ends after that day begins, so one still running today counts and one that ended is not. */
|
|
116
|
+
export function isUpcoming(occurrence: CalendarOccurrence, today: Date): boolean {
|
|
117
|
+
if (occurrence.allDay) {
|
|
118
|
+
const key = localDateKey(today);
|
|
119
|
+
return allDayDateKey(occurrence.endDate) > key || allDayDateKey(occurrence.startDate) >= key;
|
|
120
|
+
}
|
|
121
|
+
const dayStart = startOfDay(today);
|
|
122
|
+
return new Date(occurrence.endDate) > dayStart || new Date(occurrence.startDate) >= dayStart;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The local calendar day (midnight) `occurrence` starts on - the day a view shows to bring it into sight. */
|
|
126
|
+
export function occurrenceDay(occurrence: CalendarOccurrence): Date {
|
|
127
|
+
return occurrence.allDay ? parseISO(allDayDateKey(occurrence.startDate)) : startOfDay(new Date(occurrence.startDate));
|
|
128
|
+
}
|
|
129
|
+
|
|
115
130
|
/**
|
|
116
131
|
* Whether `day` is the first day `occurrence` covers. Grid views only make that day's chip/block
|
|
117
132
|
* draggable — a drag id must be unique, so the continuation copies on later days are display-only.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The calendar's views, and the one the reader last chose - kept in `localStorage` so coming back to the Calendar from elsewhere in the app (or
|
|
7
|
+
* reopening it) lands on the same view, the way the mail list remembers its own arrangement (see `listPreferences.ts`). Per device, not synced.
|
|
8
|
+
* A read that finds nothing, something this client doesn't know or a blocked store gives `null`, and a write that fails is swallowed: the
|
|
9
|
+
* choice then only doesn't outlive the page.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The views, in the order the switcher lists them: the List sits left of the Month. */
|
|
13
|
+
export type CalendarView = "list" | "month" | "week" | "workWeek" | "day" | "split";
|
|
14
|
+
export const CALENDAR_VIEWS: CalendarView[] = ["list", "month", "week", "workWeek", "day", "split"];
|
|
15
|
+
|
|
16
|
+
const STORAGE_KEY = "rapidmx:calendar-view";
|
|
17
|
+
|
|
18
|
+
/** Whether `value` names a view. */
|
|
19
|
+
export function isCalendarView(value: unknown): value is CalendarView {
|
|
20
|
+
return (CALENDAR_VIEWS as unknown[]).includes(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The view last chosen, or `null` when none was (or it cannot be read). The calendar page does not restore a stored `"list"`: that view is the phone's
|
|
24
|
+
* default and is never written by a desktop. */
|
|
25
|
+
export function getStoredCalendarView(): CalendarView | null {
|
|
26
|
+
try {
|
|
27
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
28
|
+
return isCalendarView(stored) ? stored : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Remembers `view` as the one to open the Calendar on. */
|
|
35
|
+
export function storeCalendarView(view: CalendarView): void {
|
|
36
|
+
try {
|
|
37
|
+
localStorage.setItem(STORAGE_KEY, view);
|
|
38
|
+
} catch {
|
|
39
|
+
// A blocked store only means the choice does not outlive the page.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -22,6 +22,7 @@ import ComposeProvider from "../mail/compose/ComposeContext.js";
|
|
|
22
22
|
import ContactCardProvider from "../contacts/ContactCardProvider.js";
|
|
23
23
|
import { flushComposeDrafts, markSigningOut } from "../mail/compose/composeFlushRegistry.js";
|
|
24
24
|
import BottomTabBar, { NavItem } from "../../../../lib/components/navigation/BottomTabBar.js";
|
|
25
|
+
import NavBadge, { navItemLabel } from "../../../../lib/components/navigation/NavBadge.js";
|
|
25
26
|
import { FrameBrandingFooter, FrameBrandingHeader, useBrandingHtml } from "./BrandingChrome.js";
|
|
26
27
|
import RailIcon from "./RailIcon.js";
|
|
27
28
|
import AppearanceProvider from "../../appearance/AppearanceProvider.js";
|
|
@@ -323,7 +324,10 @@ export function AppChrome({
|
|
|
323
324
|
}, [fetchedBranding?.title, inFrame]);
|
|
324
325
|
// The router sets the document's title to the page's own whenever the page changes, which drops the unread count this puts in front
|
|
325
326
|
// of it, so a new pathname is what puts the count back. (A folder change is shallow and keeps the title.)
|
|
326
|
-
|
|
327
|
+
// The same count is the chip on the Mail icon (rail and bottom tab bar). It is zero - no chip - until the folders are listed, with no
|
|
328
|
+
// mailbox, and wherever the connection is off, so nothing shows when mail isn't available.
|
|
329
|
+
const unreadTotal = inboxUnreadTotal(mail.mailboxFolders, mail.folderCounts.counts);
|
|
330
|
+
useUnreadTitle(unreadTotal, { enabled: inFrame, resetKey: pathname });
|
|
327
331
|
|
|
328
332
|
// Plugin rail entries that depend on the user (the meet plugin's personal room) are asked for once they are signed in.
|
|
329
333
|
const resolvedRail = useResolvedRailItems(pluginNav?.appRail, userUid);
|
|
@@ -332,7 +336,7 @@ export function AppChrome({
|
|
|
332
336
|
return <div className="min-h-screen" />;
|
|
333
337
|
}
|
|
334
338
|
|
|
335
|
-
const apps = appRailItems(pluginNav, resolvedRail);
|
|
339
|
+
const apps = appRailItems(pluginNav, resolvedRail).map((app) => (app.id === "mail" ? { ...app, badge: unreadTotal } : app));
|
|
336
340
|
// The header title: "settings" has no rail item, everything else is labelled by its own rail item.
|
|
337
341
|
const activeLabel = active === "settings" ? "Settings" : apps.find((app) => app.id === active)?.label;
|
|
338
342
|
// A custom header (`Branding.headerHtml`) replaces the app's own title bar and the icon at the top of the rail: it is the top of the app, and the
|
|
@@ -414,13 +418,13 @@ export function AppChrome({
|
|
|
414
418
|
{!customHeader && (
|
|
415
419
|
<RailIcon src={iconSrc} />
|
|
416
420
|
)}
|
|
417
|
-
{apps.map(({ id, href, label, icon: Icon }) => (
|
|
421
|
+
{apps.map(({ id, href, label, icon: Icon, badge }) => (
|
|
418
422
|
<a
|
|
419
423
|
key={id}
|
|
420
424
|
href={href}
|
|
421
|
-
aria-label={label}
|
|
425
|
+
aria-label={navItemLabel(label, badge)}
|
|
422
426
|
aria-current={id === active ? "page" : undefined}
|
|
423
|
-
title={label}
|
|
427
|
+
title={navItemLabel(label, badge)}
|
|
424
428
|
className={[
|
|
425
429
|
"w-10 h-10 flex items-center justify-center rounded-sm",
|
|
426
430
|
id === active
|
|
@@ -428,7 +432,10 @@ export function AppChrome({
|
|
|
428
432
|
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
429
433
|
].join(" ")}
|
|
430
434
|
>
|
|
431
|
-
<
|
|
435
|
+
<span className="relative inline-flex">
|
|
436
|
+
<Icon size={20} aria-hidden="true" />
|
|
437
|
+
<NavBadge count={badge} />
|
|
438
|
+
</span>
|
|
432
439
|
</a>
|
|
433
440
|
))}
|
|
434
441
|
</nav>
|