@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.157",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src"
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
".": "./src/index.ts",
|
|
10
10
|
"./rich-text": "./src/rich-text.ts",
|
|
11
11
|
"./spellcheck-worker": "./src/components/rich-text-spellcheck-worker-provider.ts",
|
|
12
|
-
"./tokens.css": "./src/tokens.css"
|
|
12
|
+
"./tokens.css": "./src/tokens.css",
|
|
13
|
+
"./calendar.css": "./src/calendar.css"
|
|
13
14
|
},
|
|
14
15
|
"scripts": {
|
|
15
16
|
"test": "npm run test:typecheck && npm run test:run",
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
"@fontsource-variable/geist": "^5",
|
|
21
22
|
"@fontsource-variable/hanken-grotesk": "^5",
|
|
22
23
|
"@fontsource-variable/inter": "^5",
|
|
24
|
+
"@fullcalendar/react": "^7.0.2",
|
|
23
25
|
"@lexical/code-core": "0.49.0",
|
|
24
26
|
"@lexical/html": "0.49.0",
|
|
25
27
|
"@lexical/link": "0.49.0",
|
package/src/calendar.css
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/* The structural sheet `CalendarGrid`'s engine needs — positioning and nothing
|
|
2
|
+
else; it ships no theme. Every colour, border and size the grid draws comes
|
|
3
|
+
from tokens.css instead. An app that renders the grid imports this beside it. */
|
|
4
|
+
@import "@fullcalendar/react/skeleton.css";
|
|
@@ -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,8 +1,8 @@
|
|
|
1
|
-
import { Globe, Mail, Repeat } from "lucide-react";
|
|
2
1
|
import type { ReactNode } from "react";
|
|
3
|
-
import {
|
|
2
|
+
import { calendarEventBodyClasses } from "../lib/calendar-event-shell.js";
|
|
4
3
|
import { cn } from "../lib/cn.js";
|
|
5
4
|
import type { Density } from "./app-shell-types.js";
|
|
5
|
+
import { CalendarEventChipContent } from "./calendar-event-chip-content.js";
|
|
6
6
|
import type {
|
|
7
7
|
CalendarColorId,
|
|
8
8
|
RsvpState,
|
|
@@ -46,11 +46,11 @@ export interface CalendarEventChipProps {
|
|
|
46
46
|
* carries; the RSVP and the zone ride on shape and mark instead, so a declined
|
|
47
47
|
* event in a green calendar still reads as green and as declined.
|
|
48
48
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* 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.
|
|
54
54
|
*/
|
|
55
55
|
export function CalendarEventChip({
|
|
56
56
|
title,
|
|
@@ -69,82 +69,7 @@ export function CalendarEventChip({
|
|
|
69
69
|
trailing,
|
|
70
70
|
detail,
|
|
71
71
|
}: CalendarEventChipProps) {
|
|
72
|
-
const hue = calendarColorClasses(color);
|
|
73
72
|
const isColumn = layout === "column";
|
|
74
|
-
const isCompact = density === "compact";
|
|
75
|
-
const declined = rsvp === "declined";
|
|
76
|
-
const provisional = rsvp === "tentative" || status === "tentative";
|
|
77
|
-
const stacked = isColumn || detail !== undefined;
|
|
78
|
-
|
|
79
|
-
const head = (
|
|
80
|
-
<span
|
|
81
|
-
className={cn(
|
|
82
|
-
"flex min-w-0 items-center gap-1",
|
|
83
|
-
isColumn && "w-full",
|
|
84
|
-
declined && "line-through",
|
|
85
|
-
)}
|
|
86
|
-
>
|
|
87
|
-
{timeText !== "" && (
|
|
88
|
-
<span className="shrink-0 tabular-nums opacity-80">{timeText}</span>
|
|
89
|
-
)}
|
|
90
|
-
<span className="truncate font-medium">{title}</span>
|
|
91
|
-
</span>
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
const marks = (hasThread || isRecurring || zoneCertainty === "ambiguous") && (
|
|
95
|
-
<span
|
|
96
|
-
className={cn(
|
|
97
|
-
"flex shrink-0 items-center gap-1",
|
|
98
|
-
isColumn && "mt-0.5",
|
|
99
|
-
!isColumn && trailing === undefined && "ml-auto",
|
|
100
|
-
)}
|
|
101
|
-
>
|
|
102
|
-
{isRecurring && <Repeat className="size-2.5" aria-label="Repeats" />}
|
|
103
|
-
{hasThread && <Mail className="size-2.5" aria-label="From mail" />}
|
|
104
|
-
{zoneCertainty === "ambiguous" && (
|
|
105
|
-
<Globe className="size-2.5 text-warning" aria-label="Unclear zone" />
|
|
106
|
-
)}
|
|
107
|
-
</span>
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
const tail = trailing !== undefined && (
|
|
111
|
-
<span className={cn("shrink-0 opacity-80", !isColumn && "ml-auto")}>
|
|
112
|
-
{trailing}
|
|
113
|
-
</span>
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
const body = (
|
|
117
|
-
<span
|
|
118
|
-
className={cn(
|
|
119
|
-
"flex min-w-0 flex-1 overflow-hidden rounded-sm border-l-2 px-1.5 py-0.5",
|
|
120
|
-
hue.soft,
|
|
121
|
-
hue.text,
|
|
122
|
-
hue.rail,
|
|
123
|
-
isColumn && "h-full",
|
|
124
|
-
stacked ? "flex-col" : "items-center gap-1.5",
|
|
125
|
-
provisional && "border-y border-r border-dashed",
|
|
126
|
-
provisional && hue.border,
|
|
127
|
-
declined && "opacity-60",
|
|
128
|
-
selected && "ring-2 ring-ring",
|
|
129
|
-
isCompact ? "text-2xs" : "text-xs",
|
|
130
|
-
)}
|
|
131
|
-
>
|
|
132
|
-
{isColumn || detail === undefined ? (
|
|
133
|
-
<>
|
|
134
|
-
{head}
|
|
135
|
-
{marks}
|
|
136
|
-
{tail}
|
|
137
|
-
</>
|
|
138
|
-
) : (
|
|
139
|
-
<span className="flex min-w-0 items-center gap-1.5">
|
|
140
|
-
{head}
|
|
141
|
-
{marks}
|
|
142
|
-
{tail}
|
|
143
|
-
</span>
|
|
144
|
-
)}
|
|
145
|
-
{detail}
|
|
146
|
-
</span>
|
|
147
|
-
);
|
|
148
73
|
|
|
149
74
|
return (
|
|
150
75
|
<button
|
|
@@ -159,7 +84,33 @@ export function CalendarEventChip({
|
|
|
159
84
|
)}
|
|
160
85
|
>
|
|
161
86
|
{leading}
|
|
162
|
-
|
|
87
|
+
<span
|
|
88
|
+
className={cn(
|
|
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",
|
|
100
|
+
)}
|
|
101
|
+
>
|
|
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
|
+
/>
|
|
113
|
+
</span>
|
|
163
114
|
</button>
|
|
164
115
|
);
|
|
165
116
|
}
|
|
@@ -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
|
+
});
|