@super-calendar/dom 2.0.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.
@@ -0,0 +1,546 @@
1
+ import {
2
+ addDays,
3
+ endOfMonth,
4
+ format,
5
+ isSameMonth,
6
+ type Locale,
7
+ startOfDay,
8
+ startOfMonth,
9
+ } from "date-fns";
10
+ import {
11
+ type ComponentType,
12
+ type CSSProperties,
13
+ type KeyboardEvent as ReactKeyboardEvent,
14
+ useEffect,
15
+ useMemo,
16
+ useRef,
17
+ useState,
18
+ } from "react";
19
+ import {
20
+ bandRounding,
21
+ buildMonthGrid,
22
+ type CalendarEvent,
23
+ compareDayEvents,
24
+ dayBadgeKind,
25
+ type DateRange,
26
+ type DateSelectionConstraints,
27
+ groupEventsByDay,
28
+ type MonthGridDay,
29
+ monthVisibleCount,
30
+ rangeBandKind,
31
+ type WeekStartsOn,
32
+ } from "@super-calendar/core";
33
+ import { type DomCalendarTheme, mergeDomTheme } from "./theme";
34
+
35
+ // Chip metrics for the events layout (when `events` is provided).
36
+ const DATE_ROW = 24;
37
+ const CHIP_HEIGHT = 18;
38
+ const CHIP_GAP = 2;
39
+ const CELL_PAD = 4;
40
+
41
+ /** Props passed to a custom month event chip renderer. */
42
+ export interface DomMonthEventArgs<T = unknown> {
43
+ event: CalendarEvent<T>;
44
+ onPress: () => void;
45
+ }
46
+
47
+ export type DomMonthEvent<T = unknown> = ComponentType<DomMonthEventArgs<T>>;
48
+
49
+ export interface MonthViewProps<T = unknown> extends DateSelectionConstraints {
50
+ /** Any day within the month to render. */
51
+ date: Date;
52
+ /** First day of the week. Sunday = 0 (default) … Saturday = 6. */
53
+ weekStartsOn?: WeekStartsOn;
54
+ /**
55
+ * Events to render as chips in each day cell. Passing this (even `[]`) switches
56
+ * the grid to the calendar layout (date in the corner, chips below); omit it for
57
+ * the compact date-picker look.
58
+ */
59
+ events?: CalendarEvent<T>[];
60
+ /** Custom chip renderer; falls back to the built-in titled chip. */
61
+ renderEvent?: DomMonthEvent<T>;
62
+ /** Max chips shown per day before a "+N more" row (default 3). */
63
+ maxVisibleEventCount?: number;
64
+ /** Template for the overflow row; `{moreCount}` is replaced (default "{moreCount} More"). */
65
+ moreLabel?: string;
66
+ /** Tap an event chip. */
67
+ onPressEvent?: (event: CalendarEvent<T>) => void;
68
+ /** Tap the "+N more" overflow row. */
69
+ onPressMore?: (events: CalendarEvent<T>[], date: Date) => void;
70
+ /** Selected span; days between the endpoints get the range band. */
71
+ selectedRange?: DateRange;
72
+ /** Discrete selected days (single / multiple). */
73
+ selectedDates?: Date[];
74
+ /** Render days from the neighbouring months in the leading/trailing cells (default true). */
75
+ showAdjacentMonths?: boolean;
76
+ /**
77
+ * Fill the whole cell with the range background instead of the default
78
+ * centered rounded "pill" strip. Default false.
79
+ */
80
+ fillCellOnSelection?: boolean;
81
+ /** Render the "Month yyyy" title above the grid (default true). */
82
+ showTitle?: boolean;
83
+ /** Render the weekday header row (default true). */
84
+ showWeekdays?: boolean;
85
+ /** date-fns locale for the title and weekday labels. */
86
+ locale?: Locale;
87
+ /** Theme overrides; falls back to the default light theme. */
88
+ theme?: Partial<DomCalendarTheme>;
89
+ /** Fired when a selectable day is clicked. */
90
+ onPressDay?: (date: Date) => void;
91
+ /**
92
+ * When events are shown, also make the day cells keyboard-navigable: a single
93
+ * roving tab stop, arrow keys move the focus, Enter opens the day (`onPressDay`).
94
+ * Default false, so keyboard focus moves through events only. The date picker
95
+ * (rendered without `events`) is always navigable regardless of this flag.
96
+ */
97
+ keyboardDayNavigation?: boolean;
98
+ className?: string;
99
+ style?: CSSProperties;
100
+ }
101
+
102
+ function dayCellStyle(day: MonthGridDay, theme: DomCalendarTheme): CSSProperties {
103
+ return {
104
+ position: "relative",
105
+ height: theme.cellHeight,
106
+ border: "none",
107
+ // The range band is a separate layer; the cell only carries the weekend tint.
108
+ background: day.isWeekend && !day.isInRange ? theme.weekendBackground : "transparent",
109
+ font: "inherit",
110
+ fontSize: 15,
111
+ color: day.isDisabled ? theme.textDisabled : theme.text,
112
+ cursor: day.isDisabled ? "default" : "pointer",
113
+ display: "flex",
114
+ alignItems: "center",
115
+ justifyContent: "center",
116
+ padding: 0,
117
+ WebkitTapHighlightColor: "transparent",
118
+ };
119
+ }
120
+
121
+ /**
122
+ * The range band behind a day, rendered as its own layer so it can be a centered
123
+ * rounded strip (the default) or fill the whole cell (`fillCell`). Returns null
124
+ * for days with no band. Endpoints get the leading/trailing pill rounding.
125
+ */
126
+ function rangeBandStyle(
127
+ day: MonthGridDay,
128
+ theme: DomCalendarTheme,
129
+ fillCell: boolean,
130
+ ): CSSProperties | null {
131
+ const kind = rangeBandKind(day, fillCell);
132
+ if (kind === "none") return null;
133
+ const fill = kind === "fill";
134
+ const inset = fill ? 0 : Math.max(0, (theme.cellHeight - theme.rangeBandHeight) / 2);
135
+ const radius = fill ? 0 : theme.rangeBandHeight / 2;
136
+ const rounding = bandRounding(kind);
137
+ // Cap the pill at the endpoint circles: a start/end band stops at the circle's
138
+ // outer edge (half a badge in from the cell centre) rather than spilling to the
139
+ // cell edge, so no band shows in the empty space beside the day circles.
140
+ const cap = `calc(50% - ${theme.dayBadgeSize / 2}px)`;
141
+ const style: CSSProperties = {
142
+ position: "absolute",
143
+ left: rounding.start ? cap : 0,
144
+ right: rounding.end ? cap : 0,
145
+ top: inset,
146
+ bottom: inset,
147
+ background: theme.rangeBackground,
148
+ zIndex: 0,
149
+ };
150
+ if (rounding.start) {
151
+ style.borderTopLeftRadius = radius;
152
+ style.borderBottomLeftRadius = radius;
153
+ }
154
+ if (rounding.end) {
155
+ style.borderTopRightRadius = radius;
156
+ style.borderBottomRightRadius = radius;
157
+ }
158
+ return style;
159
+ }
160
+
161
+ function badgeStyle(day: MonthGridDay, theme: DomCalendarTheme, hovered: boolean): CSSProperties {
162
+ const badge = dayBadgeKind(day, day.isToday);
163
+ const filled = badge !== "none";
164
+ const background = filled
165
+ ? badge === "today"
166
+ ? theme.todayBackground
167
+ : theme.selectedBackground
168
+ : hovered
169
+ ? theme.hoverBackground
170
+ : "transparent";
171
+ return {
172
+ position: "relative",
173
+ zIndex: 1,
174
+ width: theme.dayBadgeSize,
175
+ height: theme.dayBadgeSize,
176
+ borderRadius: "50%",
177
+ display: "flex",
178
+ alignItems: "center",
179
+ justifyContent: "center",
180
+ background,
181
+ color: filled ? (day.isToday ? theme.todayText : theme.selectedText) : "inherit",
182
+ };
183
+ }
184
+
185
+ // --- Calendar (events) layout styles -------------------------------------
186
+
187
+ function eventCellStyle(day: MonthGridDay, theme: DomCalendarTheme, height: number): CSSProperties {
188
+ return {
189
+ position: "relative",
190
+ minHeight: height,
191
+ minWidth: 0,
192
+ border: "none",
193
+ borderTop: `1px solid ${theme.gridLine}`,
194
+ background: day.isWeekend && !day.isInRange ? theme.weekendBackground : "transparent",
195
+ color: day.isDisabled ? theme.textDisabled : theme.text,
196
+ cursor: day.isDisabled ? "default" : "pointer",
197
+ display: "flex",
198
+ flexDirection: "column",
199
+ gap: CHIP_GAP,
200
+ padding: CELL_PAD,
201
+ boxSizing: "border-box",
202
+ textAlign: "left",
203
+ WebkitTapHighlightColor: "transparent",
204
+ };
205
+ }
206
+
207
+ function compactBadgeStyle(day: MonthGridDay, theme: DomCalendarTheme): CSSProperties {
208
+ const badge = dayBadgeKind(day, day.isToday);
209
+ const filled = badge !== "none";
210
+ return {
211
+ position: "relative",
212
+ zIndex: 1,
213
+ alignSelf: "flex-end",
214
+ width: DATE_ROW - 2,
215
+ height: DATE_ROW - 2,
216
+ borderRadius: "50%",
217
+ display: "flex",
218
+ alignItems: "center",
219
+ justifyContent: "center",
220
+ fontSize: 13,
221
+ background: filled
222
+ ? badge === "today"
223
+ ? theme.todayBackground
224
+ : theme.selectedBackground
225
+ : "transparent",
226
+ color: filled ? (day.isToday ? theme.todayText : theme.selectedText) : "inherit",
227
+ };
228
+ }
229
+
230
+ const chipButtonStyle: CSSProperties = {
231
+ position: "relative",
232
+ zIndex: 1,
233
+ border: "none",
234
+ padding: 0,
235
+ margin: 0,
236
+ background: "transparent",
237
+ cursor: "pointer",
238
+ textAlign: "left",
239
+ fontFamily: "inherit",
240
+ };
241
+
242
+ function chipStyle(theme: DomCalendarTheme): CSSProperties {
243
+ return {
244
+ display: "block",
245
+ height: CHIP_HEIGHT,
246
+ lineHeight: `${CHIP_HEIGHT}px`,
247
+ padding: "0 6px",
248
+ borderRadius: 4,
249
+ background: theme.eventBackground,
250
+ color: theme.eventText,
251
+ fontSize: 11,
252
+ fontWeight: 600,
253
+ whiteSpace: "nowrap",
254
+ overflow: "hidden",
255
+ textOverflow: "ellipsis",
256
+ };
257
+ }
258
+
259
+ function moreButtonStyle(theme: DomCalendarTheme): CSSProperties {
260
+ return {
261
+ position: "relative",
262
+ zIndex: 1,
263
+ border: "none",
264
+ background: "transparent",
265
+ cursor: "pointer",
266
+ textAlign: "left",
267
+ padding: "0 6px",
268
+ height: CHIP_HEIGHT,
269
+ lineHeight: `${CHIP_HEIGHT}px`,
270
+ fontSize: 11,
271
+ fontWeight: 600,
272
+ fontFamily: "inherit",
273
+ color: theme.textMuted,
274
+ };
275
+ }
276
+
277
+ // Internal-only: MonthList passes a day→events index built once for the whole
278
+ // list (via `groupEventsByDay`) so each month doesn't rebuild it. Not exported,
279
+ // so it stays off the public MonthViewProps surface.
280
+ interface MonthViewInternalProps<T = unknown> extends MonthViewProps<T> {
281
+ eventsByDay?: ReadonlyMap<string, CalendarEvent<T>[]>;
282
+ }
283
+
284
+ /** A single static month grid, rendered with plain DOM elements. */
285
+ export function MonthView<T = unknown>({
286
+ date,
287
+ weekStartsOn = 0,
288
+ events,
289
+ eventsByDay: eventsByDayProp,
290
+ renderEvent,
291
+ maxVisibleEventCount = 3,
292
+ moreLabel = "{moreCount} More",
293
+ onPressEvent,
294
+ onPressMore,
295
+ selectedRange,
296
+ selectedDates,
297
+ showAdjacentMonths = true,
298
+ fillCellOnSelection = false,
299
+ showTitle = true,
300
+ showWeekdays = true,
301
+ locale,
302
+ theme: themeOverrides,
303
+ minDate,
304
+ maxDate,
305
+ isDateDisabled,
306
+ onPressDay,
307
+ keyboardDayNavigation = false,
308
+ className,
309
+ style,
310
+ }: MonthViewInternalProps<T>) {
311
+ const theme = useMemo(() => mergeDomTheme(themeOverrides), [themeOverrides]);
312
+
313
+ // Calendar layout (date in the corner + event chips) is on whenever `events`
314
+ // is provided; otherwise the compact picker badge layout is used.
315
+ const eventsMode = events !== undefined;
316
+ // The day cells form a roving tab stop (arrow keys + Enter) in the picker
317
+ // always, and in events mode only when the consumer opts in. Otherwise events
318
+ // mode tabs through the event chips alone.
319
+ const dayRoving = !eventsMode || keyboardDayNavigation;
320
+ // Use the list-built index when provided (MonthList), else build it for this
321
+ // month. Either way lookups use startOfDay(date).toISOString().
322
+ const eventsByDay = useMemo(() => {
323
+ // The list (MonthList) sorts before passing its index in; only sort here when
324
+ // building our own, so each day reads all-day events first, then by start.
325
+ if (eventsByDayProp) return eventsByDayProp;
326
+ const map = groupEventsByDay(events ?? []);
327
+ for (const list of map.values()) list.sort(compareDayEvents);
328
+ return map;
329
+ }, [eventsByDayProp, events]);
330
+ const Chip = renderEvent;
331
+ // Reserve `maxVisibleEventCount` rows below the date so every cell is uniform;
332
+ // when a day overflows, the last row becomes the "+N more" affordance.
333
+ const eventRowHeight = CELL_PAD * 2 + DATE_ROW + maxVisibleEventCount * (CHIP_HEIGHT + CHIP_GAP);
334
+ const { weeks, weekdays } = useMemo(
335
+ () =>
336
+ buildMonthGrid(date, {
337
+ weekStartsOn,
338
+ selectedRange,
339
+ selectedDates,
340
+ minDate,
341
+ maxDate,
342
+ isDateDisabled,
343
+ locale,
344
+ }),
345
+ [date, weekStartsOn, selectedRange, selectedDates, minDate, maxDate, isDateDisabled, locale],
346
+ );
347
+
348
+ // Roving tabindex: only one day is in the tab order; arrow keys move focus
349
+ // within the month, so the grid is a single, sensible tab stop.
350
+ const initialFocus = useMemo(() => {
351
+ const inMonth = (d?: Date | null) => !!d && isSameMonth(d, date);
352
+ if (inMonth(selectedRange?.start)) return startOfDay(selectedRange!.start);
353
+ const picked = selectedDates?.find(inMonth);
354
+ if (picked) return startOfDay(picked);
355
+ const today = new Date();
356
+ return isSameMonth(today, date) ? startOfDay(today) : startOfMonth(date);
357
+ }, [date, selectedRange, selectedDates]);
358
+
359
+ const [focusedDate, setFocusedDate] = useState(initialFocus);
360
+ const initialFocusRef = useRef(initialFocus);
361
+ initialFocusRef.current = initialFocus;
362
+ const monthKey = format(date, "yyyy-MM");
363
+ useEffect(() => setFocusedDate(initialFocusRef.current), [monthKey]);
364
+
365
+ const [hoveredKey, setHoveredKey] = useState<string | null>(null);
366
+ const gridRef = useRef<HTMLDivElement>(null);
367
+ const focusKey = format(focusedDate, "yyyy-MM-dd");
368
+ const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
369
+ let next: Date | null = null;
370
+ if (e.key === "ArrowLeft") next = addDays(focusedDate, -1);
371
+ else if (e.key === "ArrowRight") next = addDays(focusedDate, 1);
372
+ else if (e.key === "ArrowUp") next = addDays(focusedDate, -7);
373
+ else if (e.key === "ArrowDown") next = addDays(focusedDate, 7);
374
+ else if (e.key === "Home") next = startOfMonth(date);
375
+ else if (e.key === "End") next = endOfMonth(date);
376
+ else return;
377
+ e.preventDefault();
378
+ if (!isSameMonth(next, date)) return;
379
+ setFocusedDate(next);
380
+ const key = format(next, "yyyy-MM-dd");
381
+ gridRef.current?.querySelector<HTMLElement>(`[data-day="${key}"]`)?.focus();
382
+ };
383
+
384
+ return (
385
+ <div
386
+ className={className}
387
+ style={{ fontFamily: theme.fontFamily, color: theme.text, ...style }}
388
+ >
389
+ {showTitle ? (
390
+ <div style={{ fontSize: 17, fontWeight: 700, padding: "10px 14px 6px" }}>
391
+ {format(date, "MMMM yyyy", locale ? { locale } : undefined)}
392
+ </div>
393
+ ) : null}
394
+ {showWeekdays ? (
395
+ <div
396
+ style={{
397
+ display: "grid",
398
+ gridTemplateColumns: "repeat(7, minmax(0, 1fr))",
399
+ borderBottom: `1px solid ${theme.gridLine}`,
400
+ padding: "6px 0",
401
+ }}
402
+ >
403
+ {weekdays.map((wd) => (
404
+ <span
405
+ key={wd.label}
406
+ style={{ textAlign: "center", fontSize: 12, fontWeight: 600, color: theme.textMuted }}
407
+ >
408
+ {wd.label}
409
+ </span>
410
+ ))}
411
+ </div>
412
+ ) : null}
413
+ <div
414
+ ref={gridRef}
415
+ role="grid"
416
+ aria-label={format(date, "MMMM yyyy", locale ? { locale } : undefined)}
417
+ // Arrow-key roving applies whenever the day cells are a tab stop: the
418
+ // picker always, events mode only when keyboardDayNavigation is set.
419
+ onKeyDown={dayRoving ? onKeyDown : undefined}
420
+ >
421
+ {weeks.map((week) => (
422
+ <div
423
+ key={week.id}
424
+ role="row"
425
+ style={{ display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))" }}
426
+ >
427
+ {week.days.map((day) => {
428
+ const hidden = !showAdjacentMonths && !day.isCurrentMonth;
429
+ const cellHeight = eventsMode ? eventRowHeight : theme.cellHeight;
430
+ if (hidden) {
431
+ return (
432
+ <div key={day.id} role="gridcell" aria-hidden style={{ height: cellHeight }} />
433
+ );
434
+ }
435
+ const band = rangeBandStyle(day, theme, fillCellOnSelection);
436
+ const label = format(day.date, "EEEE, d MMMM yyyy", locale ? { locale } : undefined);
437
+
438
+ if (eventsMode) {
439
+ const dayEvents = eventsByDay.get(startOfDay(day.date).toISOString()) ?? [];
440
+ // Core decides how many fit; withMore keeps >=1 chip so an
441
+ // overflowing day never shows only the "+N more" row.
442
+ const visible = monthVisibleCount(dayEvents.length, {
443
+ full: maxVisibleEventCount,
444
+ withMore: Math.max(maxVisibleEventCount - 1, 1),
445
+ });
446
+ const shown = dayEvents.slice(0, visible);
447
+ const rest = dayEvents.slice(visible);
448
+ const overflow = rest.length > 0;
449
+ const dayLabel = format(day.date, "d MMMM", locale ? { locale } : undefined);
450
+ return (
451
+ // Events mode: by default the cell is not a tab stop, so keyboard
452
+ // focus moves through the event chips (real buttons) only, not
453
+ // every empty day. With keyboardDayNavigation the cell joins the
454
+ // roving tab order (arrow keys + Enter to open the day). A pointer
455
+ // click always drills into the day.
456
+ <div
457
+ key={day.id}
458
+ role="gridcell"
459
+ data-day={day.id}
460
+ tabIndex={keyboardDayNavigation ? (day.id === focusKey ? 0 : -1) : undefined}
461
+ aria-disabled={day.isDisabled || undefined}
462
+ aria-label={label}
463
+ style={eventCellStyle(day, theme, cellHeight)}
464
+ onClick={day.isDisabled ? undefined : () => onPressDay?.(day.date)}
465
+ onKeyDown={
466
+ keyboardDayNavigation && !day.isDisabled
467
+ ? (e) => {
468
+ // Let Enter/Space on a focused chip activate the chip.
469
+ if (e.target !== e.currentTarget) return;
470
+ if (e.key === "Enter" || e.key === " ") {
471
+ e.preventDefault();
472
+ onPressDay?.(day.date);
473
+ }
474
+ }
475
+ : undefined
476
+ }
477
+ >
478
+ {band ? <span data-band aria-hidden style={band} /> : null}
479
+ <span style={compactBadgeStyle(day, theme)}>{day.label}</span>
480
+ <div style={{ display: "flex", flexDirection: "column", gap: CHIP_GAP }}>
481
+ {shown.map((event) => {
482
+ const onPress = () => onPressEvent?.(event);
483
+ return (
484
+ <button
485
+ key={`${event.start.toISOString()}:${event.title}`}
486
+ type="button"
487
+ onClick={(e) => {
488
+ e.stopPropagation();
489
+ onPress();
490
+ }}
491
+ style={chipButtonStyle}
492
+ title={event.title}
493
+ aria-label={`${event.title}, ${dayLabel}`}
494
+ >
495
+ {Chip ? (
496
+ <Chip event={event} onPress={onPress} />
497
+ ) : (
498
+ <span style={chipStyle(theme)}>{event.title}</span>
499
+ )}
500
+ </button>
501
+ );
502
+ })}
503
+ {overflow ? (
504
+ <button
505
+ type="button"
506
+ onClick={(e) => {
507
+ e.stopPropagation();
508
+ onPressMore?.(rest, day.date);
509
+ }}
510
+ style={moreButtonStyle(theme)}
511
+ aria-label={`${rest.length} more events, ${dayLabel}`}
512
+ >
513
+ {moreLabel.replace("{moreCount}", String(rest.length))}
514
+ </button>
515
+ ) : null}
516
+ </div>
517
+ </div>
518
+ );
519
+ }
520
+
521
+ return (
522
+ <button
523
+ key={day.id}
524
+ type="button"
525
+ role="gridcell"
526
+ data-day={day.id}
527
+ tabIndex={day.id === focusKey ? 0 : -1}
528
+ aria-disabled={day.isDisabled || undefined}
529
+ aria-label={label}
530
+ aria-pressed={day.isSelected || day.isInRange}
531
+ style={dayCellStyle(day, theme)}
532
+ onClick={day.isDisabled ? undefined : () => onPressDay?.(day.date)}
533
+ onMouseEnter={day.isDisabled ? undefined : () => setHoveredKey(day.id)}
534
+ onMouseLeave={() => setHoveredKey((k) => (k === day.id ? null : k))}
535
+ >
536
+ {band ? <span data-band aria-hidden style={band} /> : null}
537
+ <span style={badgeStyle(day, theme, hoveredKey === day.id)}>{day.label}</span>
538
+ </button>
539
+ );
540
+ })}
541
+ </div>
542
+ ))}
543
+ </div>
544
+ </div>
545
+ );
546
+ }