@dxos/react-ui-calendar 0.8.4-staging.ac66bdf99f → 0.9.1-main.c7dcc2e112

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 (36) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/index.mjs +881 -107
  3. package/dist/lib/browser/index.mjs.map +4 -4
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/browser/translations.mjs +16 -0
  6. package/dist/lib/browser/translations.mjs.map +7 -0
  7. package/dist/lib/node-esm/index.mjs +881 -107
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/lib/node-esm/translations.mjs +18 -0
  11. package/dist/lib/node-esm/translations.mjs.map +7 -0
  12. package/dist/types/src/components/Calendar/Calendar.d.ts +40 -21
  13. package/dist/types/src/components/Calendar/Calendar.d.ts.map +1 -1
  14. package/dist/types/src/components/Calendar/Calendar.stories.d.ts +4 -1
  15. package/dist/types/src/components/Calendar/Calendar.stories.d.ts.map +1 -1
  16. package/dist/types/src/components/Calendar/Week.d.ts +30 -0
  17. package/dist/types/src/components/Calendar/Week.d.ts.map +1 -0
  18. package/dist/types/src/components/Calendar/Weekdays.d.ts +18 -0
  19. package/dist/types/src/components/Calendar/Weekdays.d.ts.map +1 -0
  20. package/dist/types/src/components/Calendar/context.d.ts +40 -0
  21. package/dist/types/src/components/Calendar/context.d.ts.map +1 -0
  22. package/dist/types/src/components/Calendar/util.d.ts +47 -0
  23. package/dist/types/src/components/Calendar/util.d.ts.map +1 -1
  24. package/dist/types/src/components/Calendar/util.test.d.ts +2 -0
  25. package/dist/types/src/components/Calendar/util.test.d.ts.map +1 -0
  26. package/dist/types/src/translations.d.ts +1 -1
  27. package/dist/types/src/translations.d.ts.map +1 -1
  28. package/dist/types/tsconfig.tsbuildinfo +1 -1
  29. package/package.json +25 -19
  30. package/src/components/Calendar/Calendar.stories.tsx +63 -3
  31. package/src/components/Calendar/Calendar.tsx +483 -92
  32. package/src/components/Calendar/Week.tsx +488 -0
  33. package/src/components/Calendar/Weekdays.tsx +57 -0
  34. package/src/components/Calendar/context.ts +60 -0
  35. package/src/components/Calendar/util.test.ts +90 -0
  36. package/src/components/Calendar/util.ts +110 -1
@@ -2,12 +2,11 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { createContext } from '@radix-ui/react-context';
6
- import { type Day, addDays, differenceInWeeks, format, startOfDay, startOfWeek } from 'date-fns';
5
+ import { addDays, format, startOfDay } from 'date-fns';
7
6
  import React, {
8
- type Dispatch,
7
+ type KeyboardEvent as ReactKeyboardEvent,
8
+ type PointerEvent as ReactPointerEvent,
9
9
  type PropsWithChildren,
10
- type SetStateAction,
11
10
  forwardRef,
12
11
  useCallback,
13
12
  useEffect,
@@ -21,42 +20,70 @@ import { List, type ListProps, type ListRowRenderer } from 'react-virtualized';
21
20
 
22
21
  import { Event } from '@dxos/async';
23
22
  import { IconButton, useTranslation } from '@dxos/react-ui';
24
- import { composable, composableProps, mx } from '@dxos/ui-theme';
25
-
26
- import { translationKey } from '../../translations';
27
- import { getDate, isSameDay } from './util';
23
+ import { composable, composableProps } from '@dxos/react-ui';
24
+ import { mx } from '@dxos/ui-theme';
25
+
26
+ import { translationKey } from '#translations';
27
+
28
+ import {
29
+ type CalendarContextValue,
30
+ type CalendarController,
31
+ CalendarContextProvider,
32
+ type CalendarScrollEvent,
33
+ type Range,
34
+ useCalendarContext,
35
+ } from './context';
36
+ import { getDate, getRowIndex, gridEpoch, isSameDay } from './util';
37
+ import { CalendarWeek, type CalendarEvent, type CalendarWeekProps } from './Week';
38
+ import { Weekdays } from './Weekdays';
28
39
 
29
40
  const maxRows = 50 * 100;
30
- const start = new Date('1970-01-01');
31
- const size = 48;
41
+ const start = gridEpoch;
42
+ const size = 40;
32
43
  const defaultWidth = 7 * size;
33
44
 
45
+ // Auto-scroll while dragging near a vertical edge.
46
+ const EDGE_SCROLL_ZONE = 32; // px
47
+ const EDGE_SCROLL_MAX_SPEED = 12; // px per frame
48
+
49
+ const DATE_CLASS_NAMES = {
50
+ current: 'ring-2 ring-primary-500',
51
+ today: 'border-2 border-amber-500 bg-amber-500/50 text-inverse-fg',
52
+ busy: 'border border-green-700',
53
+ starred: 'border-2 border-dashed border-amber-500',
54
+ };
55
+
34
56
  //
35
- // Context
57
+ // Range
36
58
  //
37
59
 
38
- type CalendarEvent = {
39
- type: 'scroll';
40
- date: Date;
60
+ /** Normalize an ordered pair of dates into a Range (start-of-day, from <= to). */
61
+ const makeRange = (a: Date, b: Date): Range => {
62
+ const dayA = startOfDay(a);
63
+ const dayB = startOfDay(b);
64
+ return dayA <= dayB ? { from: dayA, to: dayB } : { from: dayB, to: dayA };
41
65
  };
42
66
 
43
- type CalendarContextValue = {
44
- weekStartsOn: Day;
45
- event: Event<CalendarEvent>;
46
- index: number | undefined;
47
- setIndex: Dispatch<SetStateAction<number | undefined>>;
48
- selected: Date | undefined;
49
- setSelected: Dispatch<SetStateAction<Date | undefined>>;
67
+ /** Inclusive day-level membership check. */
68
+ const isInRange = (date: Date, range: Range | undefined): boolean => {
69
+ if (!range) {
70
+ return false;
71
+ }
72
+ const day = startOfDay(date).getTime();
73
+ return day >= range.from.getTime() && day <= range.to.getTime();
50
74
  };
51
75
 
52
- const [CalendarContextProvider, useCalendarContext] = createContext<CalendarContextValue>('Calendar');
53
-
54
- //
55
- // Controller
56
- //
57
-
58
- type CalendarController = {
59
- scrollTo: (date: Date) => void;
76
+ /** Resolve a DOM element back to the Date its cell represents. */
77
+ const cellDate = (el: Element | null): Date | undefined => {
78
+ let current: Element | null = el;
79
+ while (current && current !== document.body) {
80
+ const iso = current.getAttribute?.('data-date');
81
+ if (iso) {
82
+ return new Date(iso);
83
+ }
84
+ current = current.parentElement;
85
+ }
86
+ return undefined;
60
87
  };
61
88
 
62
89
  //
@@ -67,9 +94,11 @@ type CalendarRootProps = PropsWithChildren<Partial<Pick<CalendarContextValue, 'w
67
94
 
68
95
  const CalendarRoot = forwardRef<CalendarController, CalendarRootProps>(
69
96
  ({ children, weekStartsOn = 1 }, forwardedRef) => {
70
- const event = useMemo(() => new Event<CalendarEvent>(), []);
97
+ const event = useMemo(() => new Event<CalendarScrollEvent>(), []);
71
98
  const [selected, setSelected] = useState<Date | undefined>();
72
99
  const [index, setIndex] = useState<number | undefined>();
100
+ const [range, setRange] = useState<Range | undefined>();
101
+ const [pendingRange, setPendingRange] = useState<Range | undefined>();
73
102
 
74
103
  useImperativeHandle(
75
104
  forwardedRef,
@@ -77,6 +106,9 @@ const CalendarRoot = forwardRef<CalendarController, CalendarRootProps>(
77
106
  scrollTo: (date: Date) => {
78
107
  event.emit({ type: 'scroll', date });
79
108
  },
109
+ select: (date: Date) => {
110
+ event.emit({ type: 'select', date });
111
+ },
80
112
  }),
81
113
  [event],
82
114
  );
@@ -89,6 +121,10 @@ const CalendarRoot = forwardRef<CalendarController, CalendarRootProps>(
89
121
  setIndex={setIndex}
90
122
  selected={selected}
91
123
  setSelected={setSelected}
124
+ range={range}
125
+ setRange={setRange}
126
+ pendingRange={pendingRange}
127
+ setPendingRange={setPendingRange}
92
128
  >
93
129
  {children}
94
130
  </CalendarContextProvider>
@@ -121,7 +157,6 @@ const CalendarToolbar = composable<HTMLDivElement, CalendarToolbarProps>(({ clas
121
157
  classNames: ['shrink-0 grid! grid-cols-3 items-center bg-toolbar-surface', classNames],
122
158
  })}
123
159
  ref={forwardedRef}
124
- style={{ width: defaultWidth }}
125
160
  >
126
161
  <div className='flex justify-start'>
127
162
  <IconButton
@@ -143,104 +178,446 @@ CalendarToolbar.displayName = CALENDAR_TOOLBAR_NAME;
143
178
 
144
179
  //
145
180
  // Grid
146
- // TODO(burdon): Key nav.
147
- // TODO(burdon): Drag range.
148
181
  //
149
182
 
150
183
  const CALENDAR_GRID_NAME = 'CalendarGrid';
151
184
 
185
+ /** Semantic kind of a {@link DateMarker}; the grid maps each kind to its own border treatment. */
186
+ export type DateMarkerTag = 'busy' | 'star';
187
+
188
+ /**
189
+ * A date (or inclusive date range) to mark on the grid. */
190
+ export type DateMarker = { startDate: Date; endDate?: Date; tag?: DateMarkerTag };
191
+
152
192
  type CalendarGridProps = {
153
193
  rows?: number;
154
- /** Dates to highlight on the grid. Each date that appears in this array receives a border indicator. */
155
- dates?: Date[];
194
+ /**
195
+ * Dates to mark on the grid; each marked day gets a border keyed off its `tag` kind (defaults to `busy`).
196
+ */
197
+ dates?: DateMarker[];
198
+ /**
199
+ * Date the grid scrolls into view on mount, and whenever this value changes.
200
+ * Defaults to today. Pass a stable (memoized) Date so the grid does not
201
+ * re-scroll on every render.
202
+ */
203
+ initialDate?: Date;
204
+ /**
205
+ * Weeks of context kept above a date when scrolling it into view (on mount and via the controller's
206
+ * `scrollTo`), so the date sits below the top edge rather than pinned to it. Defaults to 2.
207
+ */
208
+ scrollMargin?: number;
209
+ /** Fired when a user selects a single date (click or arrow key). */
156
210
  onSelect?: (event: { date: Date }) => void;
211
+ /**
212
+ * Fired when a user commits a multi-day range, either by a drag gesture or
213
+ * by shift+arrow extension. The range is normalized: `from <= to`, both at
214
+ * start-of-day. Not fired for single-day selections (use {@link onSelect}).
215
+ */
216
+ onSelectRange?: (event: { range: Range }) => void;
157
217
  };
158
218
 
159
219
  const CalendarGrid = composable<HTMLDivElement, CalendarGridProps>(
160
- ({ classNames, rows, dates = [], onSelect, ...props }, forwardedRef) => {
161
- const { weekStartsOn, event, setIndex, selected, setSelected } = useCalendarContext(CALENDAR_GRID_NAME);
220
+ (
221
+ { classNames, rows, dates = [], initialDate, scrollMargin = 2, onSelect, onSelectRange, ...props },
222
+ forwardedRef,
223
+ ) => {
224
+ const { weekStartsOn, event, setIndex, selected, setSelected, range, setRange, pendingRange, setPendingRange } =
225
+ useCalendarContext(CALENDAR_GRID_NAME);
162
226
  const { ref: containerRef, width = 0, height = 0 } = useResizeDetector();
163
227
  const maxHeight = rows ? rows * size : undefined;
164
228
  const listRef = useRef<List>(null);
229
+ const gridRef = useRef<HTMLDivElement>(null);
165
230
  const today = useMemo(() => new Date(), []);
166
231
 
167
- // Build a set of ISO date strings (YYYY-MM-DD) for O(1) per-cell lookup.
168
- const dateSet = useMemo(() => new Set(dates.map((date) => startOfDay(date).toISOString())), [dates]);
232
+ // Map each marked ISO day to its tag kind, expanding ranges. `star` wins over `busy` on the same
233
+ // day so a starred event keeps its highlighted border.
234
+ const dateMarkers = useMemo(() => {
235
+ const markers = new Map<string, DateMarkerTag>();
236
+ for (const { startDate, endDate, tag = 'busy' } of dates) {
237
+ const end = endDate ? startOfDay(endDate) : startOfDay(startDate);
238
+ for (let date = startOfDay(startDate); date <= end; date = addDays(date, 1)) {
239
+ const iso = date.toISOString();
240
+ if (markers.get(iso) !== 'star') {
241
+ markers.set(iso, tag);
242
+ }
243
+ }
244
+ }
245
+
246
+ return markers;
247
+ }, [dates]);
169
248
 
170
- const hasDate = useCallback((date: Date) => dateSet.has(startOfDay(date).toISOString()), [dateSet]);
249
+ const getMarker = useCallback(
250
+ (date: Date): { tag: DateMarkerTag } | undefined => {
251
+ const iso = startOfDay(date).toISOString();
252
+ const tag = dateMarkers.get(iso);
253
+ return tag ? { tag } : undefined;
254
+ },
255
+ [dateMarkers],
256
+ );
171
257
 
172
258
  const [initialized, setInitialized] = useState(false);
173
259
  useEffect(() => {
174
- const index = differenceInWeeks(today, start);
175
- listRef.current?.scrollToRow(index);
176
- }, [initialized, start, today]);
260
+ const index = getRowIndex(start, initialDate ?? today, weekStartsOn);
261
+ // Keep `scrollMargin` weeks of context above the target row.
262
+ listRef.current?.scrollToRow(Math.max(0, index - scrollMargin));
263
+ }, [initialized, start, today, initialDate, weekStartsOn, scrollMargin]);
177
264
 
178
265
  useEffect(() => {
179
266
  return event.on((event) => {
180
- switch (event.type) {
181
- case 'scroll': {
182
- const index = differenceInWeeks(event.date, start);
183
- listRef.current?.scrollToRow(index);
184
- break;
185
- }
267
+ // `select` also sets the grid's selection (e.g. when the active event changes); the grid still
268
+ // owns selection — a user click sets it locally and isn't overwritten until the next `select`.
269
+ if (event.type === 'select') {
270
+ setSelected(event.date);
186
271
  }
272
+ const index = getRowIndex(start, event.date, weekStartsOn);
273
+ listRef.current?.scrollToRow(Math.max(0, index - scrollMargin));
187
274
  });
188
- }, [event]);
275
+ }, [event, start, weekStartsOn, scrollMargin, setSelected]);
276
+
277
+ //
278
+ // Selection refs.
279
+ //
280
+ // `anchorRef` is the immovable end of a range gesture (pointerdown date or
281
+ // initial day when shift+arrow starts). `focusRef` is the moving end
282
+ // (pointer-under-cursor during drag, or the cursor after each shift+arrow).
283
+ // Both refs are kept in sync across mouse drag and keyboard nav so that
284
+ // the user can fluidly mix gestures (e.g., drag a range, then shift+arrow
285
+ // to fine-tune).
286
+ //
287
+ const anchorRef = useRef<Date | undefined>(undefined);
288
+ const focusRef = useRef<Date | undefined>(undefined);
289
+ const draggingRef = useRef(false);
290
+
291
+ // Pointer tracking for edge-scroll while dragging.
292
+ const pointerXRef = useRef<number>(0);
293
+ const pointerYRef = useRef<number>(0);
294
+ const scrollTopRef = useRef(0);
295
+ const scrollRafRef = useRef<number | undefined>(undefined);
296
+
297
+ // Scroll the target date into view only if it's outside the visible window.
298
+ // Horizontal moves (left/right within the same week) leave the row index
299
+ // unchanged and are a no-op.
300
+ const scrollIntoView = useCallback(
301
+ (date: Date) => {
302
+ const targetRow = getRowIndex(start, date, weekStartsOn);
303
+ const visibleHeight = maxHeight ?? height;
304
+ if (!visibleHeight) {
305
+ return;
306
+ }
307
+ // Rows fully inside the viewport. Use ceil/floor (not floor of scrollTop) so a partially
308
+ // visible row at either edge counts as "not fully visible" even when scrollTop is not a
309
+ // multiple of the row height (which it isn't after a bottom-aligned scroll).
310
+ const firstFullyVisibleRow = Math.ceil(scrollTopRef.current / size);
311
+ const lastFullyVisibleRow = Math.floor((scrollTopRef.current + visibleHeight) / size) - 1;
312
+ if (targetRow < firstFullyVisibleRow) {
313
+ // Align the top edge of the target row with the top edge of the viewport.
314
+ listRef.current?.scrollToPosition(targetRow * size);
315
+ } else if (targetRow > lastFullyVisibleRow) {
316
+ // Align the bottom edge of the target row with the bottom edge of the viewport (using the
317
+ // full visible height, not a row-rounded height, so the row sits flush against the edge).
318
+ listRef.current?.scrollToPosition(Math.max(0, (targetRow + 1) * size - visibleHeight));
319
+ }
320
+ },
321
+ [height, maxHeight, weekStartsOn],
322
+ );
189
323
 
190
- const days = useMemo(() => {
191
- const weekStart = startOfWeek(new Date(), { weekStartsOn });
192
- return Array.from({ length: 7 }, (_, i) => {
193
- const day = addDays(weekStart, i);
194
- return format(day, 'EEE'); // Short day name (Mon, Tue, etc.)
195
- });
196
- }, []);
324
+ const updateRangeFromAnchor = useCallback(
325
+ (focus: Date, fireRange = false) => {
326
+ const anchor = anchorRef.current;
327
+ if (!anchor) {
328
+ return;
329
+ }
330
+ focusRef.current = focus;
331
+ if (isSameDay(anchor, focus)) {
332
+ setRange(undefined);
333
+ setSelected(anchor);
334
+ } else {
335
+ setSelected(undefined);
336
+ const committed = makeRange(anchor, focus);
337
+ setRange(committed);
338
+ if (fireRange) {
339
+ onSelectRange?.({ range: committed });
340
+ }
341
+ }
342
+ },
343
+ [onSelectRange, setRange, setSelected],
344
+ );
345
+
346
+ //
347
+ // Drag-to-select range.
348
+ //
349
+ // `prevSelectedRef` snapshots the single-day selection that was active
350
+ // when the gesture began. A click on the *same* already-selected day
351
+ // toggles the selection off on pointerup; a click on any other day (or
352
+ // when no day was selected) just sets the new selection.
353
+ //
354
+ const prevSelectedRef = useRef<Date | undefined>(undefined);
355
+
356
+ const handleDayPointerDown = useCallback(
357
+ (date: Date, ev: ReactPointerEvent<HTMLDivElement>) => {
358
+ ev.preventDefault();
359
+ prevSelectedRef.current = selected;
360
+ anchorRef.current = date;
361
+ focusRef.current = date;
362
+ draggingRef.current = true;
363
+ // Immediate visual feedback: render the single-select ring on the anchor day.
364
+ setRange(undefined);
365
+ setPendingRange(undefined);
366
+ setSelected(date);
367
+ // Focus the grid so subsequent keyboard nav works.
368
+ gridRef.current?.focus({ preventScroll: true });
369
+ },
370
+ [selected, setPendingRange, setRange, setSelected],
371
+ );
197
372
 
198
- // TODO(burdon): Get info by range.
373
+ const handleDayPointerEnter = useCallback(
374
+ (date: Date) => {
375
+ if (!draggingRef.current) {
376
+ return;
377
+ }
378
+ const anchor = anchorRef.current;
379
+ if (!anchor) {
380
+ return;
381
+ }
382
+ focusRef.current = date;
383
+ // Always render a pending range while dragging — even a single-day range
384
+ // (when the pointer is on the anchor cell or returns to it). Otherwise
385
+ // the anchor cell would appear empty mid-drag.
386
+ setSelected(undefined);
387
+ setPendingRange(makeRange(anchor, date));
388
+ },
389
+ [setPendingRange, setSelected],
390
+ );
199
391
 
200
- const handleDaySelect = useCallback(
392
+ const handleDayPointerUp = useCallback(
201
393
  (date: Date) => {
202
- setSelected((current) => (isSameDay(date, current) ? undefined : date));
203
- onSelect?.({ date });
394
+ const anchor = anchorRef.current;
395
+ const wasDragging = draggingRef.current;
396
+ draggingRef.current = false;
397
+ setPendingRange(undefined);
398
+ if (!wasDragging || !anchor) {
399
+ return;
400
+ }
401
+ focusRef.current = date;
402
+ if (isSameDay(anchor, date)) {
403
+ // Single click — toggle off if clicking the previously-selected day,
404
+ // otherwise set as selected. (pointerenter may have cleared the ring
405
+ // mid-drag to show a 1-day pending-range fill; restore here.)
406
+ if (prevSelectedRef.current && isSameDay(prevSelectedRef.current, date)) {
407
+ setSelected(undefined);
408
+ anchorRef.current = undefined;
409
+ focusRef.current = undefined;
410
+ return;
411
+ }
412
+ setSelected(anchor);
413
+ onSelect?.({ date });
414
+ return;
415
+ }
416
+ // Drag commit — `selected` was already cleared by pointerenter.
417
+ const committed = makeRange(anchor, date);
418
+ setRange(committed);
419
+ onSelectRange?.({ range: committed });
204
420
  },
205
- [onSelect],
421
+ [onSelect, onSelectRange, setPendingRange, setRange, setSelected],
206
422
  );
207
423
 
424
+ // Cancel drag if the pointer is released outside the grid.
425
+ useEffect(() => {
426
+ const cancel = () => {
427
+ if (draggingRef.current) {
428
+ draggingRef.current = false;
429
+ setPendingRange(undefined);
430
+ }
431
+ };
432
+ window.addEventListener('pointerup', cancel);
433
+ window.addEventListener('pointercancel', cancel);
434
+ return () => {
435
+ window.removeEventListener('pointerup', cancel);
436
+ window.removeEventListener('pointercancel', cancel);
437
+ };
438
+ }, [setPendingRange]);
439
+
440
+ //
441
+ // Edge auto-scroll while dragging near top/bottom of the grid viewport.
442
+ //
443
+ const tickEdgeScroll = useCallback(() => {
444
+ scrollRafRef.current = undefined;
445
+ if (!draggingRef.current) {
446
+ return;
447
+ }
448
+ const rect = containerRef.current?.getBoundingClientRect();
449
+ if (!rect) {
450
+ return;
451
+ }
452
+ const y = pointerYRef.current;
453
+ let delta = 0;
454
+ if (y < rect.top + EDGE_SCROLL_ZONE) {
455
+ delta = -EDGE_SCROLL_MAX_SPEED * Math.min(1, Math.max(0, (rect.top + EDGE_SCROLL_ZONE - y) / EDGE_SCROLL_ZONE));
456
+ } else if (y > rect.bottom - EDGE_SCROLL_ZONE) {
457
+ delta =
458
+ EDGE_SCROLL_MAX_SPEED * Math.min(1, Math.max(0, (y - (rect.bottom - EDGE_SCROLL_ZONE)) / EDGE_SCROLL_ZONE));
459
+ }
460
+ if (delta !== 0) {
461
+ const newScroll = Math.max(0, scrollTopRef.current + delta);
462
+ listRef.current?.scrollToPosition(newScroll);
463
+ // After scroll, the cell under the (stationary) pointer changes.
464
+ // Look up the new cell and update the pending range accordingly.
465
+ const date = cellDate(document.elementFromPoint(pointerXRef.current, y));
466
+ const anchor = anchorRef.current;
467
+ if (date && anchor) {
468
+ focusRef.current = date;
469
+ if (isSameDay(anchor, date)) {
470
+ setPendingRange(undefined);
471
+ setSelected(anchor);
472
+ } else {
473
+ setSelected(undefined);
474
+ setPendingRange(makeRange(anchor, date));
475
+ }
476
+ }
477
+ scrollRafRef.current = requestAnimationFrame(tickEdgeScroll);
478
+ }
479
+ }, [containerRef, setPendingRange, setSelected]);
480
+
481
+ useEffect(() => {
482
+ const handleMove = (ev: PointerEvent) => {
483
+ if (!draggingRef.current) {
484
+ return;
485
+ }
486
+ pointerXRef.current = ev.clientX;
487
+ pointerYRef.current = ev.clientY;
488
+ if (scrollRafRef.current === undefined) {
489
+ scrollRafRef.current = requestAnimationFrame(tickEdgeScroll);
490
+ }
491
+ };
492
+ window.addEventListener('pointermove', handleMove);
493
+ return () => {
494
+ window.removeEventListener('pointermove', handleMove);
495
+ if (scrollRafRef.current !== undefined) {
496
+ cancelAnimationFrame(scrollRafRef.current);
497
+ scrollRafRef.current = undefined;
498
+ }
499
+ };
500
+ }, [tickEdgeScroll]);
501
+
502
+ //
503
+ // Keyboard nav: arrow keys move single selection; shift+arrow expands range.
504
+ //
505
+ const handleKeyDown = useCallback(
506
+ (ev: ReactKeyboardEvent<HTMLDivElement>) => {
507
+ let dx = 0;
508
+ switch (ev.key) {
509
+ case 'ArrowLeft':
510
+ dx = -1;
511
+ break;
512
+ case 'ArrowRight':
513
+ dx = 1;
514
+ break;
515
+ case 'ArrowUp':
516
+ dx = -7;
517
+ break;
518
+ case 'ArrowDown':
519
+ dx = 7;
520
+ break;
521
+ default:
522
+ return;
523
+ }
524
+ ev.preventDefault();
525
+
526
+ if (ev.shiftKey) {
527
+ // Bootstrap anchor/focus from current state if needed.
528
+ let anchor = anchorRef.current;
529
+ let focus = focusRef.current;
530
+ if (!anchor) {
531
+ // No prior gesture — seed from current selected/range/today.
532
+ if (selected) {
533
+ anchor = startOfDay(selected);
534
+ focus = anchor;
535
+ } else if (range) {
536
+ anchor = range.from;
537
+ focus = range.to;
538
+ } else {
539
+ anchor = startOfDay(today);
540
+ focus = anchor;
541
+ }
542
+ anchorRef.current = anchor;
543
+ focusRef.current = focus;
544
+ }
545
+ const newFocus = addDays(focus ?? anchor, dx);
546
+ updateRangeFromAnchor(newFocus, true);
547
+ scrollIntoView(newFocus);
548
+ } else {
549
+ // Plain arrow — move single selection; clear any range gesture state.
550
+ const current = selected ?? focusRef.current ?? anchorRef.current ?? today;
551
+ const next = addDays(startOfDay(current), dx);
552
+ anchorRef.current = next;
553
+ focusRef.current = next;
554
+ setRange(undefined);
555
+ setPendingRange(undefined);
556
+ setSelected(next);
557
+ onSelect?.({ date: next });
558
+ scrollIntoView(next);
559
+ }
560
+ },
561
+ [onSelect, range, scrollIntoView, selected, setPendingRange, setRange, setSelected, today, updateRangeFromAnchor],
562
+ );
563
+
564
+ const activeRange = pendingRange ?? range;
565
+
208
566
  const handleScroll = useCallback<NonNullable<ListProps['onScroll']>>((info) => {
567
+ scrollTopRef.current = info.scrollTop;
209
568
  setIndex(Math.round(info.scrollTop / size));
210
569
  }, []);
211
570
 
212
571
  const rowRenderer = useCallback<ListRowRenderer>(
213
572
  ({ key, index, style }) => {
214
- const getBgColor = (date: Date) => date.getMonth() % 2 === 0 && 'bg-modal-surface';
573
+ // Zebra-stripe alternating months with a subtle neutral overlay over the grid surface, so
574
+ // the banding is independent of (and robust to) surface-token retuning.
575
+ const getBgColor = (date: Date) => (date.getMonth() % 2 === 0 ? 'bg-group-surface' : 'bg-group-alt-surface');
576
+
215
577
  return (
216
- <div key={key} role='none' style={style} className='grid'>
217
- <div
218
- role='none'
219
- className='grid grid-cols-7 bg-input-surface'
220
- style={{ gridTemplateColumns: `repeat(7, ${size}px)` }}
221
- >
578
+ <div key={key} style={style} className='grid'>
579
+ <div className='grid grid-cols-7 bg-input-surface' style={{ gridTemplateColumns: `repeat(7, ${size}px)` }}>
222
580
  {Array.from({ length: 7 }).map((_, i) => {
223
581
  const date = getDate(start, index, i, weekStartsOn);
224
- const border = isSameDay(date, selected)
225
- ? 'border-primary-500'
226
- : isSameDay(date, today)
227
- ? 'border-amber-500'
228
- : hasDate(date)
229
- ? 'border-neutral-700 border-dashed'
582
+ const marker = getMarker(date);
583
+ const isToday = isSameDay(date, today);
584
+ const isCurrent = isSameDay(date, selected);
585
+ const dateClassNames = isToday
586
+ ? DATE_CLASS_NAMES.today
587
+ : marker?.tag === 'star'
588
+ ? DATE_CLASS_NAMES.starred
589
+ : marker
590
+ ? DATE_CLASS_NAMES.busy
230
591
  : undefined;
231
592
 
593
+ const inRange = isInRange(date, activeRange);
594
+
232
595
  return (
233
596
  <div
234
597
  key={i}
235
- role='none'
236
- className={mx('relative flex justify-center items-center cursor-pointer', getBgColor(date))}
237
- onClick={() => handleDaySelect(date)}
598
+ data-date={startOfDay(date).toISOString()}
599
+ className={mx('relative flex justify-center cursor-pointer select-none', getBgColor(date))}
600
+ onPointerDown={(ev) => handleDayPointerDown(date, ev)}
601
+ onPointerEnter={() => handleDayPointerEnter(date)}
602
+ onPointerUp={() => handleDayPointerUp(date)}
238
603
  >
239
- <span className='text-description'>{date.getDate()}</span>
240
- {!border && date.getDate() === 1 && (
604
+ {/* Selection range */}
605
+ {inRange && <div className='absolute inset-0 bg-primary-500/20' />}
606
+ {/* Month */}
607
+ {!dateClassNames && date.getDate() === 1 && (
241
608
  <span className='absolute top-0 text-xs text-description'>{format(date, 'MMM')}</span>
242
609
  )}
243
- {border && <div role='none' className={mx('absolute inset-1 border-2 rounded-full', border)} />}
610
+ {/* Day + Marker */}
611
+ <div
612
+ className={mx(
613
+ 'absolute inset-1 rounded-full flex justify-center items-center text-sm text-description',
614
+ dateClassNames,
615
+ )}
616
+ >
617
+ {date.getDate()}
618
+ </div>
619
+ {/* Current */}
620
+ {isCurrent && <div className={mx('absolute inset-0.5 rounded-full', DATE_CLASS_NAMES.current)} />}
244
621
  </div>
245
622
  );
246
623
  })}
@@ -248,28 +625,33 @@ const CalendarGrid = composable<HTMLDivElement, CalendarGridProps>(
248
625
  </div>
249
626
  );
250
627
  },
251
- [handleDaySelect, hasDate, selected, weekStartsOn],
628
+ [activeRange, handleDayPointerDown, handleDayPointerEnter, handleDayPointerUp, getMarker, selected, weekStartsOn],
252
629
  );
253
630
 
254
631
  return (
255
632
  <div
256
633
  {...composableProps(props, {
257
634
  role: 'none',
258
- classNames: ['flex flex-col h-full w-full justify-center overflow-hidden', classNames],
635
+ classNames: ['flex flex-col h-full w-full justify-center overflow-hidden outline-hidden', classNames],
259
636
  })}
260
- ref={forwardedRef}
637
+ ref={(node: HTMLDivElement | null) => {
638
+ gridRef.current = node;
639
+ if (typeof forwardedRef === 'function') {
640
+ forwardedRef(node);
641
+ } else if (forwardedRef) {
642
+ (forwardedRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
643
+ }
644
+ }}
645
+ tabIndex={0}
646
+ onKeyDown={handleKeyDown}
261
647
  >
262
648
  {/* Day of week labels */}
263
- <div role='none' className='grid w-full grid-cols-7' style={{ width: defaultWidth }}>
264
- {days.map((date, i) => (
265
- <div key={i} role='none' className='flex justify-center p-2 text-sm font-thin'>
266
- {date}
267
- </div>
268
- ))}
649
+ <div style={{ width: defaultWidth }}>
650
+ <Weekdays weekStartsOn={weekStartsOn} columnWidth={size} />
269
651
  </div>
270
652
 
271
653
  {/* Grid */}
272
- <div role='none' className='flex flex-col h-full w-full justify-center overflow-hidden' ref={containerRef}>
654
+ <div className='flex flex-col h-full w-full justify-center overflow-hidden' ref={containerRef}>
273
655
  <List
274
656
  ref={listRef}
275
657
  role='none'
@@ -299,6 +681,15 @@ export const Calendar = {
299
681
  Root: CalendarRoot,
300
682
  Toolbar: CalendarToolbar,
301
683
  Grid: CalendarGrid,
684
+ Week: CalendarWeek,
302
685
  };
303
686
 
304
- export type { CalendarController, CalendarRootProps, CalendarToolbarProps, CalendarGridProps };
687
+ export type {
688
+ CalendarController,
689
+ CalendarEvent,
690
+ CalendarGridProps,
691
+ CalendarRootProps,
692
+ CalendarToolbarProps,
693
+ CalendarWeekProps,
694
+ Range,
695
+ };