@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.
Files changed (104) hide show
  1. package/AGENTS.md +10 -0
  2. package/CHANGELOG.md +276 -0
  3. package/CONTRIBUTING.md +48 -0
  4. package/DESIGN.md +51 -0
  5. package/LICENSE +21 -0
  6. package/README.md +155 -2
  7. package/TESTING.md +83 -0
  8. package/baseline-policy.json +16 -0
  9. package/bin/mortar.mjs +258 -0
  10. package/catalog.json +9703 -0
  11. package/cli/audit.mjs +943 -0
  12. package/cli/data.mjs +176 -0
  13. package/cli/mcp.mjs +216 -0
  14. package/cli/migrations.mjs +128 -0
  15. package/cli/packet.mjs +267 -0
  16. package/cli/search.mjs +246 -0
  17. package/cli/tools.mjs +229 -0
  18. package/design-principles.json +30 -0
  19. package/dist/agent-data.json +1 -0
  20. package/dist/application.d.ts +340 -0
  21. package/dist/application.js +240 -0
  22. package/dist/auth.d.ts +121 -0
  23. package/dist/auth.js +97 -0
  24. package/dist/brand.d.ts +69 -0
  25. package/dist/brand.js +266 -0
  26. package/dist/calendar.d.ts +75 -0
  27. package/dist/calendar.js +310 -0
  28. package/dist/choice.d.ts +36 -0
  29. package/dist/choice.js +36 -0
  30. package/dist/composer.d.ts +28 -0
  31. package/dist/composer.js +47 -0
  32. package/dist/display.d.ts +136 -0
  33. package/dist/display.js +75 -0
  34. package/dist/docs.d.ts +145 -0
  35. package/dist/docs.js +141 -0
  36. package/dist/ecommerce.d.ts +261 -0
  37. package/dist/ecommerce.js +177 -0
  38. package/dist/feedback.d.ts +80 -0
  39. package/dist/feedback.js +98 -0
  40. package/dist/foundation.d.ts +42 -0
  41. package/dist/foundation.js +121 -0
  42. package/dist/heading.d.ts +5 -0
  43. package/dist/heading.js +9 -0
  44. package/dist/icons.d.ts +50 -0
  45. package/dist/icons.js +70 -0
  46. package/dist/index.d.ts +34 -0
  47. package/dist/index.js +17 -0
  48. package/dist/inspection.d.ts +24 -0
  49. package/dist/inspection.js +14 -0
  50. package/dist/internal.d.ts +492 -0
  51. package/dist/internal.js +273 -0
  52. package/dist/layout.d.ts +107 -0
  53. package/dist/layout.js +66 -0
  54. package/dist/marketing-sections.d.ts +161 -0
  55. package/dist/marketing-sections.js +58 -0
  56. package/dist/marketing.d.ts +254 -0
  57. package/dist/marketing.js +150 -0
  58. package/dist/navigation.d.ts +35 -0
  59. package/dist/navigation.js +44 -0
  60. package/dist/primitives.d.ts +258 -0
  61. package/dist/primitives.js +244 -0
  62. package/dist/showcase.d.ts +29 -0
  63. package/dist/showcase.js +10 -0
  64. package/dist/workspace.d.ts +80 -0
  65. package/dist/workspace.js +48 -0
  66. package/docs/agents.md +127 -0
  67. package/docs/auth-flows.md +71 -0
  68. package/docs/migrating-to-0.6.md +94 -0
  69. package/docs/theming.md +170 -0
  70. package/package.json +94 -4
  71. package/skills/mortar/SKILL.md +36 -0
  72. package/src/application.css +1487 -0
  73. package/src/application.tsx +1413 -0
  74. package/src/auth.tsx +452 -0
  75. package/src/brand.ts +335 -0
  76. package/src/calendar.css +468 -0
  77. package/src/calendar.tsx +868 -0
  78. package/src/choice.tsx +240 -0
  79. package/src/composer.tsx +158 -0
  80. package/src/data.css +690 -0
  81. package/src/display.tsx +452 -0
  82. package/src/docs.css +704 -0
  83. package/src/docs.tsx +737 -0
  84. package/src/ecommerce.css +810 -0
  85. package/src/ecommerce.tsx +1033 -0
  86. package/src/feedback.tsx +388 -0
  87. package/src/foundation.css +1867 -0
  88. package/src/foundation.tsx +227 -0
  89. package/src/heading.ts +13 -0
  90. package/src/icons.tsx +237 -0
  91. package/src/index.ts +230 -0
  92. package/src/inspection.tsx +91 -0
  93. package/src/internal.tsx +614 -0
  94. package/src/layout.tsx +342 -0
  95. package/src/marketing-sections.tsx +621 -0
  96. package/src/marketing.css +2400 -0
  97. package/src/marketing.tsx +1156 -0
  98. package/src/navigation.tsx +118 -0
  99. package/src/overlays.css +350 -0
  100. package/src/primitives.tsx +1135 -0
  101. package/src/showcase.tsx +120 -0
  102. package/src/tokens.css +449 -0
  103. package/src/workspace.tsx +333 -0
  104. package/styles.css +56 -0
@@ -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
+ }