@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.
- package/package.json +5 -2
- package/src/calendar.css +4 -0
- package/src/components/agenda-composer.render.test.ts +196 -0
- package/src/components/agenda-composer.stories.tsx +225 -0
- package/src/components/agenda-composer.tsx +283 -0
- package/src/components/agenda-flow.render.test.ts +217 -0
- package/src/components/agenda-flow.stories.tsx +215 -0
- package/src/components/agenda-flow.tsx +887 -0
- package/src/components/agenda-panels.render.test.ts +252 -0
- package/src/components/agenda-panels.stories.tsx +207 -0
- package/src/components/agenda-panels.tsx +421 -0
- package/src/components/calendar-event-chip-content.tsx +102 -0
- package/src/components/calendar-event-chip.stories.tsx +5 -7
- package/src/components/calendar-event-chip.tsx +47 -47
- package/src/components/calendar-grid.render.test.ts +309 -0
- package/src/components/calendar-grid.stories.tsx +203 -0
- package/src/components/calendar-grid.tsx +370 -0
- package/src/components/calendar-types.ts +85 -0
- package/src/index.ts +82 -0
- package/src/lib/agenda-time.test.ts +539 -0
- package/src/lib/agenda-time.ts +505 -0
- package/src/lib/calendar-event-shell.ts +55 -0
- package/src/lib/calendar-slot-pick.test.ts +140 -0
- package/src/lib/calendar-slot-pick.ts +65 -0
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* element
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
}
|