@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,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules the grid owns, read off the markup it produces: which day an event
|
|
3
|
+
* lands on, which band it lands in, which day is today, and what a week with
|
|
4
|
+
* nothing in it says. The engine's pixel geometry — how far along a column an
|
|
5
|
+
* overlap sits — is measured in a browser and belongs to a story, not here.
|
|
6
|
+
*/
|
|
7
|
+
import "@remit/test-dom";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import { createElement } from "react";
|
|
11
|
+
import { renderToString } from "react-dom/server";
|
|
12
|
+
import { CalendarGrid, type CalendarGridProps } from "./calendar-grid.js";
|
|
13
|
+
import type { CalendarEventData } from "./calendar-types.js";
|
|
14
|
+
|
|
15
|
+
const TIME_ZONE = "Europe/Amsterdam";
|
|
16
|
+
const TODAY = "2026-06-10";
|
|
17
|
+
const TOMORROW = "2026-06-11";
|
|
18
|
+
const NOW = `${TODAY}T09:30:00+02:00`;
|
|
19
|
+
|
|
20
|
+
const WORK = "cal_work";
|
|
21
|
+
const HOME = "cal_home";
|
|
22
|
+
|
|
23
|
+
const template: CalendarEventData = {
|
|
24
|
+
id: "",
|
|
25
|
+
calendarId: WORK,
|
|
26
|
+
title: "",
|
|
27
|
+
start: "",
|
|
28
|
+
end: "",
|
|
29
|
+
allDay: false,
|
|
30
|
+
location: "",
|
|
31
|
+
notes: "",
|
|
32
|
+
attendees: [],
|
|
33
|
+
myRsvp: "accepted",
|
|
34
|
+
threadId: "",
|
|
35
|
+
threadSubject: "",
|
|
36
|
+
timeZone: TIME_ZONE,
|
|
37
|
+
zoneCertainty: "explicit",
|
|
38
|
+
recurrenceRule: "",
|
|
39
|
+
seriesId: "",
|
|
40
|
+
seriesException: false,
|
|
41
|
+
status: "confirmed",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const anEvent = (over: Partial<CalendarEventData>): CalendarEventData => ({
|
|
45
|
+
...template,
|
|
46
|
+
...over,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const roadmap = anEvent({
|
|
50
|
+
id: "roadmap",
|
|
51
|
+
title: "Roadmap review",
|
|
52
|
+
start: `${TODAY}T10:00:00+02:00`,
|
|
53
|
+
end: `${TODAY}T11:00:00+02:00`,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const dentist = anEvent({
|
|
57
|
+
id: "dentist",
|
|
58
|
+
calendarId: HOME,
|
|
59
|
+
title: "Dentist",
|
|
60
|
+
start: `${TODAY}T10:30:00+02:00`,
|
|
61
|
+
end: `${TODAY}T11:30:00+02:00`,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const handover = anEvent({
|
|
65
|
+
id: "handover",
|
|
66
|
+
title: "Handover",
|
|
67
|
+
start: `${TOMORROW}T09:00:00+02:00`,
|
|
68
|
+
end: `${TOMORROW}T09:30:00+02:00`,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const conference = anEvent({
|
|
72
|
+
id: "conference",
|
|
73
|
+
title: "Conference",
|
|
74
|
+
allDay: true,
|
|
75
|
+
start: TOMORROW,
|
|
76
|
+
end: "2026-06-12",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const base: CalendarGridProps = {
|
|
80
|
+
view: "week",
|
|
81
|
+
date: TODAY,
|
|
82
|
+
events: [],
|
|
83
|
+
colorByCalendarId: { [WORK]: "cal-3", [HOME]: "cal-5" },
|
|
84
|
+
density: "comfortable",
|
|
85
|
+
selectedEventId: "",
|
|
86
|
+
timeZone: TIME_ZONE,
|
|
87
|
+
now: NOW,
|
|
88
|
+
onSelectEvent: () => undefined,
|
|
89
|
+
onPickSlot: () => undefined,
|
|
90
|
+
onRangeChange: () => undefined,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
interface Chip {
|
|
94
|
+
title: string;
|
|
95
|
+
time: string;
|
|
96
|
+
/** The day column the chip was drawn in. */
|
|
97
|
+
date: string;
|
|
98
|
+
/** A block filling its slot, as opposed to the all-day band's pill. */
|
|
99
|
+
timed: boolean;
|
|
100
|
+
tabbable: boolean;
|
|
101
|
+
classes: string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The engine schedules browser timers while it renders. Nothing mounts here, so
|
|
106
|
+
* nothing ever clears them and the suite would sit on a live event loop after
|
|
107
|
+
* its last assertion. Take them back: a timer with no calendar left to tick for
|
|
108
|
+
* is not work.
|
|
109
|
+
*/
|
|
110
|
+
function grid(over: Partial<CalendarGridProps> = {}): HTMLElement {
|
|
111
|
+
const scheduled: ReturnType<typeof setTimeout>[] = [];
|
|
112
|
+
const native = globalThis.setTimeout;
|
|
113
|
+
globalThis.setTimeout = ((...args: Parameters<typeof setTimeout>) => {
|
|
114
|
+
const handle = native(...args);
|
|
115
|
+
scheduled.push(handle);
|
|
116
|
+
return handle;
|
|
117
|
+
}) as typeof globalThis.setTimeout;
|
|
118
|
+
const root = document.createElement("div");
|
|
119
|
+
try {
|
|
120
|
+
root.innerHTML = renderToString(
|
|
121
|
+
createElement(CalendarGrid, { ...base, ...over }),
|
|
122
|
+
);
|
|
123
|
+
} finally {
|
|
124
|
+
globalThis.setTimeout = native;
|
|
125
|
+
for (const handle of scheduled) clearTimeout(handle);
|
|
126
|
+
}
|
|
127
|
+
return root;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function chips(root: HTMLElement): Chip[] {
|
|
131
|
+
return Array.from(root.querySelectorAll<HTMLElement>("[role=button]"))
|
|
132
|
+
.filter((el) => el.querySelector(".font-medium") !== null)
|
|
133
|
+
.map((el) => ({
|
|
134
|
+
title: el.querySelector(".font-medium")?.textContent ?? "",
|
|
135
|
+
time: el.querySelector(".tabular-nums")?.textContent ?? "",
|
|
136
|
+
date: el.closest<HTMLElement>("[data-date]")?.dataset.date ?? "",
|
|
137
|
+
timed: !el.classList.contains("my-px"),
|
|
138
|
+
tabbable: el.getAttribute("tabindex") === "0",
|
|
139
|
+
classes: Array.from(el.classList),
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function chip(root: HTMLElement, title: string): Chip {
|
|
144
|
+
const found = chips(root).find((candidate) => candidate.title === title);
|
|
145
|
+
assert.ok(found, `no chip drawn for ${title}`);
|
|
146
|
+
return found;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe("CalendarGrid placement", () => {
|
|
150
|
+
it("draws a timed event in its own day's column, at its own start", () => {
|
|
151
|
+
const drawn = chip(grid({ events: [roadmap, handover] }), "Roadmap review");
|
|
152
|
+
assert.equal(drawn.date, TODAY);
|
|
153
|
+
assert.equal(drawn.time, "10:00");
|
|
154
|
+
assert.equal(drawn.timed, true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("sorts each event onto its own day rather than the range's first", () => {
|
|
158
|
+
const root = grid({ events: [roadmap, handover] });
|
|
159
|
+
assert.equal(chip(root, "Handover").date, TOMORROW);
|
|
160
|
+
assert.equal(chip(root, "Handover").time, "09:00");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("names the start only, so a column is never a range of digits", () => {
|
|
164
|
+
assert.equal(
|
|
165
|
+
chip(grid({ events: [roadmap] }), "Roadmap review").time,
|
|
166
|
+
"10:00",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("keeps both sides of an overlap in the day, each in its own hue", () => {
|
|
171
|
+
const root = grid({ events: [roadmap, dentist] });
|
|
172
|
+
const drawn = chips(root);
|
|
173
|
+
assert.equal(drawn.length, 2);
|
|
174
|
+
assert.ok(drawn.every((one) => one.date === TODAY));
|
|
175
|
+
assert.ok(chip(root, "Roadmap review").classes.includes("bg-cal-3-soft"));
|
|
176
|
+
assert.ok(chip(root, "Dentist").classes.includes("bg-cal-5-soft"));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("falls back to the first hue for a calendar nobody coloured", () => {
|
|
180
|
+
const root = grid({ events: [roadmap], colorByCalendarId: {} });
|
|
181
|
+
assert.ok(chip(root, "Roadmap review").classes.includes("bg-cal-1-soft"));
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("CalendarGrid all-day band", () => {
|
|
186
|
+
it("gives the week a band of its own, named", () => {
|
|
187
|
+
assert.match(grid().innerHTML, /All day/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("draws an all-day event as a pill in the band, not a block in a slot", () => {
|
|
191
|
+
const drawn = chip(grid({ events: [conference] }), "Conference");
|
|
192
|
+
assert.equal(drawn.timed, false);
|
|
193
|
+
assert.equal(drawn.date, TOMORROW);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("gives an all-day event no clock, because it has none", () => {
|
|
197
|
+
assert.equal(chip(grid({ events: [conference] }), "Conference").time, "");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("CalendarGrid today", () => {
|
|
202
|
+
it("marks the day the clock says, and only that day", () => {
|
|
203
|
+
const root = grid();
|
|
204
|
+
assert.ok(
|
|
205
|
+
root.querySelector(`[data-date="${TODAY}"][aria-current="date"]`),
|
|
206
|
+
);
|
|
207
|
+
assert.equal(
|
|
208
|
+
root.querySelector(`[data-date="${TOMORROW}"][aria-current="date"]`),
|
|
209
|
+
null,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("tints today's lane with the accent rather than a colour of its own", () => {
|
|
214
|
+
const lane = grid().querySelector<HTMLElement>(
|
|
215
|
+
`[data-date="${TODAY}"][aria-current="date"]`,
|
|
216
|
+
);
|
|
217
|
+
assert.ok(lane);
|
|
218
|
+
assert.ok(
|
|
219
|
+
Array.from(
|
|
220
|
+
grid().querySelectorAll<HTMLElement>(`[data-date="${TODAY}"]`),
|
|
221
|
+
).some((cell) => cell.classList.contains("bg-accent-soft/40")),
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("moves the marker with the clock it was handed", () => {
|
|
226
|
+
const root = grid({ now: `${TOMORROW}T09:30:00+02:00` });
|
|
227
|
+
assert.ok(
|
|
228
|
+
root.querySelector(`[data-date="${TOMORROW}"][aria-current="date"]`),
|
|
229
|
+
);
|
|
230
|
+
assert.equal(
|
|
231
|
+
root.querySelector(`[data-date="${TODAY}"][aria-current="date"]`),
|
|
232
|
+
null,
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("CalendarGrid empty", () => {
|
|
238
|
+
it("still draws the week when nothing is booked in it", () => {
|
|
239
|
+
const root = grid({ events: [] });
|
|
240
|
+
assert.equal(chips(root).length, 0);
|
|
241
|
+
assert.ok(root.querySelector(`[data-date="${TODAY}"]`));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("says so in the agenda, where an empty list is otherwise a blank pane", () => {
|
|
245
|
+
const status = grid({ view: "agenda", events: [] }).querySelector(
|
|
246
|
+
"[role=status]",
|
|
247
|
+
);
|
|
248
|
+
assert.equal(status?.textContent, "Nothing scheduled");
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("CalendarGrid selection and state", () => {
|
|
253
|
+
it("rings the selected event and leaves the rest alone", () => {
|
|
254
|
+
const root = grid({
|
|
255
|
+
events: [roadmap, dentist],
|
|
256
|
+
selectedEventId: "dentist",
|
|
257
|
+
});
|
|
258
|
+
assert.ok(chip(root, "Dentist").classes.includes("ring-2"));
|
|
259
|
+
assert.ok(!chip(root, "Roadmap review").classes.includes("ring-2"));
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("dims a declined event and strikes its title, the way the chip does", () => {
|
|
263
|
+
const root = grid({
|
|
264
|
+
events: [anEvent({ ...roadmap, myRsvp: "declined" })],
|
|
265
|
+
});
|
|
266
|
+
assert.ok(chip(root, "Roadmap review").classes.includes("opacity-60"));
|
|
267
|
+
assert.match(root.innerHTML, /line-through/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("dashes a tentative event rather than recolouring it", () => {
|
|
271
|
+
const root = grid({
|
|
272
|
+
events: [anEvent({ ...roadmap, status: "tentative" })],
|
|
273
|
+
});
|
|
274
|
+
const drawn = chip(root, "Roadmap review");
|
|
275
|
+
assert.ok(drawn.classes.includes("border-dashed"));
|
|
276
|
+
assert.ok(drawn.classes.includes("bg-cal-3-soft"));
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("marks an event that came out of mail", () => {
|
|
280
|
+
const root = grid({ events: [anEvent({ ...roadmap, threadId: "th_1" })] });
|
|
281
|
+
assert.match(root.innerHTML, /From mail/);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("flags an event whose zone the source never settled", () => {
|
|
285
|
+
const root = grid({
|
|
286
|
+
events: [anEvent({ ...roadmap, zoneCertainty: "ambiguous" })],
|
|
287
|
+
});
|
|
288
|
+
assert.match(root.innerHTML, /Unclear zone/);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("CalendarGrid reach", () => {
|
|
293
|
+
it("puts every event in the tab order and answers Enter on it", () => {
|
|
294
|
+
const drawn = chips(grid({ events: [roadmap, dentist, conference] }));
|
|
295
|
+
assert.equal(drawn.length, 3);
|
|
296
|
+
assert.ok(drawn.every((one) => one.tabbable));
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("CalendarGrid density", () => {
|
|
301
|
+
it("drops the time off a chip once the slots are halved", () => {
|
|
302
|
+
const drawn = chip(
|
|
303
|
+
grid({ events: [roadmap], density: "compact" }),
|
|
304
|
+
"Roadmap review",
|
|
305
|
+
);
|
|
306
|
+
assert.equal(drawn.time, "");
|
|
307
|
+
assert.equal(drawn.title, "Roadmap review");
|
|
308
|
+
});
|
|
309
|
+
});
|
|
@@ -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
|
+
};
|