@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.
- package/package.json +4 -2
- package/src/calendar.css +4 -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 +34 -83
- 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/index.ts +18 -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,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
|
+
}
|