@tonycodes-ui/mortar 0.0.0-stage → 0.11.0
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/AGENTS.md +10 -0
- package/CHANGELOG.md +276 -0
- package/CONTRIBUTING.md +48 -0
- package/DESIGN.md +51 -0
- package/LICENSE +21 -0
- package/README.md +155 -2
- package/TESTING.md +83 -0
- package/baseline-policy.json +16 -0
- package/bin/mortar.mjs +258 -0
- package/catalog.json +9703 -0
- package/cli/audit.mjs +943 -0
- package/cli/data.mjs +176 -0
- package/cli/mcp.mjs +216 -0
- package/cli/migrations.mjs +128 -0
- package/cli/packet.mjs +267 -0
- package/cli/search.mjs +246 -0
- package/cli/tools.mjs +229 -0
- package/design-principles.json +30 -0
- package/dist/agent-data.json +1 -0
- package/dist/application.d.ts +340 -0
- package/dist/application.js +240 -0
- package/dist/auth.d.ts +121 -0
- package/dist/auth.js +97 -0
- package/dist/brand.d.ts +69 -0
- package/dist/brand.js +266 -0
- package/dist/calendar.d.ts +75 -0
- package/dist/calendar.js +310 -0
- package/dist/choice.d.ts +36 -0
- package/dist/choice.js +36 -0
- package/dist/composer.d.ts +28 -0
- package/dist/composer.js +47 -0
- package/dist/display.d.ts +136 -0
- package/dist/display.js +75 -0
- package/dist/docs.d.ts +145 -0
- package/dist/docs.js +141 -0
- package/dist/ecommerce.d.ts +261 -0
- package/dist/ecommerce.js +177 -0
- package/dist/feedback.d.ts +80 -0
- package/dist/feedback.js +98 -0
- package/dist/foundation.d.ts +42 -0
- package/dist/foundation.js +121 -0
- package/dist/heading.d.ts +5 -0
- package/dist/heading.js +9 -0
- package/dist/icons.d.ts +50 -0
- package/dist/icons.js +70 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +17 -0
- package/dist/inspection.d.ts +24 -0
- package/dist/inspection.js +14 -0
- package/dist/internal.d.ts +492 -0
- package/dist/internal.js +273 -0
- package/dist/layout.d.ts +107 -0
- package/dist/layout.js +66 -0
- package/dist/marketing-sections.d.ts +161 -0
- package/dist/marketing-sections.js +58 -0
- package/dist/marketing.d.ts +254 -0
- package/dist/marketing.js +150 -0
- package/dist/navigation.d.ts +35 -0
- package/dist/navigation.js +44 -0
- package/dist/primitives.d.ts +258 -0
- package/dist/primitives.js +244 -0
- package/dist/showcase.d.ts +29 -0
- package/dist/showcase.js +10 -0
- package/dist/workspace.d.ts +80 -0
- package/dist/workspace.js +48 -0
- package/docs/agents.md +127 -0
- package/docs/auth-flows.md +71 -0
- package/docs/migrating-to-0.6.md +94 -0
- package/docs/theming.md +170 -0
- package/package.json +94 -4
- package/skills/mortar/SKILL.md +36 -0
- package/src/application.css +1487 -0
- package/src/application.tsx +1413 -0
- package/src/auth.tsx +452 -0
- package/src/brand.ts +335 -0
- package/src/calendar.css +468 -0
- package/src/calendar.tsx +868 -0
- package/src/choice.tsx +240 -0
- package/src/composer.tsx +158 -0
- package/src/data.css +690 -0
- package/src/display.tsx +452 -0
- package/src/docs.css +704 -0
- package/src/docs.tsx +737 -0
- package/src/ecommerce.css +810 -0
- package/src/ecommerce.tsx +1033 -0
- package/src/feedback.tsx +388 -0
- package/src/foundation.css +1867 -0
- package/src/foundation.tsx +227 -0
- package/src/heading.ts +13 -0
- package/src/icons.tsx +237 -0
- package/src/index.ts +230 -0
- package/src/inspection.tsx +91 -0
- package/src/internal.tsx +614 -0
- package/src/layout.tsx +342 -0
- package/src/marketing-sections.tsx +621 -0
- package/src/marketing.css +2400 -0
- package/src/marketing.tsx +1156 -0
- package/src/navigation.tsx +118 -0
- package/src/overlays.css +350 -0
- package/src/primitives.tsx +1135 -0
- package/src/showcase.tsx +120 -0
- package/src/tokens.css +449 -0
- package/src/workspace.tsx +333 -0
- package/styles.css +56 -0
package/src/calendar.tsx
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import {
|
|
3
|
+
type CSSProperties,
|
|
4
|
+
useEffect,
|
|
5
|
+
useId,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type KeyboardEvent,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { SegmentedControl } from "./inspection.js";
|
|
13
|
+
import { ChevronLeft, ChevronRight } from "./icons.js";
|
|
14
|
+
import { childLevel, headingTag, type HeadingLevel } from "./heading.js";
|
|
15
|
+
import { componentRoot, cx } from "./internal.js";
|
|
16
|
+
|
|
17
|
+
export interface CalendarEvent {
|
|
18
|
+
id: string;
|
|
19
|
+
/** Day of the event as YYYY-MM-DD. */
|
|
20
|
+
date: string;
|
|
21
|
+
title: string;
|
|
22
|
+
/** Display time, e.g. "10:00". */
|
|
23
|
+
time?: string;
|
|
24
|
+
/** Start as HH:MM; positions the event in week and day views. Omit for all-day. */
|
|
25
|
+
start?: string;
|
|
26
|
+
/** End as HH:MM; defaults to an hour after start. */
|
|
27
|
+
end?: string;
|
|
28
|
+
/** Avatar or icon beside the event in the month view's `agenda`. */
|
|
29
|
+
media?: ReactNode;
|
|
30
|
+
}
|
|
31
|
+
export type CalendarView = "year" | "month" | "week" | "day";
|
|
32
|
+
|
|
33
|
+
// Dates are YYYY-MM-DD strings handled in UTC, so no local time zone can shift a day.
|
|
34
|
+
const DAY = 86_400_000;
|
|
35
|
+
const toTime = (date: string) => {
|
|
36
|
+
const [y, m, d] = date.split("-").map(Number);
|
|
37
|
+
return Date.UTC(y, m - 1, d);
|
|
38
|
+
};
|
|
39
|
+
const toDate = (time: number) => new Date(time).toISOString().slice(0, 10);
|
|
40
|
+
const addDays = (date: string, days: number) =>
|
|
41
|
+
toDate(toTime(date) + days * DAY);
|
|
42
|
+
function addMonths(date: string, months: number) {
|
|
43
|
+
const [y, m, d] = date.split("-").map(Number);
|
|
44
|
+
const first = new Date(Date.UTC(y, m - 1 + months, 1));
|
|
45
|
+
const last = new Date(
|
|
46
|
+
Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0),
|
|
47
|
+
).getUTCDate();
|
|
48
|
+
return toDate(
|
|
49
|
+
Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), Math.min(d, last)),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const monthOf = (date: string) => date.slice(0, 7);
|
|
53
|
+
/** Column of a date in a week starting on `weekStartsOn` (0 = Sunday). */
|
|
54
|
+
const weekdayIndex = (date: string, weekStartsOn: number) =>
|
|
55
|
+
(new Date(toTime(date)).getUTCDay() - weekStartsOn + 7) % 7;
|
|
56
|
+
/** A day button's name: the full date, plus how many events it has. */
|
|
57
|
+
const dayLabel = (name: string, count: number) =>
|
|
58
|
+
count ? `${name}, ${count} ${count === 1 ? "event" : "events"}` : name;
|
|
59
|
+
const toMinutes = (time: string) => {
|
|
60
|
+
const [h, m] = time.split(":").map(Number);
|
|
61
|
+
return h * 60 + (m || 0);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Shortest drawn event, in minutes, so an event at the edge stays visible. */
|
|
65
|
+
const MIN_DRAWN = 30;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Place timed events between `min` and `max` minutes and assign overlapping
|
|
69
|
+
* ones to side-by-side lanes. Events that start before `min` or end after
|
|
70
|
+
* `max` are drawn at the edge and flagged as clipped.
|
|
71
|
+
*/
|
|
72
|
+
function layout(events: CalendarEvent[], min: number, max: number) {
|
|
73
|
+
const timed = events
|
|
74
|
+
.filter((e) => e.start)
|
|
75
|
+
.map((e) => {
|
|
76
|
+
const start = toMinutes(e.start!);
|
|
77
|
+
const end = e.end ? toMinutes(e.end) : start + 60;
|
|
78
|
+
const top = Math.min(Math.max(start, min), max - MIN_DRAWN);
|
|
79
|
+
const bottom = Math.max(Math.min(end, max), top + MIN_DRAWN);
|
|
80
|
+
return {
|
|
81
|
+
event: e,
|
|
82
|
+
top,
|
|
83
|
+
bottom,
|
|
84
|
+
clippedStart: start < min,
|
|
85
|
+
clippedEnd: end > max,
|
|
86
|
+
};
|
|
87
|
+
})
|
|
88
|
+
.sort((a, b) => a.top - b.top || a.bottom - b.bottom);
|
|
89
|
+
type Placed = (typeof timed)[number] & { lane: number; lanes: number };
|
|
90
|
+
const out: Placed[] = [];
|
|
91
|
+
let cluster: Placed[] = [];
|
|
92
|
+
let laneEnds: number[] = [];
|
|
93
|
+
let clusterEnd = -1;
|
|
94
|
+
const flush = () => {
|
|
95
|
+
const lanes = Math.max(1, ...cluster.map((c) => c.lane + 1));
|
|
96
|
+
cluster.forEach((c) => (c.lanes = lanes));
|
|
97
|
+
out.push(...cluster);
|
|
98
|
+
cluster = [];
|
|
99
|
+
laneEnds = [];
|
|
100
|
+
clusterEnd = -1;
|
|
101
|
+
};
|
|
102
|
+
for (const item of timed) {
|
|
103
|
+
if (cluster.length && item.top >= clusterEnd) flush();
|
|
104
|
+
let lane = laneEnds.findIndex((end) => end <= item.top);
|
|
105
|
+
if (lane < 0) {
|
|
106
|
+
lane = laneEnds.length;
|
|
107
|
+
laneEnds.push(item.bottom);
|
|
108
|
+
} else laneEnds[lane] = item.bottom;
|
|
109
|
+
cluster.push({ ...item, lane, lanes: 1 });
|
|
110
|
+
clusterEnd = Math.max(clusterEnd, item.bottom);
|
|
111
|
+
}
|
|
112
|
+
if (cluster.length) flush();
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
function localToday() {
|
|
116
|
+
const now = new Date();
|
|
117
|
+
return toDate(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface CalendarProps {
|
|
121
|
+
/** Displayed month as YYYY-MM. Omit to follow `date`. */
|
|
122
|
+
month?: string;
|
|
123
|
+
/** Called with YYYY-MM whenever the current date moves into another month, in any view. */
|
|
124
|
+
onMonthChange?: (month: string) => void;
|
|
125
|
+
/**
|
|
126
|
+
* The current date as YYYY-MM-DD: the month view's focusable day, the
|
|
127
|
+
* year shown in the year view and the week or day shown in the time views.
|
|
128
|
+
* Omit to let the calendar manage it; it starts at `selected` or today.
|
|
129
|
+
*/
|
|
130
|
+
date?: string;
|
|
131
|
+
/** Called with the new current date after navigation in any view. */
|
|
132
|
+
onDateChange?: (date: string) => void;
|
|
133
|
+
/** Selected day as YYYY-MM-DD. */
|
|
134
|
+
selected?: string;
|
|
135
|
+
onSelect?: (date: string) => void;
|
|
136
|
+
events?: CalendarEvent[];
|
|
137
|
+
/** Month view only: `full` lists each day's events in its cell. */
|
|
138
|
+
variant?: "compact" | "full";
|
|
139
|
+
weekStartsOn?: 0 | 1;
|
|
140
|
+
/** Override today's date (YYYY-MM-DD), e.g. for tests or a user's time zone. */
|
|
141
|
+
today?: string;
|
|
142
|
+
locale?: string;
|
|
143
|
+
/** Month view with `variant="full"` only: events listed per day before "+N more". */
|
|
144
|
+
maxEvents?: number;
|
|
145
|
+
/** Twelve months, one month grid, or a time grid for one week or one day. */
|
|
146
|
+
view?: CalendarView;
|
|
147
|
+
/** Shows a Year/Month/Week/Day switcher when provided. */
|
|
148
|
+
onViewChange?: (view: CalendarView) => void;
|
|
149
|
+
/** Makes events in week and day views, and in the agenda, buttons. */
|
|
150
|
+
onEventSelect?: (id: string) => void;
|
|
151
|
+
/**
|
|
152
|
+
* Month view only: lists the events of the selected day (or the current
|
|
153
|
+
* date when nothing is selected) beside the month, or below it when
|
|
154
|
+
* narrow, under a "Schedule for" heading one level below the title.
|
|
155
|
+
*/
|
|
156
|
+
agenda?: boolean;
|
|
157
|
+
/**
|
|
158
|
+
* First and last hour shown in week and day views. Events that start or
|
|
159
|
+
* end outside them are drawn at the edge with a dashed border and keep
|
|
160
|
+
* their real times in their text.
|
|
161
|
+
*/
|
|
162
|
+
dayStartHour?: number;
|
|
163
|
+
dayEndHour?: number;
|
|
164
|
+
/** Level of the title (month, year, week or day); month and day headings are one below. */
|
|
165
|
+
headingLevel?: HeadingLevel;
|
|
166
|
+
id?: string;
|
|
167
|
+
className?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Calendar with year, month, week and day views. One current date (`date`)
|
|
172
|
+
* is shared by every view, so moving through months and then switching to
|
|
173
|
+
* the week view shows a week of the month you reached. The month and year
|
|
174
|
+
* views are grids with keyboard navigation; the week and day views place
|
|
175
|
+
* timed events on an hour grid.
|
|
176
|
+
*/
|
|
177
|
+
export function Calendar({
|
|
178
|
+
month: controlledMonth,
|
|
179
|
+
onMonthChange,
|
|
180
|
+
date: controlledDate,
|
|
181
|
+
onDateChange,
|
|
182
|
+
selected,
|
|
183
|
+
onSelect,
|
|
184
|
+
events = [],
|
|
185
|
+
variant = "compact",
|
|
186
|
+
weekStartsOn = 1,
|
|
187
|
+
today: todayProp,
|
|
188
|
+
locale = "en-GB",
|
|
189
|
+
maxEvents = 2,
|
|
190
|
+
view = "month",
|
|
191
|
+
onViewChange,
|
|
192
|
+
onEventSelect,
|
|
193
|
+
agenda = false,
|
|
194
|
+
dayStartHour = 8,
|
|
195
|
+
dayEndHour = 19,
|
|
196
|
+
headingLevel = 2,
|
|
197
|
+
id,
|
|
198
|
+
className,
|
|
199
|
+
}: CalendarProps) {
|
|
200
|
+
const today = todayProp || localToday();
|
|
201
|
+
const [ownDate, setOwnDate] = useState(() => {
|
|
202
|
+
const start = selected || today;
|
|
203
|
+
return controlledMonth && monthOf(start) !== controlledMonth
|
|
204
|
+
? `${controlledMonth}-01`
|
|
205
|
+
: start;
|
|
206
|
+
});
|
|
207
|
+
// Follow a changed `selected` or `month` prop when the date is uncontrolled.
|
|
208
|
+
const [seen, setSeen] = useState({ selected, month: controlledMonth });
|
|
209
|
+
if (seen.selected !== selected || seen.month !== controlledMonth) {
|
|
210
|
+
setSeen({ selected, month: controlledMonth });
|
|
211
|
+
if (controlledDate === undefined) {
|
|
212
|
+
let next = selected && selected !== seen.selected ? selected : ownDate;
|
|
213
|
+
if (controlledMonth && monthOf(next) !== controlledMonth)
|
|
214
|
+
next = `${controlledMonth}-01`;
|
|
215
|
+
if (next !== ownDate) setOwnDate(next);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const date = controlledDate ?? ownDate;
|
|
219
|
+
const month = controlledMonth ?? monthOf(date);
|
|
220
|
+
const focusDate = monthOf(date) === month ? date : `${month}-01`;
|
|
221
|
+
const moved = useRef(false);
|
|
222
|
+
const grid = useRef<HTMLElement>(null);
|
|
223
|
+
const headingId = useId();
|
|
224
|
+
const Heading = headingTag(headingLevel);
|
|
225
|
+
const SubHeading = headingTag(childLevel(headingLevel));
|
|
226
|
+
const rootClass = cx("m-calendar", className);
|
|
227
|
+
|
|
228
|
+
const formats = useMemo(
|
|
229
|
+
() => ({
|
|
230
|
+
title: new Intl.DateTimeFormat(locale, {
|
|
231
|
+
month: "long",
|
|
232
|
+
year: "numeric",
|
|
233
|
+
timeZone: "UTC",
|
|
234
|
+
}),
|
|
235
|
+
day: new Intl.DateTimeFormat(locale, {
|
|
236
|
+
weekday: "long",
|
|
237
|
+
day: "numeric",
|
|
238
|
+
month: "long",
|
|
239
|
+
year: "numeric",
|
|
240
|
+
timeZone: "UTC",
|
|
241
|
+
}),
|
|
242
|
+
short: new Intl.DateTimeFormat(locale, {
|
|
243
|
+
weekday: "short",
|
|
244
|
+
timeZone: "UTC",
|
|
245
|
+
}),
|
|
246
|
+
long: new Intl.DateTimeFormat(locale, {
|
|
247
|
+
weekday: "long",
|
|
248
|
+
timeZone: "UTC",
|
|
249
|
+
}),
|
|
250
|
+
narrow: new Intl.DateTimeFormat(locale, {
|
|
251
|
+
weekday: "narrow",
|
|
252
|
+
timeZone: "UTC",
|
|
253
|
+
}),
|
|
254
|
+
monthName: new Intl.DateTimeFormat(locale, {
|
|
255
|
+
month: "long",
|
|
256
|
+
timeZone: "UTC",
|
|
257
|
+
}),
|
|
258
|
+
}),
|
|
259
|
+
[locale],
|
|
260
|
+
);
|
|
261
|
+
const byDay = useMemo(() => {
|
|
262
|
+
const map = new Map<string, CalendarEvent[]>();
|
|
263
|
+
for (const event of events)
|
|
264
|
+
map.set(event.date, [...(map.get(event.date) || []), event]);
|
|
265
|
+
return map;
|
|
266
|
+
}, [events]);
|
|
267
|
+
|
|
268
|
+
const first = `${month}-01`;
|
|
269
|
+
const start = addDays(first, -weekdayIndex(first, weekStartsOn));
|
|
270
|
+
const weeks = Array.from({ length: 6 }, (_, w) =>
|
|
271
|
+
Array.from({ length: 7 }, (_, d) => addDays(start, w * 7 + d)),
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
function setDate(next: string) {
|
|
275
|
+
if (next === date) return;
|
|
276
|
+
if (controlledDate === undefined) setOwnDate(next);
|
|
277
|
+
onDateChange?.(next);
|
|
278
|
+
if (monthOf(next) !== month) onMonthChange?.(monthOf(next));
|
|
279
|
+
}
|
|
280
|
+
const year = date.slice(0, 4);
|
|
281
|
+
function move(next: string) {
|
|
282
|
+
moved.current = true;
|
|
283
|
+
setDate(next);
|
|
284
|
+
}
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
if (!moved.current) return;
|
|
287
|
+
moved.current = false;
|
|
288
|
+
const target = view === "year" ? date : focusDate;
|
|
289
|
+
grid.current
|
|
290
|
+
?.querySelector<HTMLButtonElement>(`[data-date="${target}"]`)
|
|
291
|
+
?.focus();
|
|
292
|
+
});
|
|
293
|
+
function onKeyDown(event: KeyboardEvent<HTMLButtonElement>, day: string) {
|
|
294
|
+
const weekday = weekdayIndex(day, weekStartsOn);
|
|
295
|
+
const next = {
|
|
296
|
+
ArrowLeft: () => addDays(day, -1),
|
|
297
|
+
ArrowRight: () => addDays(day, 1),
|
|
298
|
+
ArrowUp: () => addDays(day, -7),
|
|
299
|
+
ArrowDown: () => addDays(day, 7),
|
|
300
|
+
Home: () => addDays(day, -weekday),
|
|
301
|
+
End: () => addDays(day, 6 - weekday),
|
|
302
|
+
PageUp: () => addMonths(day, event.shiftKey ? -12 : -1),
|
|
303
|
+
PageDown: () => addMonths(day, event.shiftKey ? 12 : 1),
|
|
304
|
+
}[event.key];
|
|
305
|
+
if (!next) return;
|
|
306
|
+
event.preventDefault();
|
|
307
|
+
move(next());
|
|
308
|
+
}
|
|
309
|
+
const step = (months: number) => setDate(addMonths(focusDate, months));
|
|
310
|
+
|
|
311
|
+
const switcher = onViewChange && (
|
|
312
|
+
<SegmentedControl
|
|
313
|
+
label="Calendar view"
|
|
314
|
+
value={view}
|
|
315
|
+
options={[
|
|
316
|
+
{ value: "year", label: "Year" },
|
|
317
|
+
{ value: "month", label: "Month" },
|
|
318
|
+
{ value: "week", label: "Week" },
|
|
319
|
+
{ value: "day", label: "Day" },
|
|
320
|
+
]}
|
|
321
|
+
onChange={(next) => onViewChange(next as CalendarView)}
|
|
322
|
+
/>
|
|
323
|
+
);
|
|
324
|
+
if (view === "year") {
|
|
325
|
+
const shiftYear = (by: number) => setDate(addMonths(date, 12 * by));
|
|
326
|
+
const label = (day: string) =>
|
|
327
|
+
dayLabel(
|
|
328
|
+
formats.day.format(new Date(toTime(day))),
|
|
329
|
+
byDay.get(day)?.length ?? 0,
|
|
330
|
+
);
|
|
331
|
+
return (
|
|
332
|
+
<div
|
|
333
|
+
id={id}
|
|
334
|
+
{...componentRoot("Calendar")}
|
|
335
|
+
className={rootClass}
|
|
336
|
+
data-view="year"
|
|
337
|
+
>
|
|
338
|
+
<div className="m-calendar-header">
|
|
339
|
+
<Heading
|
|
340
|
+
id={headingId}
|
|
341
|
+
className="m-calendar-title"
|
|
342
|
+
aria-live="polite"
|
|
343
|
+
>
|
|
344
|
+
{year}
|
|
345
|
+
</Heading>
|
|
346
|
+
{switcher}
|
|
347
|
+
<div className="m-calendar-nav">
|
|
348
|
+
<NavButton
|
|
349
|
+
label="Previous year"
|
|
350
|
+
direction="back"
|
|
351
|
+
onClick={() => shiftYear(-1)}
|
|
352
|
+
/>
|
|
353
|
+
<NavButton
|
|
354
|
+
label="Next year"
|
|
355
|
+
direction="forward"
|
|
356
|
+
onClick={() => shiftYear(1)}
|
|
357
|
+
/>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
<div
|
|
361
|
+
className="m-calendar-year"
|
|
362
|
+
ref={grid as React.RefObject<HTMLDivElement>}
|
|
363
|
+
>
|
|
364
|
+
{Array.from({ length: 12 }, (_, i) => {
|
|
365
|
+
const mm = String(i + 1).padStart(2, "0");
|
|
366
|
+
const firstDay = `${year}-${mm}-01`;
|
|
367
|
+
const lead = weekdayIndex(firstDay, weekStartsOn);
|
|
368
|
+
const cells = Array.from({ length: 42 }, (_, n) =>
|
|
369
|
+
addDays(firstDay, n - lead),
|
|
370
|
+
);
|
|
371
|
+
const titleId = `${headingId}-m${mm}`;
|
|
372
|
+
return (
|
|
373
|
+
<section
|
|
374
|
+
key={mm}
|
|
375
|
+
className="m-calendar-month"
|
|
376
|
+
aria-labelledby={titleId}
|
|
377
|
+
>
|
|
378
|
+
<SubHeading id={titleId} className="m-calendar-month-title">
|
|
379
|
+
{formats.monthName.format(new Date(toTime(firstDay)))}
|
|
380
|
+
</SubHeading>
|
|
381
|
+
<table>
|
|
382
|
+
<thead>
|
|
383
|
+
<tr>
|
|
384
|
+
{cells.slice(0, 7).map((day) => {
|
|
385
|
+
const d = new Date(toTime(day));
|
|
386
|
+
return (
|
|
387
|
+
<th
|
|
388
|
+
key={day}
|
|
389
|
+
scope="col"
|
|
390
|
+
abbr={formats.long.format(d)}
|
|
391
|
+
>
|
|
392
|
+
{formats.narrow.format(d)}
|
|
393
|
+
</th>
|
|
394
|
+
);
|
|
395
|
+
})}
|
|
396
|
+
</tr>
|
|
397
|
+
</thead>
|
|
398
|
+
<tbody>
|
|
399
|
+
{Array.from({ length: 6 }, (_, w) => (
|
|
400
|
+
<tr key={w}>
|
|
401
|
+
{cells.slice(w * 7, w * 7 + 7).map((day) =>
|
|
402
|
+
monthOf(day) === `${year}-${mm}` ? (
|
|
403
|
+
<td key={day}>
|
|
404
|
+
<button
|
|
405
|
+
type="button"
|
|
406
|
+
data-date={day}
|
|
407
|
+
tabIndex={day === date ? 0 : -1}
|
|
408
|
+
aria-current={
|
|
409
|
+
day === today ? "date" : undefined
|
|
410
|
+
}
|
|
411
|
+
aria-pressed={day === selected}
|
|
412
|
+
aria-label={label(day)}
|
|
413
|
+
onClick={() => {
|
|
414
|
+
setDate(day);
|
|
415
|
+
onSelect?.(day);
|
|
416
|
+
}}
|
|
417
|
+
onKeyDown={(event) => onKeyDown(event, day)}
|
|
418
|
+
>
|
|
419
|
+
<span className="m-calendar-day">
|
|
420
|
+
{Number(day.slice(8))}
|
|
421
|
+
</span>
|
|
422
|
+
{byDay.has(day) && (
|
|
423
|
+
<span
|
|
424
|
+
className="m-calendar-dot"
|
|
425
|
+
aria-hidden="true"
|
|
426
|
+
/>
|
|
427
|
+
)}
|
|
428
|
+
</button>
|
|
429
|
+
</td>
|
|
430
|
+
) : (
|
|
431
|
+
<td key={day} />
|
|
432
|
+
),
|
|
433
|
+
)}
|
|
434
|
+
</tr>
|
|
435
|
+
))}
|
|
436
|
+
</tbody>
|
|
437
|
+
</table>
|
|
438
|
+
</section>
|
|
439
|
+
);
|
|
440
|
+
})}
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
if (view !== "month")
|
|
446
|
+
return (
|
|
447
|
+
<TimeGrid
|
|
448
|
+
view={view}
|
|
449
|
+
date={date}
|
|
450
|
+
onDateChange={setDate}
|
|
451
|
+
events={byDay}
|
|
452
|
+
today={today}
|
|
453
|
+
selected={selected}
|
|
454
|
+
weekStartsOn={weekStartsOn}
|
|
455
|
+
locale={locale}
|
|
456
|
+
formats={formats}
|
|
457
|
+
headingId={headingId}
|
|
458
|
+
headingLevel={headingLevel}
|
|
459
|
+
switcher={switcher}
|
|
460
|
+
onEventSelect={onEventSelect}
|
|
461
|
+
dayStartHour={dayStartHour}
|
|
462
|
+
dayEndHour={dayEndHour}
|
|
463
|
+
id={id}
|
|
464
|
+
className={rootClass}
|
|
465
|
+
/>
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
const monthGrid = (
|
|
469
|
+
<>
|
|
470
|
+
<div className="m-calendar-header">
|
|
471
|
+
<Heading id={headingId} className="m-calendar-title" aria-live="polite">
|
|
472
|
+
{formats.title.format(new Date(toTime(first)))}
|
|
473
|
+
</Heading>
|
|
474
|
+
{switcher}
|
|
475
|
+
<div className="m-calendar-nav">
|
|
476
|
+
<NavButton
|
|
477
|
+
label="Previous month"
|
|
478
|
+
direction="back"
|
|
479
|
+
onClick={() => step(-1)}
|
|
480
|
+
/>
|
|
481
|
+
<NavButton
|
|
482
|
+
label="Next month"
|
|
483
|
+
direction="forward"
|
|
484
|
+
onClick={() => step(1)}
|
|
485
|
+
/>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
<table
|
|
489
|
+
role="grid"
|
|
490
|
+
aria-labelledby={headingId}
|
|
491
|
+
ref={grid as React.RefObject<HTMLTableElement>}
|
|
492
|
+
>
|
|
493
|
+
<thead>
|
|
494
|
+
<tr>
|
|
495
|
+
{weeks[0].map((day) => {
|
|
496
|
+
const d = new Date(toTime(day));
|
|
497
|
+
return (
|
|
498
|
+
<th key={day} scope="col" abbr={formats.long.format(d)}>
|
|
499
|
+
{formats.short.format(d)}
|
|
500
|
+
</th>
|
|
501
|
+
);
|
|
502
|
+
})}
|
|
503
|
+
</tr>
|
|
504
|
+
</thead>
|
|
505
|
+
<tbody>
|
|
506
|
+
{weeks.map((week) => (
|
|
507
|
+
<tr key={week[0]}>
|
|
508
|
+
{week.map((day) => {
|
|
509
|
+
const dayEvents = byDay.get(day) || [];
|
|
510
|
+
const count = dayEvents.length;
|
|
511
|
+
const name = formats.day.format(new Date(toTime(day)));
|
|
512
|
+
const listed = variant === "full" && count > 0;
|
|
513
|
+
return (
|
|
514
|
+
<td
|
|
515
|
+
key={day}
|
|
516
|
+
aria-selected={day === selected ? true : undefined}
|
|
517
|
+
data-outside={monthOf(day) !== month || undefined}
|
|
518
|
+
>
|
|
519
|
+
<button
|
|
520
|
+
type="button"
|
|
521
|
+
data-date={day}
|
|
522
|
+
tabIndex={day === focusDate ? 0 : -1}
|
|
523
|
+
aria-current={day === today ? "date" : undefined}
|
|
524
|
+
aria-pressed={day === selected}
|
|
525
|
+
aria-describedby={
|
|
526
|
+
listed ? `${headingId}-${day}` : undefined
|
|
527
|
+
}
|
|
528
|
+
aria-label={dayLabel(name, count)}
|
|
529
|
+
onClick={() => {
|
|
530
|
+
setDate(day);
|
|
531
|
+
onSelect?.(day);
|
|
532
|
+
}}
|
|
533
|
+
onKeyDown={(event) => onKeyDown(event, day)}
|
|
534
|
+
>
|
|
535
|
+
<span className="m-calendar-day">
|
|
536
|
+
{Number(day.slice(8))}
|
|
537
|
+
</span>
|
|
538
|
+
{count > 0 && (
|
|
539
|
+
<span className="m-calendar-dot" aria-hidden="true" />
|
|
540
|
+
)}
|
|
541
|
+
</button>
|
|
542
|
+
{listed && (
|
|
543
|
+
<ol
|
|
544
|
+
className="m-calendar-events"
|
|
545
|
+
id={`${headingId}-${day}`}
|
|
546
|
+
>
|
|
547
|
+
{dayEvents.slice(0, maxEvents).map((event) => (
|
|
548
|
+
<li key={event.id}>
|
|
549
|
+
{event.time && <time>{event.time}</time>}
|
|
550
|
+
{event.time && " "}
|
|
551
|
+
{event.title}
|
|
552
|
+
</li>
|
|
553
|
+
))}
|
|
554
|
+
{count > maxEvents && (
|
|
555
|
+
<li className="m-calendar-more">
|
|
556
|
+
+{count - maxEvents} more
|
|
557
|
+
</li>
|
|
558
|
+
)}
|
|
559
|
+
</ol>
|
|
560
|
+
)}
|
|
561
|
+
</td>
|
|
562
|
+
);
|
|
563
|
+
})}
|
|
564
|
+
</tr>
|
|
565
|
+
))}
|
|
566
|
+
</tbody>
|
|
567
|
+
</table>
|
|
568
|
+
</>
|
|
569
|
+
);
|
|
570
|
+
const rootProps = {
|
|
571
|
+
...componentRoot("Calendar"),
|
|
572
|
+
id,
|
|
573
|
+
className: rootClass,
|
|
574
|
+
"data-variant": variant === "full" ? "full" : undefined,
|
|
575
|
+
};
|
|
576
|
+
if (!agenda) return <div {...rootProps}>{monthGrid}</div>;
|
|
577
|
+
const agendaDay = selected ?? date;
|
|
578
|
+
const agendaEvents = [...(byDay.get(agendaDay) || [])].sort((a, b) =>
|
|
579
|
+
(a.start ?? a.time ?? "").localeCompare(b.start ?? b.time ?? ""),
|
|
580
|
+
);
|
|
581
|
+
const agendaId = `${headingId}-agenda`;
|
|
582
|
+
return (
|
|
583
|
+
<div {...rootProps} data-agenda="true">
|
|
584
|
+
<div className="m-calendar-main">{monthGrid}</div>
|
|
585
|
+
<section className="m-calendar-agenda" aria-labelledby={agendaId}>
|
|
586
|
+
<SubHeading
|
|
587
|
+
id={agendaId}
|
|
588
|
+
className="m-calendar-agenda-title"
|
|
589
|
+
aria-live="polite"
|
|
590
|
+
>
|
|
591
|
+
Schedule for{" "}
|
|
592
|
+
<time dateTime={agendaDay}>
|
|
593
|
+
{formats.day.format(new Date(toTime(agendaDay)))}
|
|
594
|
+
</time>
|
|
595
|
+
</SubHeading>
|
|
596
|
+
{agendaEvents.length ? (
|
|
597
|
+
<ol className="m-calendar-agenda-list">
|
|
598
|
+
{agendaEvents.map((event) => {
|
|
599
|
+
const when = event.start ? (
|
|
600
|
+
<>
|
|
601
|
+
<time>{event.start}</time>
|
|
602
|
+
{event.end && (
|
|
603
|
+
<>
|
|
604
|
+
–<time>{event.end}</time>
|
|
605
|
+
</>
|
|
606
|
+
)}
|
|
607
|
+
</>
|
|
608
|
+
) : event.time ? (
|
|
609
|
+
<time>{event.time}</time>
|
|
610
|
+
) : (
|
|
611
|
+
"All day"
|
|
612
|
+
);
|
|
613
|
+
// The space keeps the title and time apart in a button's name.
|
|
614
|
+
const text = (
|
|
615
|
+
<>
|
|
616
|
+
<strong>{event.title}</strong>{" "}
|
|
617
|
+
<span className="m-calendar-event-time">{when}</span>
|
|
618
|
+
</>
|
|
619
|
+
);
|
|
620
|
+
return (
|
|
621
|
+
<li key={event.id}>
|
|
622
|
+
{event.media}
|
|
623
|
+
{onEventSelect ? (
|
|
624
|
+
<button
|
|
625
|
+
type="button"
|
|
626
|
+
className="m-calendar-agenda-text"
|
|
627
|
+
onClick={() => onEventSelect(event.id)}
|
|
628
|
+
>
|
|
629
|
+
{text}
|
|
630
|
+
</button>
|
|
631
|
+
) : (
|
|
632
|
+
<p className="m-calendar-agenda-text">{text}</p>
|
|
633
|
+
)}
|
|
634
|
+
</li>
|
|
635
|
+
);
|
|
636
|
+
})}
|
|
637
|
+
</ol>
|
|
638
|
+
) : (
|
|
639
|
+
<p className="m-calendar-none">No events</p>
|
|
640
|
+
)}
|
|
641
|
+
</section>
|
|
642
|
+
</div>
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function NavButton({
|
|
647
|
+
label,
|
|
648
|
+
direction,
|
|
649
|
+
onClick,
|
|
650
|
+
}: {
|
|
651
|
+
label: string;
|
|
652
|
+
direction: "back" | "forward";
|
|
653
|
+
onClick: () => void;
|
|
654
|
+
}) {
|
|
655
|
+
return (
|
|
656
|
+
<button type="button" aria-label={label} onClick={onClick}>
|
|
657
|
+
{direction === "back" ? <ChevronLeft /> : <ChevronRight />}
|
|
658
|
+
</button>
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Week and day views: labelled day groups whose events are positioned on an hour grid. */
|
|
663
|
+
function TimeGrid({
|
|
664
|
+
view,
|
|
665
|
+
date,
|
|
666
|
+
onDateChange,
|
|
667
|
+
events,
|
|
668
|
+
today,
|
|
669
|
+
selected,
|
|
670
|
+
weekStartsOn,
|
|
671
|
+
locale,
|
|
672
|
+
formats,
|
|
673
|
+
headingId,
|
|
674
|
+
headingLevel,
|
|
675
|
+
switcher,
|
|
676
|
+
onEventSelect,
|
|
677
|
+
dayStartHour,
|
|
678
|
+
dayEndHour,
|
|
679
|
+
id,
|
|
680
|
+
className,
|
|
681
|
+
}: {
|
|
682
|
+
view: "week" | "day";
|
|
683
|
+
date: string;
|
|
684
|
+
onDateChange: (date: string) => void;
|
|
685
|
+
events: Map<string, CalendarEvent[]>;
|
|
686
|
+
today: string;
|
|
687
|
+
selected?: string;
|
|
688
|
+
weekStartsOn: 0 | 1;
|
|
689
|
+
locale: string;
|
|
690
|
+
formats: { day: Intl.DateTimeFormat; short: Intl.DateTimeFormat };
|
|
691
|
+
headingId: string;
|
|
692
|
+
headingLevel: HeadingLevel;
|
|
693
|
+
switcher: ReactNode;
|
|
694
|
+
onEventSelect?: (id: string) => void;
|
|
695
|
+
dayStartHour: number;
|
|
696
|
+
dayEndHour: number;
|
|
697
|
+
id?: string;
|
|
698
|
+
className: string;
|
|
699
|
+
}) {
|
|
700
|
+
const Heading = headingTag(headingLevel);
|
|
701
|
+
const DayHeading = headingTag(childLevel(headingLevel));
|
|
702
|
+
const offset = weekdayIndex(date, weekStartsOn);
|
|
703
|
+
const days =
|
|
704
|
+
view === "week"
|
|
705
|
+
? Array.from({ length: 7 }, (_, i) => addDays(date, i - offset))
|
|
706
|
+
: [date];
|
|
707
|
+
const step = view === "week" ? 7 : 1;
|
|
708
|
+
const unit = view === "week" ? "week" : "day";
|
|
709
|
+
const range = new Intl.DateTimeFormat(locale, {
|
|
710
|
+
day: "numeric",
|
|
711
|
+
month: "long",
|
|
712
|
+
year: "numeric",
|
|
713
|
+
timeZone: "UTC",
|
|
714
|
+
});
|
|
715
|
+
const title =
|
|
716
|
+
view === "week"
|
|
717
|
+
? range.formatRange(
|
|
718
|
+
new Date(toTime(days[0])),
|
|
719
|
+
new Date(toTime(days[days.length - 1])),
|
|
720
|
+
)
|
|
721
|
+
: formats.day.format(new Date(toTime(date)));
|
|
722
|
+
const first = dayStartHour * 60;
|
|
723
|
+
const span = Math.max(60, (dayEndHour - dayStartHour) * 60);
|
|
724
|
+
const pct = (n: number) =>
|
|
725
|
+
`${(Math.min(Math.max(n, 0), 1) * 100).toFixed(4)}%`;
|
|
726
|
+
const hours = Array.from(
|
|
727
|
+
{ length: dayEndHour - dayStartHour },
|
|
728
|
+
(_, i) => dayStartHour + i,
|
|
729
|
+
);
|
|
730
|
+
const times = (event: CalendarEvent) => (
|
|
731
|
+
<span className="m-calendar-event-time">
|
|
732
|
+
<time>{event.start}</time>
|
|
733
|
+
{event.end && (
|
|
734
|
+
<>
|
|
735
|
+
–<time>{event.end}</time>
|
|
736
|
+
</>
|
|
737
|
+
)}
|
|
738
|
+
</span>
|
|
739
|
+
);
|
|
740
|
+
const item = (event: CalendarEvent, timed: boolean) => {
|
|
741
|
+
// The space keeps the title and time apart in the accessible name.
|
|
742
|
+
const inner = (
|
|
743
|
+
<>
|
|
744
|
+
{event.title}
|
|
745
|
+
{timed && " "}
|
|
746
|
+
{timed && times(event)}
|
|
747
|
+
</>
|
|
748
|
+
);
|
|
749
|
+
return onEventSelect ? (
|
|
750
|
+
<button
|
|
751
|
+
type="button"
|
|
752
|
+
className="m-calendar-event"
|
|
753
|
+
onClick={() => onEventSelect(event.id)}
|
|
754
|
+
>
|
|
755
|
+
{inner}
|
|
756
|
+
</button>
|
|
757
|
+
) : timed ? (
|
|
758
|
+
<div className="m-calendar-event">{inner}</div>
|
|
759
|
+
) : (
|
|
760
|
+
inner
|
|
761
|
+
);
|
|
762
|
+
};
|
|
763
|
+
return (
|
|
764
|
+
<div
|
|
765
|
+
id={id}
|
|
766
|
+
{...componentRoot("Calendar")}
|
|
767
|
+
className={className}
|
|
768
|
+
data-view={view}
|
|
769
|
+
>
|
|
770
|
+
<div className="m-calendar-header">
|
|
771
|
+
<Heading id={headingId} className="m-calendar-title" aria-live="polite">
|
|
772
|
+
{title}
|
|
773
|
+
</Heading>
|
|
774
|
+
{switcher}
|
|
775
|
+
<div className="m-calendar-nav">
|
|
776
|
+
<NavButton
|
|
777
|
+
label={`Previous ${unit}`}
|
|
778
|
+
direction="back"
|
|
779
|
+
onClick={() => onDateChange(addDays(date, -step))}
|
|
780
|
+
/>
|
|
781
|
+
<NavButton
|
|
782
|
+
label={`Next ${unit}`}
|
|
783
|
+
direction="forward"
|
|
784
|
+
onClick={() => onDateChange(addDays(date, step))}
|
|
785
|
+
/>
|
|
786
|
+
</div>
|
|
787
|
+
</div>
|
|
788
|
+
<div
|
|
789
|
+
className="m-calendar-timegrid"
|
|
790
|
+
style={
|
|
791
|
+
{
|
|
792
|
+
"--m-hours": hours.length,
|
|
793
|
+
"--m-days": days.length,
|
|
794
|
+
} as CSSProperties
|
|
795
|
+
}
|
|
796
|
+
>
|
|
797
|
+
<div className="m-calendar-hours" aria-hidden="true">
|
|
798
|
+
{hours.map((h) => (
|
|
799
|
+
<span key={h}>{`${String(h).padStart(2, "0")}:00`}</span>
|
|
800
|
+
))}
|
|
801
|
+
</div>
|
|
802
|
+
<div className="m-calendar-columns" data-count={days.length}>
|
|
803
|
+
{days.map((day) => {
|
|
804
|
+
const dayEvents = events.get(day) || [];
|
|
805
|
+
const allDay = dayEvents.filter((e) => !e.start);
|
|
806
|
+
const timed = layout(dayEvents, first, first + span);
|
|
807
|
+
const d = new Date(toTime(day));
|
|
808
|
+
const labelId = `${headingId}-${day}`;
|
|
809
|
+
return (
|
|
810
|
+
<div
|
|
811
|
+
key={day}
|
|
812
|
+
role="group"
|
|
813
|
+
className="m-calendar-column"
|
|
814
|
+
aria-labelledby={labelId}
|
|
815
|
+
data-selected={day === selected || undefined}
|
|
816
|
+
>
|
|
817
|
+
<DayHeading
|
|
818
|
+
id={labelId}
|
|
819
|
+
className="m-calendar-column-heading"
|
|
820
|
+
aria-current={day === today ? "date" : undefined}
|
|
821
|
+
>
|
|
822
|
+
<span className="m-calendar-column-label" aria-hidden="true">
|
|
823
|
+
<span>{formats.short.format(d)}</span>
|
|
824
|
+
<strong>{Number(day.slice(8))}</strong>
|
|
825
|
+
</span>
|
|
826
|
+
<span className="m-sr-only">{formats.day.format(d)}</span>
|
|
827
|
+
</DayHeading>
|
|
828
|
+
{allDay.length > 0 && (
|
|
829
|
+
<ul className="m-calendar-allday" aria-label="All day">
|
|
830
|
+
{allDay.map((event) => (
|
|
831
|
+
<li key={event.id}>{item(event, false)}</li>
|
|
832
|
+
))}
|
|
833
|
+
</ul>
|
|
834
|
+
)}
|
|
835
|
+
{timed.length > 0 ? (
|
|
836
|
+
<ol className="m-calendar-slots">
|
|
837
|
+
{timed.map((t) => (
|
|
838
|
+
<li
|
|
839
|
+
key={t.event.id}
|
|
840
|
+
data-clipped-start={t.clippedStart || undefined}
|
|
841
|
+
data-clipped-end={t.clippedEnd || undefined}
|
|
842
|
+
style={
|
|
843
|
+
{
|
|
844
|
+
"--m-top": pct((t.top - first) / span),
|
|
845
|
+
"--m-height": pct((t.bottom - t.top) / span),
|
|
846
|
+
"--m-lane": t.lane,
|
|
847
|
+
"--m-lanes": t.lanes,
|
|
848
|
+
} as CSSProperties
|
|
849
|
+
}
|
|
850
|
+
>
|
|
851
|
+
{item(t.event, true)}
|
|
852
|
+
</li>
|
|
853
|
+
))}
|
|
854
|
+
</ol>
|
|
855
|
+
) : allDay.length ? (
|
|
856
|
+
// Keeps the hour lines in columns that only have all-day events.
|
|
857
|
+
<div className="m-calendar-slots" aria-hidden="true" />
|
|
858
|
+
) : (
|
|
859
|
+
<p className="m-calendar-none">No events</p>
|
|
860
|
+
)}
|
|
861
|
+
</div>
|
|
862
|
+
);
|
|
863
|
+
})}
|
|
864
|
+
</div>
|
|
865
|
+
</div>
|
|
866
|
+
</div>
|
|
867
|
+
);
|
|
868
|
+
}
|