@remit/ui 0.0.155 → 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,421 @@
1
+ /**
2
+ * The readings that sit beside the strip.
3
+ *
4
+ * A wide screen given to a single column of days wastes it as badly as a grid
5
+ * wastes a sparse week, so the width goes to the questions the list is good at:
6
+ * what is next, where the free time is, and where in the strip you currently
7
+ * are. None of them is a second copy of the list — each one answers something
8
+ * the rows cannot answer at a glance.
9
+ */
10
+
11
+ import type { LucideIcon } from "lucide-react";
12
+ import {
13
+ AlignJustify,
14
+ CalendarOff,
15
+ Clock,
16
+ LayoutList,
17
+ MoreHorizontal,
18
+ Radio,
19
+ Sun,
20
+ } from "lucide-react";
21
+ import { type ReactNode, useId, useMemo } from "react";
22
+ import {
23
+ addDays,
24
+ DAY_END_MINUTE,
25
+ DAY_START_MINUTE,
26
+ datesBetween,
27
+ type FreeStretch,
28
+ formatMinute,
29
+ formatShortDay,
30
+ formatSpan,
31
+ type NextUp,
32
+ shortMonthLabel,
33
+ } from "../lib/agenda-time.js";
34
+ import { calendarColorClasses } from "../lib/calendar-color.js";
35
+ import { cn } from "../lib/cn.js";
36
+ import type { AgendaDensity } from "./agenda-flow.js";
37
+ import { segmentClassName } from "./calendar-toolbar.js";
38
+ import type {
39
+ CalendarColorId,
40
+ CalendarDay,
41
+ CalendarDescriptor,
42
+ } from "./calendar-types.js";
43
+
44
+ /* ------------------------------------------------------------------ */
45
+ /* Density */
46
+ /* ------------------------------------------------------------------ */
47
+
48
+ const DENSITY_OPTIONS: {
49
+ value: AgendaDensity;
50
+ label: string;
51
+ Icon: LucideIcon;
52
+ }[] = [
53
+ { value: "dots", label: "Dots", Icon: MoreHorizontal },
54
+ { value: "pills", label: "Rows", Icon: AlignJustify },
55
+ { value: "detail", label: "Detail", Icon: LayoutList },
56
+ ];
57
+
58
+ export interface AgendaDensityControlProps {
59
+ value: AgendaDensity;
60
+ onChange: (density: AgendaDensity) => void;
61
+ /** Where the words do not fit — a phone's bottom bar. */
62
+ icons?: boolean;
63
+ touch?: boolean;
64
+ className?: string;
65
+ }
66
+
67
+ /**
68
+ * Three steps, not two. A list can be read as a month of coloured dots or as a
69
+ * stack of cards, and which one is right depends on the day and the person, so
70
+ * the control is on screen at every width rather than in a settings page.
71
+ */
72
+ export function AgendaDensityControl({
73
+ value,
74
+ onChange,
75
+ icons,
76
+ touch,
77
+ className,
78
+ }: AgendaDensityControlProps) {
79
+ const group = useId();
80
+ return (
81
+ <fieldset className={cn("inline-flex items-center gap-0.5", className)}>
82
+ <legend className="sr-only">Agenda density</legend>
83
+ {DENSITY_OPTIONS.map((option) => (
84
+ <label
85
+ key={option.value}
86
+ aria-label={option.label}
87
+ className={cn(
88
+ segmentClassName(value === option.value),
89
+ touch ? "min-h-11 flex-1 text-sm" : "h-7 text-xs",
90
+ )}
91
+ >
92
+ <input
93
+ type="radio"
94
+ name={group}
95
+ value={option.value}
96
+ checked={value === option.value}
97
+ onChange={() => onChange(option.value)}
98
+ className="sr-only"
99
+ />
100
+ {icons ? <option.Icon className="size-4" /> : option.label}
101
+ </label>
102
+ ))}
103
+ </fieldset>
104
+ );
105
+ }
106
+
107
+ /* ------------------------------------------------------------------ */
108
+ /* What's next */
109
+ /* ------------------------------------------------------------------ */
110
+
111
+ export interface NextUpCardProps {
112
+ nextUp: NextUp;
113
+ /** Whose hue each named event is drawn with. */
114
+ calendars: readonly CalendarDescriptor[];
115
+ today: string;
116
+ onSelectEvent: (eventId: string) => void;
117
+ onGoTo: (date: string) => void;
118
+ touch?: boolean;
119
+ className?: string;
120
+ }
121
+
122
+ /**
123
+ * The one question a grid answers badly. "What is next" out of a time grid
124
+ * means finding the now-line and reading downward past the empty rows; here it
125
+ * is a sentence, and the free time after it is part of the same sentence.
126
+ */
127
+ export function NextUpCard({
128
+ nextUp,
129
+ calendars,
130
+ today,
131
+ onSelectEvent,
132
+ onGoTo,
133
+ touch,
134
+ className,
135
+ }: NextUpCardProps) {
136
+ const { running, next, minutesUntilNext, after, free } = nextUp;
137
+ const colorOf = useMemo(() => {
138
+ const byId = new Map(
139
+ calendars.map((calendar) => [calendar.id, calendar.color]),
140
+ );
141
+ return (calendarId: string): CalendarColorId =>
142
+ byId.get(calendarId) ?? "cal-1";
143
+ }, [calendars]);
144
+
145
+ return (
146
+ <section
147
+ className={cn(
148
+ "flex flex-col gap-2 rounded-lg border border-line bg-surface-raised p-3",
149
+ className,
150
+ )}
151
+ >
152
+ {running.length > 0 && (
153
+ <div className="flex flex-col gap-1">
154
+ <Caption icon={<Radio className="size-3 text-danger" />}>Now</Caption>
155
+ {running.map((event) => (
156
+ <EventButton
157
+ key={event.id}
158
+ eventId={event.id}
159
+ title={event.title}
160
+ color={colorOf(event.calendarId)}
161
+ meta={`until ${event.end.slice(11, 16)}`}
162
+ onSelect={onSelectEvent}
163
+ touch={touch}
164
+ />
165
+ ))}
166
+ </div>
167
+ )}
168
+
169
+ <div className="flex flex-col gap-1">
170
+ <Caption icon={<Clock className="size-3" />}>
171
+ {next ? `Next · in ${formatSpan(minutesUntilNext)}` : "Next"}
172
+ </Caption>
173
+ {next ? (
174
+ <EventButton
175
+ eventId={next.id}
176
+ title={next.title}
177
+ color={colorOf(next.calendarId)}
178
+ meta={`${dayPrefix(next.start.slice(0, 10), today)}${next.start.slice(
179
+ 11,
180
+ 16,
181
+ )}${next.location === "" ? "" : ` · ${next.location}`}`}
182
+ onSelect={onSelectEvent}
183
+ touch={touch}
184
+ />
185
+ ) : (
186
+ <p className="text-sm text-fg-muted">Nothing else booked.</p>
187
+ )}
188
+ {after && (
189
+ <p className="truncate pl-1 text-2xs text-fg-subtle">
190
+ then {after.title} · {dayPrefix(after.start.slice(0, 10), today)}
191
+ {after.start.slice(11, 16)}
192
+ </p>
193
+ )}
194
+ </div>
195
+
196
+ {free && (
197
+ <button
198
+ type="button"
199
+ onClick={() => onGoTo(free.date)}
200
+ className={cn(
201
+ "flex items-center gap-2 rounded-md border border-dashed border-accent-2 bg-accent-2-soft/40 px-2 text-left text-accent-2 outline-none transition-colors hover:bg-accent-2-soft focus-visible:ring-2 focus-visible:ring-ring",
202
+ touch ? "min-h-11" : "min-h-8",
203
+ )}
204
+ >
205
+ <Sun className="size-3.5 shrink-0" />
206
+ <span className="text-xs font-medium">
207
+ {formatSpan(free.minutes)} free
208
+ </span>
209
+ <span className="text-2xs tabular-nums opacity-80">
210
+ {dayPrefix(free.date, today)}
211
+ {formatMinute(free.startMinute)} – {formatMinute(free.endMinute)}
212
+ </span>
213
+ </button>
214
+ )}
215
+ </section>
216
+ );
217
+ }
218
+
219
+ function Caption({ icon, children }: { icon: ReactNode; children: ReactNode }) {
220
+ return (
221
+ <h3 className="flex items-center gap-1.5 text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
222
+ {icon}
223
+ {children}
224
+ </h3>
225
+ );
226
+ }
227
+
228
+ function EventButton({
229
+ eventId,
230
+ title,
231
+ color,
232
+ meta,
233
+ onSelect,
234
+ touch,
235
+ }: {
236
+ eventId: string;
237
+ title: string;
238
+ color: CalendarColorId;
239
+ meta: string;
240
+ onSelect: (eventId: string) => void;
241
+ touch?: boolean;
242
+ }) {
243
+ const hue = calendarColorClasses(color);
244
+ return (
245
+ <button
246
+ type="button"
247
+ onClick={() => onSelect(eventId)}
248
+ className={cn(
249
+ "flex w-full items-center gap-2 rounded-md border-l-2 px-2 py-1 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
250
+ hue.soft,
251
+ hue.text,
252
+ hue.rail,
253
+ touch && "min-h-12",
254
+ )}
255
+ >
256
+ <span className="min-w-0 flex-1">
257
+ <span className="block truncate text-sm font-medium">{title}</span>
258
+ <span className="block truncate text-2xs opacity-80">{meta}</span>
259
+ </span>
260
+ </button>
261
+ );
262
+ }
263
+
264
+ function dayPrefix(date: string, today: string): string {
265
+ if (date === today) return "";
266
+ if (date === addDays(today, 1)) return "tomorrow · ";
267
+ return `${formatShortDay(date)} · `;
268
+ }
269
+
270
+ /* ------------------------------------------------------------------ */
271
+ /* Free time */
272
+ /* ------------------------------------------------------------------ */
273
+
274
+ export interface FreeTimeListProps {
275
+ stretches: FreeStretch[];
276
+ today: string;
277
+ onPick: (stretch: FreeStretch) => void;
278
+ touch?: boolean;
279
+ }
280
+
281
+ /** Empty time, listed like anything else that is on the calendar. */
282
+ export function FreeTimeList({
283
+ stretches,
284
+ today,
285
+ onPick,
286
+ touch,
287
+ }: FreeTimeListProps) {
288
+ return (
289
+ <section className="flex flex-col gap-1.5">
290
+ <Caption icon={<CalendarOff className="size-3" />}>Open time</Caption>
291
+ {stretches.length === 0 ? (
292
+ <p className="text-xs text-fg-subtle">
293
+ Nothing open in the days on screen.
294
+ </p>
295
+ ) : (
296
+ stretches.map((stretch) => (
297
+ <button
298
+ key={`${stretch.date}_${stretch.startMinute}`}
299
+ type="button"
300
+ onClick={() => onPick(stretch)}
301
+ className={cn(
302
+ "flex items-center gap-2 rounded-md border border-line px-2 text-left outline-none transition-colors hover:border-accent hover:text-accent focus-visible:ring-2 focus-visible:ring-ring",
303
+ touch ? "min-h-11" : "min-h-8",
304
+ )}
305
+ >
306
+ <span className="text-xs font-medium text-fg">
307
+ {formatSpan(stretch.minutes)}
308
+ </span>
309
+ <span className="min-w-0 flex-1 truncate text-2xs text-fg-subtle">
310
+ {stretch.date === today ? "today" : formatShortDay(stretch.date)}{" "}
311
+ · {formatMinute(stretch.startMinute)} –{" "}
312
+ {formatMinute(stretch.endMinute)}
313
+ </span>
314
+ </button>
315
+ ))
316
+ )}
317
+ </section>
318
+ );
319
+ }
320
+
321
+ /* ------------------------------------------------------------------ */
322
+ /* Where you are in the strip */
323
+ /* ------------------------------------------------------------------ */
324
+
325
+ export interface PositionMapProps {
326
+ /** The month to draw, and the one after it. */
327
+ anchorDate: string;
328
+ visibleDate: string;
329
+ today: string;
330
+ dayOf: (date: string) => CalendarDay;
331
+ onGoTo: (date: string) => void;
332
+ className?: string;
333
+ }
334
+
335
+ /**
336
+ * Two months as a heat strip: how full each day is, and where the strip is
337
+ * currently parked. It is a scrollbar with meaning — the empty days are as
338
+ * legible as the full ones, which is the whole argument in miniature.
339
+ */
340
+ export function PositionMap({
341
+ anchorDate,
342
+ visibleDate,
343
+ today,
344
+ dayOf,
345
+ onGoTo,
346
+ className,
347
+ }: PositionMapProps) {
348
+ const months = [
349
+ monthStart(anchorDate),
350
+ monthStart(addDays(monthEnd(anchorDate), 1)),
351
+ ];
352
+ const span = DAY_END_MINUTE - DAY_START_MINUTE;
353
+
354
+ return (
355
+ <div className={cn("flex flex-col gap-3", className)}>
356
+ {months.map((first) => (
357
+ <section key={first} className="flex flex-col gap-1">
358
+ <h3 className="px-row-inset text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
359
+ {shortMonthLabel(first)}
360
+ </h3>
361
+ <div className="grid grid-cols-7 gap-0.5 px-row-inset">
362
+ {leadingBlanks(first).map((key) => (
363
+ <span key={key} />
364
+ ))}
365
+ {datesBetween(first, monthEnd(first)).map((date) => {
366
+ const day = dayOf(date);
367
+ const fill = Math.min(1, day.busyMinutes / span);
368
+ return (
369
+ <button
370
+ key={date}
371
+ type="button"
372
+ onClick={() => onGoTo(date)}
373
+ className={cn(
374
+ "relative flex h-6 items-center justify-center rounded-sm text-2xs tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
375
+ date === visibleDate
376
+ ? "bg-surface-raised font-semibold text-fg ring-1 ring-line-strong"
377
+ : "text-fg-muted hover:bg-surface-sunken",
378
+ date === today && "font-semibold text-accent",
379
+ )}
380
+ >
381
+ {Number(date.slice(8))}
382
+ {fill > 0 && (
383
+ <span
384
+ aria-hidden
385
+ className="absolute inset-x-1 bottom-0.5 h-0.5 rounded-full bg-fg-subtle"
386
+ style={{ opacity: 0.35 + fill * 0.65 }}
387
+ />
388
+ )}
389
+ {day.conflicts.length > 0 && (
390
+ <span
391
+ aria-hidden
392
+ className="absolute right-0.5 top-0.5 size-1 rounded-full bg-warning"
393
+ />
394
+ )}
395
+ </button>
396
+ );
397
+ })}
398
+ </div>
399
+ </section>
400
+ ))}
401
+ </div>
402
+ );
403
+ }
404
+
405
+ function monthStart(date: string): string {
406
+ return `${date.slice(0, 8)}01`;
407
+ }
408
+
409
+ function monthEnd(date: string): string {
410
+ const [year, month] = date.split("-").map(Number);
411
+ const last = new Date(Date.UTC(year, month, 0));
412
+ return last.toISOString().slice(0, 10);
413
+ }
414
+
415
+ /** Monday-first padding cells before the first of the month. */
416
+ function leadingBlanks(first: string): string[] {
417
+ const [year, month, day] = first.split("-").map(Number);
418
+ const weekday = new Date(year, month - 1, day).getDay();
419
+ const count = (weekday + 6) % 7;
420
+ return Array.from({ length: count }, (_, index) => `blank_${first}_${index}`);
421
+ }
@@ -0,0 +1,102 @@
1
+ import { Globe, Mail, Repeat } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+ import type { RsvpState, ZoneCertainty } from "./calendar-types.js";
5
+
6
+ export interface CalendarEventChipContentProps {
7
+ title: string;
8
+ /** Rendered ahead of the title; empty for an all-day entry. */
9
+ timeText: string;
10
+ /**
11
+ * `row` is the horizontal pill of the all-day band and the month grid.
12
+ * `column` is the block that fills its slot in a time grid.
13
+ */
14
+ layout: "row" | "column";
15
+ rsvp: RsvpState;
16
+ /** Carries the mail mark that says this event has a thread behind it. */
17
+ hasThread: boolean;
18
+ isRecurring: boolean;
19
+ zoneCertainty: ZoneCertainty;
20
+ /** Rendered at the far end of the title line — a length, a count. */
21
+ trailing?: ReactNode;
22
+ /** A second line under the title: where it is, who is coming, whose calendar. */
23
+ detail?: ReactNode;
24
+ }
25
+
26
+ /**
27
+ * What is written inside one event: the time, the title, the marks, and
28
+ * whatever the surface adds beside them. Every surface that draws an event
29
+ * draws this — `CalendarEventChip` inside its own button, `CalendarGrid` inside
30
+ * the element its engine built — so an event reads the same wherever it lands.
31
+ */
32
+ export function CalendarEventChipContent({
33
+ title,
34
+ timeText,
35
+ layout,
36
+ rsvp,
37
+ hasThread,
38
+ isRecurring,
39
+ zoneCertainty,
40
+ trailing,
41
+ detail,
42
+ }: CalendarEventChipContentProps) {
43
+ const isColumn = layout === "column";
44
+ const zoneAmbiguous = zoneCertainty === "ambiguous";
45
+ const stacked = !isColumn && detail !== undefined;
46
+
47
+ const head = (
48
+ <span
49
+ className={cn(
50
+ "flex min-w-0 items-center gap-1",
51
+ isColumn && "w-full",
52
+ rsvp === "declined" && "line-through",
53
+ )}
54
+ >
55
+ {timeText !== "" && (
56
+ <span className="shrink-0 tabular-nums opacity-80">{timeText}</span>
57
+ )}
58
+ <span className="truncate font-medium">{title}</span>
59
+ </span>
60
+ );
61
+
62
+ const marks = (isRecurring || hasThread || zoneAmbiguous) && (
63
+ <span
64
+ className={cn(
65
+ "flex shrink-0 items-center gap-1",
66
+ isColumn && "mt-0.5",
67
+ !isColumn && trailing === undefined && "ml-auto",
68
+ )}
69
+ >
70
+ {isRecurring && <Repeat className="size-2.5" aria-label="Repeats" />}
71
+ {hasThread && <Mail className="size-2.5" aria-label="From mail" />}
72
+ {zoneAmbiguous && (
73
+ <Globe className="size-2.5 text-warning" aria-label="Unclear zone" />
74
+ )}
75
+ </span>
76
+ );
77
+
78
+ const tail = trailing !== undefined && (
79
+ <span className={cn("shrink-0 opacity-80", !isColumn && "ml-auto")}>
80
+ {trailing}
81
+ </span>
82
+ );
83
+
84
+ return (
85
+ <>
86
+ {stacked ? (
87
+ <span className="flex min-w-0 items-center gap-1.5">
88
+ {head}
89
+ {marks}
90
+ {tail}
91
+ </span>
92
+ ) : (
93
+ <>
94
+ {head}
95
+ {marks}
96
+ {tail}
97
+ </>
98
+ )}
99
+ {detail}
100
+ </>
101
+ );
102
+ }
@@ -7,13 +7,11 @@ import { calendarColorIds } from "./calendar-types.js";
7
7
  * picker, a day column we draw ourselves. Colour says which calendar; shape and
8
8
  * mark say everything else, so an event never depends on hue alone to be read.
9
9
  *
10
- * Option A's grid is FullCalendar, which renders the element itself and accepts
11
- * only a class string and the content inside it, so that one surface restates
12
- * this shell rather than mounting the component. The two are held to the same
13
- * values hue, the dashed box for a provisional event, the dimming for a
14
- * declined one, the mark size. What the grid cannot take from here is the
15
- * element: no `aria-pressed`, no focus ring of ours, and a column too short for
16
- * the second line this chip puts its marks on.
10
+ * `CalendarGrid` renders the element itself and accepts only a class string and
11
+ * the content inside it, so that one surface does not mount this component. It
12
+ * is still the same chip: both draw the box from `calendarEventBodyClasses` and
13
+ * what is written in it from `CalendarEventChipContent`. What the grid cannot
14
+ * take from here is the element no `aria-pressed`, no leading slot.
17
15
  */
18
16
  const meta: Meta<typeof CalendarEventChip> = {
19
17
  title: "Calendar/Event chip",
@@ -1,7 +1,8 @@
1
- import { Globe, Mail, Repeat } from "lucide-react";
2
- import { calendarColorClasses } from "../lib/calendar-color.js";
1
+ import type { ReactNode } from "react";
2
+ import { calendarEventBodyClasses } from "../lib/calendar-event-shell.js";
3
3
  import { cn } from "../lib/cn.js";
4
4
  import type { Density } from "./app-shell-types.js";
5
+ import { CalendarEventChipContent } from "./calendar-event-chip-content.js";
5
6
  import type {
6
7
  CalendarColorId,
7
8
  RsvpState,
@@ -27,6 +28,16 @@ export interface CalendarEventChipProps {
27
28
  zoneCertainty: ZoneCertainty;
28
29
  selected: boolean;
29
30
  onClick?: () => void;
31
+ /**
32
+ * Rendered inside the control but outside the coloured body, ahead of it —
33
+ * the agenda's time gutter, which has to line up down a whole day and so
34
+ * cannot sit inside a block whose width follows the title.
35
+ */
36
+ leading?: ReactNode;
37
+ /** Rendered at the far end of the title line — a length, a count. */
38
+ trailing?: ReactNode;
39
+ /** A second line under the title: where it is, who is coming, whose calendar. */
40
+ detail?: ReactNode;
30
41
  }
31
42
 
32
43
  /**
@@ -35,11 +46,11 @@ export interface CalendarEventChipProps {
35
46
  * carries; the RSVP and the zone ride on shape and mark instead, so a declined
36
47
  * event in a green calendar still reads as green and as declined.
37
48
  *
38
- * A FullCalendar grid is the one surface that does not use itthe library
39
- * renders the event's element itself and takes only a class string and the
40
- * content inside, so `calendar-grid.tsx` restates this shell there. Every value
41
- * that can be shared is the same on both sides; what cannot cross is the
42
- * element itself, and with it `aria-pressed` and the focus ring.
49
+ * `CalendarGrid` is the one surface that does not render this component its
50
+ * engine builds the event's element itself. It draws the same event out of the
51
+ * same two pieces: `calendarEventBodyClasses` for the box and
52
+ * `CalendarEventChipContent` for what is written in it. What cannot cross is
53
+ * the element, and with it `aria-pressed` and the leading slot.
43
54
  */
44
55
  export function CalendarEventChip({
45
56
  title,
@@ -54,12 +65,11 @@ export function CalendarEventChip({
54
65
  zoneCertainty,
55
66
  selected,
56
67
  onClick,
68
+ leading,
69
+ trailing,
70
+ detail,
57
71
  }: CalendarEventChipProps) {
58
- const hue = calendarColorClasses(color);
59
72
  const isColumn = layout === "column";
60
- const isCompact = density === "compact";
61
- const declined = rsvp === "declined";
62
- const provisional = rsvp === "tentative" || status === "tentative";
63
73
 
64
74
  return (
65
75
  <button
@@ -67,50 +77,40 @@ export function CalendarEventChip({
67
77
  onClick={onClick}
68
78
  aria-pressed={selected}
69
79
  className={cn(
70
- "group flex w-full min-w-0 overflow-hidden text-left outline-none transition-colors",
80
+ "group flex w-full min-w-0 text-left outline-none transition-colors",
71
81
  "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-surface",
72
- hue.soft,
73
- hue.text,
74
- isColumn
75
- ? "h-full flex-col rounded-sm border-l-2 px-1.5 py-0.5"
76
- : "items-center gap-1.5 rounded-sm border-l-2 px-1.5 py-0.5",
77
- hue.rail,
78
- provisional && "border-y border-r border-dashed",
79
- provisional && hue.border,
80
- declined && "opacity-60",
81
- selected && "ring-2 ring-ring",
82
- isCompact ? "text-2xs" : "text-xs",
82
+ isColumn ? "h-full" : "items-start",
83
+ leading !== undefined && "gap-2",
83
84
  )}
84
85
  >
86
+ {leading}
85
87
  <span
86
88
  className={cn(
87
- "flex min-w-0 items-center gap-1",
88
- isColumn && "w-full",
89
- declined && "line-through",
89
+ calendarEventBodyClasses({
90
+ color,
91
+ layout,
92
+ density,
93
+ rsvp,
94
+ status,
95
+ selected,
96
+ stacked: detail !== undefined,
97
+ }),
98
+ "flex-1",
99
+ isColumn && "h-full",
90
100
  )}
91
101
  >
92
- {timeText !== "" && (
93
- <span className="shrink-0 tabular-nums opacity-80">{timeText}</span>
94
- )}
95
- <span className="truncate font-medium">{title}</span>
102
+ <CalendarEventChipContent
103
+ title={title}
104
+ timeText={timeText}
105
+ layout={layout}
106
+ rsvp={rsvp}
107
+ hasThread={hasThread}
108
+ isRecurring={isRecurring}
109
+ zoneCertainty={zoneCertainty}
110
+ trailing={trailing}
111
+ detail={detail}
112
+ />
96
113
  </span>
97
- {(hasThread || isRecurring || zoneCertainty === "ambiguous") && (
98
- <span
99
- className={cn(
100
- "flex shrink-0 items-center gap-1",
101
- isColumn ? "mt-0.5" : "ml-auto",
102
- )}
103
- >
104
- {isRecurring && <Repeat className="size-2.5" aria-label="Repeats" />}
105
- {hasThread && <Mail className="size-2.5" aria-label="From mail" />}
106
- {zoneCertainty === "ambiguous" && (
107
- <Globe
108
- className="size-2.5 text-warning"
109
- aria-label="Unclear zone"
110
- />
111
- )}
112
- </span>
113
- )}
114
114
  </button>
115
115
  );
116
116
  }