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