@super-calendar/dom 2.3.2 → 2.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@super-calendar/dom",
3
- "version": "2.3.2",
3
+ "version": "2.5.0",
4
4
  "description": "Virtualized month / week / day calendar and date picker for react-dom. No React Native, no react-native-web.",
5
5
  "keywords": [
6
6
  "calendar",
@@ -48,7 +48,7 @@
48
48
  "access": "public"
49
49
  },
50
50
  "dependencies": {
51
- "@super-calendar/core": "2.3.2"
51
+ "@super-calendar/core": "2.5.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@legendapp/list": ">=3",
package/src/Calendar.tsx CHANGED
@@ -1,14 +1,33 @@
1
- import type { Locale } from "date-fns";
2
- import type { CSSProperties, ReactElement, ReactNode } from "react";
3
- import type {
4
- BusinessHours,
5
- CalendarEvent,
6
- DateRange,
7
- DateSelectionConstraints,
8
- EventAccessibilityLabeler,
9
- TimeGridMode,
10
- WeekdayFormat,
11
- WeekStartsOn,
1
+ import {
2
+ addDays,
3
+ addMonths,
4
+ endOfDay,
5
+ endOfMonth,
6
+ endOfWeek,
7
+ type Locale,
8
+ startOfDay,
9
+ startOfMonth,
10
+ startOfWeek,
11
+ } from "date-fns";
12
+ import {
13
+ type CSSProperties,
14
+ type KeyboardEvent as ReactKeyboardEvent,
15
+ type ReactElement,
16
+ type ReactNode,
17
+ useMemo,
18
+ } from "react";
19
+ import {
20
+ type BusinessHours,
21
+ type CalendarEvent,
22
+ type DateRange,
23
+ type DateSelectionConstraints,
24
+ type EventAccessibilityLabeler,
25
+ eventsInTimeZone,
26
+ expandRecurringEvents,
27
+ getViewDays,
28
+ type TimeGridMode,
29
+ type WeekdayFormat,
30
+ type WeekStartsOn,
12
31
  } from "@super-calendar/core";
13
32
  import { Agenda, type AgendaSlot, type DomAgendaEvent } from "./Agenda";
14
33
  import { type DomMonthEvent, MonthView, type MonthViewSlot } from "./MonthView";
@@ -33,6 +52,13 @@ export interface CalendarProps<T = unknown>
33
52
  mode?: "month" | "schedule" | TimeGridMode;
34
53
  /** Controlled anchor date. Change it (e.g. from your own header) to navigate. */
35
54
  date: Date;
55
+ /**
56
+ * Fires with the next/previous period's date when the user pages the focused
57
+ * calendar with **PageDown** / **PageUp** (a month in `month`, a week in
58
+ * `schedule`, else the view's day span). It's controlled, so update `date` in
59
+ * response. Omit it to leave paging to your own controls.
60
+ */
61
+ onChangeDate?: (date: Date) => void;
36
62
  /** Your events. */
37
63
  events?: CalendarEvent<T>[];
38
64
  /** First day of the week. Sunday = 0 (default) … Saturday = 6. */
@@ -41,6 +67,12 @@ export interface CalendarProps<T = unknown>
41
67
  numberOfDays?: number;
42
68
  /** date-fns locale for titles, headers, and time labels. */
43
69
  locale?: Locale;
70
+ /**
71
+ * Display events in this IANA time zone (e.g. `"America/New_York"`), DST-correct
72
+ * and independent of the device zone. Display-only: it shifts the wall-clock the
73
+ * grid lays out from, it doesn't change your `Date`s. Uses `eventsInTimeZone`.
74
+ */
75
+ timeZone?: string;
44
76
  /** Theme overrides; falls back to the default light theme. */
45
77
  theme?: Partial<DomCalendarTheme>;
46
78
  /** Height of the scroll viewport, in px. */
@@ -68,6 +100,16 @@ export interface CalendarProps<T = unknown>
68
100
  scrollOffsetMinutes?: number;
69
101
  /** Sub-divisions per hour for the grid lines. */
70
102
  timeslots?: number;
103
+ /** First hour shown (0–23). Default 0. */
104
+ minHour?: number;
105
+ /** Last hour shown, exclusive (1–24). Default 24. */
106
+ maxHour?: number;
107
+ /** Hide the left hour-axis column (lines stay, labels/gutter go). Default false. */
108
+ hideHours?: boolean;
109
+ /** Show the ISO week number in the header gutter. Default false. */
110
+ showWeekNumber?: boolean;
111
+ /** Prefix for the week number, e.g. "W" → "W28". Default "W". */
112
+ weekNumberPrefix?: string;
71
113
  /** Shade the hours outside business hours. */
72
114
  businessHours?: BusinessHours;
73
115
  /** Show the current-time indicator (default true). */
@@ -90,6 +132,11 @@ export interface CalendarProps<T = unknown>
90
132
  renderTimeEvent?: DomRenderEvent<T>;
91
133
  /** Replace the hour-axis label. Receives the hour (0–23) and the `ampm` flag. */
92
134
  hourComponent?: (hour: number, ampm: boolean) => ReactNode;
135
+ /**
136
+ * Add arrow-key navigation between time-grid events (a convenience for sighted
137
+ * keyboard users; every event stays individually tabbable). Time-grid modes only.
138
+ */
139
+ keyboardEventNavigation?: boolean;
93
140
 
94
141
  // --- Month mode ---
95
142
  /** Max chips per day before a "+N more" row. */
@@ -124,6 +171,48 @@ export interface CalendarProps<T = unknown>
124
171
  renderScheduleEvent?: DomAgendaEvent<T>;
125
172
  }
126
173
 
174
+ // Stable empty array so a missing `events` prop doesn't churn `displayEvents`
175
+ // identity (and bust child memoization) on every re-render.
176
+ const EMPTY_EVENTS: CalendarEvent<never>[] = [];
177
+
178
+ // Recurrence is expanded over the range a mode renders: the month grid for
179
+ // `month`, the day columns for the time-grid modes, and a forward window for the
180
+ // `schedule` agenda (which has no bounded viewport of its own).
181
+ function expansionRange(
182
+ mode: "month" | "schedule" | TimeGridMode,
183
+ date: Date,
184
+ weekStartsOn: WeekStartsOn,
185
+ numberOfDays: number | undefined,
186
+ ): [Date, Date] {
187
+ if (mode === "month") {
188
+ return [
189
+ startOfWeek(startOfMonth(date), { weekStartsOn }),
190
+ endOfWeek(endOfMonth(date), { weekStartsOn }),
191
+ ];
192
+ }
193
+ if (mode === "schedule") {
194
+ // The agenda lists forward from the anchor date; three months is a sensible
195
+ // default look-ahead. Pre-expand for a different window.
196
+ return [startOfDay(date), endOfDay(addMonths(date, 3))];
197
+ }
198
+ const days = getViewDays(mode, date, weekStartsOn, numberOfDays ?? 1);
199
+ return [startOfDay(days[0]), endOfDay(days[days.length - 1])];
200
+ }
201
+
202
+ // The date one page away in `direction` (+1 next, −1 previous): a month for
203
+ // `month`, a week for `schedule`, else the view's day span.
204
+ function pageStep(
205
+ mode: "month" | "schedule" | TimeGridMode,
206
+ date: Date,
207
+ direction: number,
208
+ weekStartsOn: WeekStartsOn,
209
+ numberOfDays: number | undefined,
210
+ ): Date {
211
+ if (mode === "month") return addMonths(date, direction);
212
+ if (mode === "schedule") return addDays(date, direction * 7);
213
+ return addDays(date, direction * getViewDays(mode, date, weekStartsOn, numberOfDays ?? 1).length);
214
+ }
215
+
127
216
  /**
128
217
  * Batteries-included entry point for the react-dom renderer: it picks the right
129
218
  * view for `mode`. `month` renders a single {@link MonthView}; `schedule` renders
@@ -139,9 +228,11 @@ export function Calendar<T = unknown>({
139
228
  mode = "week",
140
229
  date,
141
230
  events,
231
+ onChangeDate,
142
232
  weekStartsOn = 0,
143
233
  numberOfDays,
144
234
  locale,
235
+ timeZone,
145
236
  theme,
146
237
  height,
147
238
  className,
@@ -153,6 +244,11 @@ export function Calendar<T = unknown>({
153
244
  hourHeight,
154
245
  scrollOffsetMinutes,
155
246
  timeslots,
247
+ minHour,
248
+ maxHour,
249
+ hideHours,
250
+ showWeekNumber,
251
+ weekNumberPrefix,
156
252
  businessHours,
157
253
  showNowIndicator,
158
254
  showAllDayEventCell,
@@ -164,6 +260,7 @@ export function Calendar<T = unknown>({
164
260
  onPressDateHeader,
165
261
  renderTimeEvent,
166
262
  hourComponent,
263
+ keyboardEventNavigation,
167
264
  // month
168
265
  maxVisibleEventCount,
169
266
  moreLabel,
@@ -185,10 +282,41 @@ export function Calendar<T = unknown>({
185
282
  classNames,
186
283
  styles,
187
284
  }: CalendarProps<T>): ReactElement {
285
+ // Materialise recurring events over the range this mode renders, then apply the
286
+ // display zone. Non-recurring and already-expanded events pass through
287
+ // untouched, so identity is preserved for the common (no-recurrence) case.
288
+ const displayEvents = useMemo(() => {
289
+ let out: CalendarEvent<T>[] = events ?? EMPTY_EVENTS;
290
+ if (out.some((e) => e.recurrence)) {
291
+ let [start, end] = expansionRange(mode, date, weekStartsOn, numberOfDays);
292
+ // A display zone shifts each occurrence's wall-clock by up to ~a day, which
293
+ // can move an edge occurrence into a visible column; widen so it's generated.
294
+ if (timeZone) {
295
+ start = addDays(start, -1);
296
+ end = addDays(end, 1);
297
+ }
298
+ out = expandRecurringEvents(out, start, end);
299
+ }
300
+ if (timeZone) out = eventsInTimeZone(out, timeZone);
301
+ return out;
302
+ // `date.getTime()`: recompute on the instant, not a re-created Date identity.
303
+ }, [events, mode, date.getTime(), weekStartsOn, numberOfDays, timeZone]);
304
+
305
+ // PageDown / PageUp page the calendar when the consumer opts in with
306
+ // `onChangeDate`; keydowns bubble up from the focused grid to this handler.
307
+ const handlePageKeys = (e: ReactKeyboardEvent) => {
308
+ if (!onChangeDate) return;
309
+ if (e.key === "PageDown") onChangeDate(pageStep(mode, date, 1, weekStartsOn, numberOfDays));
310
+ else if (e.key === "PageUp") onChangeDate(pageStep(mode, date, -1, weekStartsOn, numberOfDays));
311
+ else return;
312
+ e.preventDefault();
313
+ };
314
+
315
+ let view: ReactElement;
188
316
  if (mode === "schedule") {
189
- return (
317
+ view = (
190
318
  <Agenda<T>
191
- events={events ?? []}
319
+ events={displayEvents}
192
320
  locale={locale}
193
321
  ampm={ampm}
194
322
  theme={theme}
@@ -204,13 +332,11 @@ export function Calendar<T = unknown>({
204
332
  onPressDay={onPressDay}
205
333
  />
206
334
  );
207
- }
208
-
209
- if (mode === "month") {
210
- return (
335
+ } else if (mode === "month") {
336
+ view = (
211
337
  <MonthView<T>
212
338
  date={date}
213
- events={events ?? []}
339
+ events={displayEvents}
214
340
  weekStartsOn={weekStartsOn}
215
341
  weekdayFormat={weekdayFormat}
216
342
  locale={locale}
@@ -237,40 +363,56 @@ export function Calendar<T = unknown>({
237
363
  eventAccessibilityLabel={eventAccessibilityLabel}
238
364
  />
239
365
  );
366
+ } else {
367
+ view = (
368
+ <TimeGrid<T>
369
+ date={date}
370
+ mode={mode}
371
+ events={displayEvents}
372
+ weekStartsOn={weekStartsOn}
373
+ weekdayFormat={weekdayFormat}
374
+ numberOfDays={numberOfDays}
375
+ locale={locale}
376
+ theme={theme}
377
+ height={height}
378
+ className={className}
379
+ style={style}
380
+ classNames={classNames}
381
+ styles={styles}
382
+ ampm={ampm}
383
+ hourHeight={hourHeight}
384
+ scrollOffsetMinutes={scrollOffsetMinutes}
385
+ timeslots={timeslots}
386
+ minHour={minHour}
387
+ maxHour={maxHour}
388
+ hideHours={hideHours}
389
+ showWeekNumber={showWeekNumber}
390
+ weekNumberPrefix={weekNumberPrefix}
391
+ businessHours={businessHours}
392
+ showNowIndicator={showNowIndicator}
393
+ showAllDayEventCell={showAllDayEventCell}
394
+ dragStepMinutes={dragStepMinutes}
395
+ onPressEvent={onPressEvent}
396
+ onPressCell={onPressCell}
397
+ onCreateEvent={onCreateEvent}
398
+ onDragStart={onDragStart}
399
+ onDragEvent={onDragEvent}
400
+ onPressDateHeader={onPressDateHeader}
401
+ renderEvent={renderTimeEvent}
402
+ eventAccessibilityLabel={eventAccessibilityLabel}
403
+ hourComponent={hourComponent}
404
+ keyboardEventNavigation={keyboardEventNavigation}
405
+ />
406
+ );
240
407
  }
241
408
 
409
+ // `display: contents` adds no layout box, so the view renders exactly as before
410
+ // while keydowns from the focused grid still bubble to the paging handler (a
411
+ // no-op when `onChangeDate` is unset). Rendered unconditionally so toggling the
412
+ // handler never remounts the view subtree.
242
413
  return (
243
- <TimeGrid<T>
244
- date={date}
245
- mode={mode}
246
- events={events}
247
- weekStartsOn={weekStartsOn}
248
- weekdayFormat={weekdayFormat}
249
- numberOfDays={numberOfDays}
250
- locale={locale}
251
- theme={theme}
252
- height={height}
253
- className={className}
254
- style={style}
255
- classNames={classNames}
256
- styles={styles}
257
- ampm={ampm}
258
- hourHeight={hourHeight}
259
- scrollOffsetMinutes={scrollOffsetMinutes}
260
- timeslots={timeslots}
261
- businessHours={businessHours}
262
- showNowIndicator={showNowIndicator}
263
- showAllDayEventCell={showAllDayEventCell}
264
- dragStepMinutes={dragStepMinutes}
265
- onPressEvent={onPressEvent}
266
- onPressCell={onPressCell}
267
- onCreateEvent={onCreateEvent}
268
- onDragStart={onDragStart}
269
- onDragEvent={onDragEvent}
270
- onPressDateHeader={onPressDateHeader}
271
- renderEvent={renderTimeEvent}
272
- eventAccessibilityLabel={eventAccessibilityLabel}
273
- hourComponent={hourComponent}
274
- />
414
+ <div style={{ display: "contents" }} onKeyDown={handlePageKeys}>
415
+ {view}
416
+ </div>
275
417
  );
276
418
  }