@remit/ui 0.0.156 → 0.0.158

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.
@@ -0,0 +1,203 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { CalendarGrid } from "./calendar-grid.js";
4
+ import type { CalendarColorId, CalendarEventData } from "./calendar-types.js";
5
+
6
+ /**
7
+ * The grid, at every zoom it offers. It holds nothing: the events, the hues,
8
+ * the day it is centred on and the clock it calls now all arrive as props, so a
9
+ * story can put the marker on a Wednesday and keep it there.
10
+ *
11
+ * These are the states worth looking at rather than asserting: how far along a
12
+ * column an overlap sits, and how a week reads once a day is full, are measured
13
+ * in a browser and cannot be read off the markup.
14
+ */
15
+ const TIME_ZONE = "Europe/Amsterdam";
16
+ const TODAY = "2026-06-10";
17
+ const NOW = `${TODAY}T09:30:00+02:00`;
18
+
19
+ const WORK = "cal_work";
20
+ const HOME = "cal_home";
21
+ const TEAM = "cal_team";
22
+
23
+ const colorByCalendarId: Record<string, CalendarColorId> = {
24
+ [WORK]: "cal-1",
25
+ [HOME]: "cal-4",
26
+ [TEAM]: "cal-6",
27
+ };
28
+
29
+ const template: CalendarEventData = {
30
+ id: "",
31
+ calendarId: WORK,
32
+ title: "",
33
+ start: "",
34
+ end: "",
35
+ allDay: false,
36
+ location: "",
37
+ notes: "",
38
+ attendees: [],
39
+ myRsvp: "accepted",
40
+ threadId: "",
41
+ threadSubject: "",
42
+ timeZone: TIME_ZONE,
43
+ zoneCertainty: "explicit",
44
+ recurrenceRule: "",
45
+ seriesId: "",
46
+ seriesException: false,
47
+ status: "confirmed",
48
+ };
49
+
50
+ const at = (
51
+ id: string,
52
+ title: string,
53
+ day: string,
54
+ from: string,
55
+ to: string,
56
+ over: Partial<CalendarEventData> = {},
57
+ ): CalendarEventData => ({
58
+ ...template,
59
+ ...over,
60
+ id,
61
+ title,
62
+ start: `2026-06-${day}T${from}:00+02:00`,
63
+ end: `2026-06-${day}T${to}:00+02:00`,
64
+ });
65
+
66
+ const week: CalendarEventData[] = [
67
+ at("standup-mon", "Standup", "08", "09:15", "09:30", {
68
+ recurrenceRule: "Every weekday, 09:15",
69
+ seriesId: "ser_standup",
70
+ }),
71
+ at("supplier", "Supplier call", "08", "11:00", "12:00", {
72
+ calendarId: TEAM,
73
+ threadId: "th_supplier",
74
+ zoneCertainty: "ambiguous",
75
+ }),
76
+ at("standup-tue", "Standup", "09", "09:15", "09:30", {
77
+ recurrenceRule: "Every weekday, 09:15",
78
+ seriesId: "ser_standup",
79
+ }),
80
+ at("review", "Design review", "09", "14:00", "15:30", { calendarId: TEAM }),
81
+ at("standup-wed", "Standup", "10", "09:15", "09:30", {
82
+ recurrenceRule: "Every weekday, 09:15",
83
+ seriesId: "ser_standup",
84
+ }),
85
+ at("roadmap", "Roadmap review", "10", "10:00", "11:00"),
86
+ at("dentist", "Dentist", "10", "10:30", "11:30", { calendarId: HOME }),
87
+ at("retro", "Retro", "10", "10:45", "11:15", {
88
+ calendarId: TEAM,
89
+ status: "tentative",
90
+ }),
91
+ at("lunch", "Lunch with Ada", "10", "12:30", "13:30", { calendarId: HOME }),
92
+ at("board", "Board prep", "11", "09:00", "10:30"),
93
+ at("skipped", "All-hands", "11", "16:00", "17:00", { myRsvp: "declined" }),
94
+ at("focus", "Focus block", "12", "09:00", "12:00", { calendarId: HOME }),
95
+ {
96
+ ...template,
97
+ id: "offsite",
98
+ calendarId: TEAM,
99
+ title: "Offsite",
100
+ allDay: true,
101
+ start: "2026-06-11",
102
+ end: "2026-06-13",
103
+ },
104
+ ];
105
+
106
+ const meta: Meta<typeof CalendarGrid> = {
107
+ title: "Calendar/Grid",
108
+ component: CalendarGrid,
109
+ parameters: { layout: "fullscreen" },
110
+ decorators: [
111
+ (Story) => (
112
+ <div className="h-screen bg-surface p-4">
113
+ <Story />
114
+ </div>
115
+ ),
116
+ ],
117
+ args: {
118
+ view: "week",
119
+ date: TODAY,
120
+ events: week,
121
+ colorByCalendarId,
122
+ density: "comfortable",
123
+ selectedEventId: "",
124
+ timeZone: TIME_ZONE,
125
+ now: NOW,
126
+ onSelectEvent: () => undefined,
127
+ onPickSlot: () => undefined,
128
+ onRangeChange: () => undefined,
129
+ },
130
+ };
131
+ export default meta;
132
+ type Story = StoryObj<typeof CalendarGrid>;
133
+
134
+ export const Week: Story = {};
135
+
136
+ /** Three events running into each other on the same morning. */
137
+ export const Overlapping: Story = {
138
+ args: {
139
+ date: TODAY,
140
+ events: week.filter((event) => event.start.startsWith("2026-06-10")),
141
+ },
142
+ };
143
+
144
+ export const AllDayBand: Story = {
145
+ args: { events: week.filter((event) => event.allDay) },
146
+ };
147
+
148
+ export const Day: Story = { args: { view: "day" } };
149
+
150
+ export const Month: Story = { args: { view: "month" } };
151
+
152
+ export const Year: Story = { args: { view: "year" } };
153
+
154
+ export const Agenda: Story = { args: { view: "agenda" } };
155
+
156
+ /** Halved slots, and the time comes off the chips that no longer fit it. */
157
+ export const Compact: Story = { args: { density: "compact" } };
158
+
159
+ export const Selected: Story = { args: { selectedEventId: "roadmap" } };
160
+
161
+ export const Empty: Story = { args: { events: [] } };
162
+
163
+ /** Nothing to list is a sentence, not a blank pane. */
164
+ export const AgendaEmpty: Story = { args: { view: "agenda", events: [] } };
165
+
166
+ /**
167
+ * The clock is a prop, so the marker follows it: the same week, read on the
168
+ * Friday instead.
169
+ */
170
+ export const AnotherDayIsToday: Story = {
171
+ args: { now: "2026-06-12T09:30:00+02:00" },
172
+ };
173
+
174
+ /** Clicking an event selects it; dragging a range reports the slot picked. */
175
+ export const Interactive: Story = {
176
+ render: (args) => {
177
+ const [selectedEventId, setSelected] = useState("");
178
+ const [picked, setPicked] = useState("nothing yet");
179
+ const [title, setTitle] = useState("");
180
+ return (
181
+ <div className="flex h-full flex-col gap-2">
182
+ <p className="text-xs text-fg-muted">
183
+ {title} — selected: {selectedEventId || "none"} — picked: {picked}
184
+ </p>
185
+ <div className="min-h-0 flex-1">
186
+ <CalendarGrid
187
+ {...args}
188
+ selectedEventId={selectedEventId}
189
+ onSelectEvent={setSelected}
190
+ onPickSlot={(pick) =>
191
+ setPicked(
192
+ pick.allDay
193
+ ? `${pick.date}, all day`
194
+ : `${pick.date} ${pick.startTime}–${pick.endTime}`,
195
+ )
196
+ }
197
+ onRangeChange={setTitle}
198
+ />
199
+ </div>
200
+ </div>
201
+ );
202
+ },
203
+ };
@@ -0,0 +1,370 @@
1
+ import type {
2
+ CalendarRef,
3
+ EventDisplayInfo,
4
+ EventInput,
5
+ } from "@fullcalendar/react";
6
+ import FullCalendar from "@fullcalendar/react";
7
+ import dayGridPlugin from "@fullcalendar/react/daygrid";
8
+ import interactionPlugin from "@fullcalendar/react/interaction";
9
+ import listPlugin from "@fullcalendar/react/list";
10
+ import multiMonthPlugin from "@fullcalendar/react/multimonth";
11
+ import timeGridPlugin from "@fullcalendar/react/timegrid";
12
+ import { useEffect, useMemo, useRef } from "react";
13
+ import { calendarEventBodyClasses } from "../lib/calendar-event-shell.js";
14
+ import {
15
+ isDraggedSelection,
16
+ pointPick,
17
+ rangePick,
18
+ } from "../lib/calendar-slot-pick.js";
19
+ import { cn } from "../lib/cn.js";
20
+ import type { Density } from "./app-shell-types.js";
21
+ import { CalendarEventChipContent } from "./calendar-event-chip-content.js";
22
+ import type {
23
+ CalendarColorId,
24
+ CalendarEventData,
25
+ CalendarSlotPick,
26
+ CalendarViewId,
27
+ RsvpState,
28
+ ZoneCertainty,
29
+ } from "./calendar-types.js";
30
+
31
+ /**
32
+ * One continuous strip of time, at whichever zoom the view names. Every pixel
33
+ * comes from the kit's tokens: the calendar engine ships a structural skeleton
34
+ * sheet and no theme, and it is styled entirely through its per-element
35
+ * `*Class` props, so no rule here overrides one of the library's own.
36
+ *
37
+ * The component is presentational. It holds no events of its own, reads no
38
+ * clock of its own, and every gesture leaves through a callback.
39
+ */
40
+
41
+ const PLUGINS = [
42
+ dayGridPlugin,
43
+ timeGridPlugin,
44
+ listPlugin,
45
+ multiMonthPlugin,
46
+ interactionPlugin,
47
+ ];
48
+
49
+ const FC_VIEW: Record<CalendarViewId, string> = {
50
+ year: "multiMonthYear",
51
+ month: "dayGridMonth",
52
+ week: "timeGridWeek",
53
+ day: "timeGridDay",
54
+ agenda: "listWeek",
55
+ };
56
+
57
+ /* A week or a day names the date in its header. A month grid repeats the same
58
+ seven weekdays down the page, so the header names the weekday and the cells
59
+ own the dates; a year has room for one letter. */
60
+ const DAY_HEADER_FORMAT: Record<
61
+ CalendarViewId,
62
+ { weekday: "short" | "narrow"; day?: "numeric" }
63
+ > = {
64
+ year: { weekday: "narrow" },
65
+ month: { weekday: "short" },
66
+ week: { weekday: "short", day: "numeric" },
67
+ day: { weekday: "short", day: "numeric" },
68
+ agenda: { weekday: "short", day: "numeric" },
69
+ };
70
+
71
+ /** Views that draw every event as a horizontal pill rather than a block. */
72
+ const ROW_VIEWS = new Set<CalendarViewId>(["year", "month", "agenda"]);
73
+
74
+ /**
75
+ * Density is a real change in how much of a day fits on screen, not a padding
76
+ * tweak: a tighter setting halves the slot height and drops the time off short
77
+ * chips.
78
+ */
79
+ const DENSITY: Record<
80
+ Density,
81
+ { slotMinutes: number; slotMinHeight: number; eventShortHeight: number }
82
+ > = {
83
+ comfortable: { slotMinutes: 30, slotMinHeight: 26, eventShortHeight: 34 },
84
+ compact: { slotMinutes: 60, slotMinHeight: 15, eventShortHeight: 22 },
85
+ };
86
+
87
+ const FALLBACK_COLOR: CalendarColorId = "cal-1";
88
+
89
+ export interface CalendarGridProps {
90
+ view: CalendarViewId;
91
+ /** The day the view is centred on; changing it moves the grid. */
92
+ date: string;
93
+ events: CalendarEventData[];
94
+ /** Which calendar owns which hue. Anything missing falls back to cal-1. */
95
+ colorByCalendarId: Record<string, CalendarColorId>;
96
+ density: Density;
97
+ selectedEventId: string;
98
+ /** IANA zone the grid's clock runs on, and the zone a pick is read in. */
99
+ timeZone: string;
100
+ /** The instant the grid calls now: the today marker and the now line read it. */
101
+ now: string;
102
+ onSelectEvent: (eventId: string) => void;
103
+ onPickSlot: (pick: CalendarSlotPick) => void;
104
+ /** The range title the grid computed, e.g. "8 – 14 Jun 2026". */
105
+ onRangeChange: (title: string) => void;
106
+ className?: string;
107
+ }
108
+
109
+ /**
110
+ * What drawing one event needs, keyed by id. The engine renders the element and
111
+ * hands the callback its own `EventApi`, so the data is looked up rather than
112
+ * smuggled through `extendedProps` and cast back out.
113
+ */
114
+ interface GridEvent {
115
+ color: CalendarColorId;
116
+ rsvp: RsvpState;
117
+ status: "confirmed" | "tentative";
118
+ hasThread: boolean;
119
+ isRecurring: boolean;
120
+ zoneCertainty: ZoneCertainty;
121
+ }
122
+
123
+ /** The placeholder the engine drags under the pointer, which is not one of ours. */
124
+ const MIRROR_EVENT: GridEvent = {
125
+ color: FALLBACK_COLOR,
126
+ rsvp: "accepted",
127
+ status: "confirmed",
128
+ hasThread: false,
129
+ isRecurring: false,
130
+ zoneCertainty: "local",
131
+ };
132
+
133
+ function toInput(event: CalendarEventData): EventInput {
134
+ return {
135
+ id: event.id,
136
+ title: event.title,
137
+ start: event.start,
138
+ end: event.end,
139
+ allDay: event.allDay,
140
+ };
141
+ }
142
+
143
+ export function CalendarGrid({
144
+ view,
145
+ date,
146
+ events,
147
+ colorByCalendarId,
148
+ density,
149
+ selectedEventId,
150
+ timeZone,
151
+ now,
152
+ onSelectEvent,
153
+ onPickSlot,
154
+ onRangeChange,
155
+ className,
156
+ }: CalendarGridProps) {
157
+ const calendarRef = useRef<CalendarRef>(null);
158
+
159
+ useEffect(() => {
160
+ calendarRef.current?.getApi().changeView(FC_VIEW[view]);
161
+ }, [view]);
162
+
163
+ useEffect(() => {
164
+ calendarRef.current?.getApi().gotoDate(date);
165
+ }, [date]);
166
+
167
+ const eventInputs = useMemo(() => events.map(toInput), [events]);
168
+
169
+ const byId = useMemo(
170
+ () =>
171
+ new Map<string, GridEvent>(
172
+ events.map((event) => [
173
+ event.id,
174
+ {
175
+ color: colorByCalendarId[event.calendarId] ?? FALLBACK_COLOR,
176
+ rsvp: event.myRsvp,
177
+ status: event.status,
178
+ hasThread: event.threadId !== "",
179
+ isRecurring: event.recurrenceRule !== "",
180
+ zoneCertainty: event.zoneCertainty,
181
+ },
182
+ ]),
183
+ ),
184
+ [events, colorByCalendarId],
185
+ );
186
+
187
+ const lookup = useMemo(
188
+ () => (id: string) => byId.get(id) ?? MIRROR_EVENT,
189
+ [byId],
190
+ );
191
+
192
+ const isRowEvent = useMemo(
193
+ () => (info: EventDisplayInfo) => info.event.allDay || ROW_VIEWS.has(view),
194
+ [view],
195
+ );
196
+
197
+ /* The event element the engine built, dressed as the body of a
198
+ `CalendarEventChip`: same box, same hue, same states, one definition. A
199
+ grid cell is tight whatever the density does to the slots around it. */
200
+ const eventBody = useMemo(
201
+ () => (info: EventDisplayInfo) => {
202
+ const event = lookup(info.event.id);
203
+ const isRow = isRowEvent(info);
204
+ return cn(
205
+ calendarEventBodyClasses({
206
+ color: event.color,
207
+ layout: isRow ? "row" : "column",
208
+ density: "compact",
209
+ rsvp: event.rsvp,
210
+ status: event.status,
211
+ selected: info.event.id === selectedEventId,
212
+ stacked: false,
213
+ }),
214
+ "cursor-pointer outline-none transition-colors",
215
+ "focus-visible:ring-2 focus-visible:ring-ring",
216
+ /* Tighter than a chip drawn on its own: a grid cell is smaller than
217
+ anywhere else an event lands, and an all-day pill has to fit a band
218
+ one line high. */
219
+ isRow ? "my-px px-1 py-0" : "px-1 py-0.5",
220
+ );
221
+ },
222
+ [lookup, isRowEvent, selectedEventId],
223
+ );
224
+
225
+ const slot = DENSITY[density];
226
+ const isTight = density === "compact";
227
+
228
+ return (
229
+ <div className={cn("h-full min-h-0 w-full text-fg", className)}>
230
+ <FullCalendar
231
+ ref={calendarRef}
232
+ plugins={PLUGINS}
233
+ initialView={FC_VIEW[view]}
234
+ initialDate={date}
235
+ now={now}
236
+ timeZone={timeZone}
237
+ height="100%"
238
+ headerToolbar={false}
239
+ firstDay={1}
240
+ nowIndicator
241
+ selectable
242
+ selectMirror
243
+ editable={false}
244
+ /* Events take focus and answer Enter, the way the toolbar's controls
245
+ do — the grid is not reachable by pointer only. */
246
+ eventInteractive
247
+ expandRows
248
+ allDayText="All day"
249
+ dayMaxEvents={isTight ? 2 : 3}
250
+ eventMaxStack={3}
251
+ moreLinkClick="popover"
252
+ moreLinkText={(num) => `+${num}`}
253
+ slotMinTime="07:00:00"
254
+ slotMaxTime="23:00:00"
255
+ scrollTime="08:30:00"
256
+ scrollTimeReset={false}
257
+ slotDuration={{ minutes: slot.slotMinutes }}
258
+ slotMinHeight={slot.slotMinHeight}
259
+ eventShortHeight={slot.eventShortHeight}
260
+ displayEventEnd={false}
261
+ eventTimeFormat={{ hour: "2-digit", minute: "2-digit", hour12: false }}
262
+ slotHeaderFormat={{ hour: "2-digit", minute: "2-digit", hour12: false }}
263
+ dayHeaderFormat={DAY_HEADER_FORMAT[view]}
264
+ events={eventInputs}
265
+ eventClick={(info) => onSelectEvent(info.event.id)}
266
+ /* One gesture, two readings, and each owns a shape the other cannot
267
+ reach. A point is `dateClick`'s: a finger has to hold a full second
268
+ before the library will call it a selection, and a selection cancels
269
+ the scroll the finger might have meant, so a tap has nowhere else to
270
+ land. A dragged range is `select`'s.
271
+
272
+ Acting on both would report a point twice, and the second report
273
+ arrives late — the library finishes a click a task after the pointer
274
+ is up — by which time the pane the first report opened has rebuilt
275
+ the grid and left the second reading a detached cell. */
276
+ dateClick={(info) => onPickSlot(pointPick(info.dateStr, info.allDay))}
277
+ select={(info) => {
278
+ if (
279
+ !isDraggedSelection(
280
+ info.startStr,
281
+ info.endStr,
282
+ info.allDay,
283
+ slot.slotMinutes,
284
+ )
285
+ ) {
286
+ /* The point reading is answering this one. Left alone the
287
+ selection would stay lit under the draft, a slot wide where
288
+ the draft is an hour, until a click somewhere else. */
289
+ info.view.calendar.unselect();
290
+ return;
291
+ }
292
+ onPickSlot(rangePick(info.startStr, info.endStr, info.allDay));
293
+ }}
294
+ datesSet={(info) => onRangeChange(info.view.title)}
295
+ /* ---- chrome, all of it ours ---- */
296
+ viewClass="bg-surface"
297
+ tableClass="border-line"
298
+ tableHeaderClass="bg-surface"
299
+ fillerClass="border-line"
300
+ dayHeaderRowClass="h-pane-header"
301
+ dayHeaderClass={(info) =>
302
+ cn(
303
+ "border-b border-line bg-surface",
304
+ info.isToday ? "text-accent" : "text-fg-subtle",
305
+ )
306
+ }
307
+ dayHeaderInnerClass="px-1 py-1 text-2xs font-semibold uppercase tracking-wider"
308
+ dayHeaderDividerClass="border-b border-line"
309
+ dayCellClass={(info) =>
310
+ cn("border-line", info.isOther && "bg-surface-sunken")
311
+ }
312
+ dayLaneClass={(info) => cn(info.isToday && "bg-accent-soft/40")}
313
+ dayCellTopInnerClass={(info) =>
314
+ cn(
315
+ "px-1 py-0.5 text-2xs tabular-nums",
316
+ info.isToday ? "font-semibold text-accent" : "text-fg-muted",
317
+ )
318
+ }
319
+ dayRowClass="border-line"
320
+ slotLaneClass={(info) =>
321
+ cn("border-line", info.isMinor && "border-dashed")
322
+ }
323
+ slotHeaderClass="border-line"
324
+ slotHeaderInnerClass="pr-1.5 text-2xs tabular-nums text-fg-subtle"
325
+ slotHeaderDividerClass="border-r border-line"
326
+ allDayHeaderClass="border-line"
327
+ allDayHeaderInnerClass="px-1 text-2xs uppercase tracking-wider text-fg-subtle"
328
+ allDayDividerClass="border-b border-line-strong"
329
+ nonBusinessHoursClass="bg-surface-sunken"
330
+ highlightClass="bg-accent-soft"
331
+ nowIndicatorLineClass="border-t border-danger"
332
+ nowIndicatorDotClass="bg-danger"
333
+ moreLinkClass="rounded-sm bg-surface-sunken text-2xs font-semibold text-fg-muted hover:bg-line hover:text-fg"
334
+ moreLinkInnerClass="px-1 py-0.5"
335
+ popoverClass="rounded-lg border border-line bg-surface-raised shadow-xl shadow-black/20"
336
+ popoverCloseClass="text-fg-subtle hover:text-fg"
337
+ listDaysClass="bg-surface"
338
+ listDayClass="border-line"
339
+ listDayHeaderClass="border-y border-line bg-surface-sunken"
340
+ listDayHeaderInnerClass="flex h-section-row items-center px-row-inset text-2xs font-semibold uppercase tracking-wider text-fg-subtle"
341
+ listDayBodyClass="border-line"
342
+ noEventsClass="p-10 text-center"
343
+ noEventsInnerClass="text-sm text-fg-muted"
344
+ noEventsContent="Nothing scheduled"
345
+ singleMonthClass="p-2"
346
+ singleMonthHeaderClass="pb-1"
347
+ singleMonthHeaderInnerClass="text-xs font-semibold text-fg"
348
+ eventClass={eventBody}
349
+ listItemEventClass={eventBody}
350
+ eventContent={(info) => {
351
+ const event = lookup(info.event.id);
352
+ /* A column an hour wide has room for a time or a title, never
353
+ both — the title is the one worth keeping. */
354
+ const showTime = info.timeText !== "" && !isTight && !info.isNarrow;
355
+ return (
356
+ <CalendarEventChipContent
357
+ title={info.event.title}
358
+ timeText={showTime ? info.timeText : ""}
359
+ layout={isRowEvent(info) ? "row" : "column"}
360
+ rsvp={event.rsvp}
361
+ hasThread={event.hasThread}
362
+ isRecurring={event.isRecurring}
363
+ zoneCertainty={event.zoneCertainty}
364
+ />
365
+ );
366
+ }}
367
+ />
368
+ </div>
369
+ );
370
+ }
@@ -124,3 +124,34 @@ describe("EventDetail", () => {
124
124
  assert.match(html, /Recent mail · Priya Natarajan/);
125
125
  });
126
126
  });
127
+
128
+ /**
129
+ * A host that cannot write yet — the calendar before its API lands — passes
130
+ * neither action. A button that leads nowhere is worse than no button, so the
131
+ * header has to come back without them rather than with two that do nothing.
132
+ */
133
+ describe("EventDetail with nowhere to write", () => {
134
+ const readOnly = renderToString(
135
+ createElement(EventDetail, {
136
+ event,
137
+ calendar,
138
+ whenText: "Wednesday 10 June · 10:00 – 11:30",
139
+ onClose: () => undefined,
140
+ }),
141
+ );
142
+
143
+ it("offers neither Edit nor Delete", () => {
144
+ assert.doesNotMatch(readOnly, />Edit</);
145
+ assert.doesNotMatch(readOnly, />Delete</);
146
+ });
147
+
148
+ it("still names the event, its calendar and when it is", () => {
149
+ assert.match(readOnly, /Q3 roadmap review/);
150
+ assert.match(readOnly, /Northwind/);
151
+ assert.match(readOnly, /10:00 – 11:30/);
152
+ });
153
+
154
+ it("keeps the way out of the pane", () => {
155
+ assert.match(readOnly, /aria-label="Close event"/);
156
+ });
157
+ });
@@ -176,3 +176,20 @@ export const AmbiguousZone: Story = {
176
176
  />
177
177
  ),
178
178
  };
179
+
180
+ /**
181
+ * The state the app itself renders while the calendar has no API to write
182
+ * through: the facts, the way back to the thread, and no control that would
183
+ * lead nowhere.
184
+ */
185
+ export const NoActions: Story = {
186
+ render: () => (
187
+ <EventDetail
188
+ event={base}
189
+ calendar={calendar}
190
+ whenText="Wednesday 10 June · 10:00 – 11:30"
191
+ onClose={() => {}}
192
+ onOpenThread={() => {}}
193
+ />
194
+ ),
195
+ };
@@ -23,8 +23,13 @@ export interface EventDetailProps {
23
23
  calendar: CalendarDescriptor;
24
24
  /** Already formatted by the caller — the component owns no clock. */
25
25
  whenText: string;
26
- onEdit: () => void;
27
- onDelete: () => void;
26
+ /**
27
+ * Absent where the surface cannot write yet. A control that leads nowhere is
28
+ * worse than no control, so the header renders each one only when it has
29
+ * somewhere to go.
30
+ */
31
+ onEdit?: () => void;
32
+ onDelete?: () => void;
28
33
  /** Absent when the event was never born from a mail. */
29
34
  onOpenThread?: () => void;
30
35
  /** Puts the pane back to whatever it shows with nothing selected. */
@@ -171,23 +176,27 @@ export function EventDetail({
171
176
  <span className="min-w-0 flex-1 truncate text-xs text-fg-muted">
172
177
  {calendar.name} · {calendar.accountLabel}
173
178
  </span>
174
- <Button
175
- variant="ghost"
176
- size="sm"
177
- icon={<Pencil className="size-3.5" />}
178
- onClick={onEdit}
179
- >
180
- Edit
181
- </Button>
182
- <Button
183
- variant="ghost"
184
- size="sm"
185
- icon={<Trash2 className="size-3.5" />}
186
- onClick={onDelete}
187
- className="text-danger"
188
- >
189
- Delete
190
- </Button>
179
+ {onEdit && (
180
+ <Button
181
+ variant="ghost"
182
+ size="sm"
183
+ icon={<Pencil className="size-3.5" />}
184
+ onClick={onEdit}
185
+ >
186
+ Edit
187
+ </Button>
188
+ )}
189
+ {onDelete && (
190
+ <Button
191
+ variant="ghost"
192
+ size="sm"
193
+ icon={<Trash2 className="size-3.5" />}
194
+ onClick={onDelete}
195
+ className="text-danger"
196
+ >
197
+ Delete
198
+ </Button>
199
+ )}
191
200
  {onClose && (
192
201
  <button
193
202
  type="button"