@remit/ui 0.0.156 → 0.0.157

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
+ }
package/src/index.ts CHANGED
@@ -147,6 +147,14 @@ export {
147
147
  CalendarEventChip,
148
148
  type CalendarEventChipProps,
149
149
  } from "./components/calendar-event-chip.js";
150
+ export {
151
+ CalendarEventChipContent,
152
+ type CalendarEventChipContentProps,
153
+ } from "./components/calendar-event-chip-content.js";
154
+ export {
155
+ CalendarGrid,
156
+ type CalendarGridProps,
157
+ } from "./components/calendar-grid.js";
150
158
  export {
151
159
  CalendarList,
152
160
  type CalendarListProps,
@@ -881,6 +889,16 @@ export {
881
889
  type CalendarColorClasses,
882
890
  calendarColorClasses,
883
891
  } from "./lib/calendar-color.js";
892
+ export {
893
+ type CalendarEventShell,
894
+ calendarEventBodyClasses,
895
+ } from "./lib/calendar-event-shell.js";
896
+ export {
897
+ DRAFT_MINUTES,
898
+ isDraggedSelection,
899
+ pointPick,
900
+ rangePick,
901
+ } from "./lib/calendar-slot-pick.js";
884
902
  export {
885
903
  buildCidResolver,
886
904
  type CidResolvableBodyPart,
@@ -0,0 +1,55 @@
1
+ import type { Density } from "../components/app-shell-types.js";
2
+ import type {
3
+ CalendarColorId,
4
+ RsvpState,
5
+ } from "../components/calendar-types.js";
6
+ import { calendarColorClasses } from "./calendar-color.js";
7
+ import { cn } from "./cn.js";
8
+
9
+ export interface CalendarEventShell {
10
+ color: CalendarColorId;
11
+ /**
12
+ * `row` is the horizontal pill of the all-day band and the month grid.
13
+ * `column` is the block that fills its slot in a time grid.
14
+ */
15
+ layout: "row" | "column";
16
+ density: Density;
17
+ /** The reader's own reply. A declined event dims; it never recolours. */
18
+ rsvp: RsvpState;
19
+ status: "confirmed" | "tentative";
20
+ selected: boolean;
21
+ /** Two lines rather than one: a title with something under it. */
22
+ stacked: boolean;
23
+ }
24
+
25
+ /**
26
+ * The coloured body of one event: the hue, the RSVP, the selection, and the box
27
+ * they sit in.
28
+ *
29
+ * `CalendarEventChip` draws this body inside a button of its own.
30
+ * `CalendarGrid` cannot — its engine builds the event's element and takes a
31
+ * class string for it — so there the engine's element *is* the body, styled
32
+ * from here. Neither surface restates the other.
33
+ *
34
+ * How the body is sized is left to the surface: the chip stretches it beside a
35
+ * leading slot, and the grid's engine has already positioned it.
36
+ */
37
+ export function calendarEventBodyClasses(shell: CalendarEventShell): string {
38
+ const hue = calendarColorClasses(shell.color);
39
+ const provisional =
40
+ shell.rsvp === "tentative" || shell.status === "tentative";
41
+ return cn(
42
+ "flex min-w-0 overflow-hidden rounded-sm border-l-2 px-1.5 py-0.5",
43
+ hue.soft,
44
+ hue.text,
45
+ hue.rail,
46
+ shell.layout === "column" || shell.stacked
47
+ ? "flex-col"
48
+ : "items-center gap-1.5",
49
+ provisional && "border-y border-r border-dashed",
50
+ provisional && hue.border,
51
+ shell.rsvp === "declined" && "opacity-60",
52
+ shell.selected && "ring-2 ring-ring",
53
+ shell.density === "compact" ? "text-2xs" : "text-xs",
54
+ );
55
+ }