@super-calendar/dom 2.3.1 → 2.4.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/src/TimeGrid.tsx CHANGED
@@ -1,7 +1,8 @@
1
- import { addDays, addMinutes, format, type Locale, startOfDay } from "date-fns";
1
+ import { addDays, addMinutes, format, getISOWeek, type Locale, startOfDay } from "date-fns";
2
2
  import {
3
3
  type ComponentType,
4
4
  type CSSProperties,
5
+ type KeyboardEvent as ReactKeyboardEvent,
5
6
  type PointerEvent as ReactPointerEvent,
6
7
  type ReactElement,
7
8
  type ReactNode,
@@ -42,6 +43,7 @@ import { type DomCalendarTheme, mergeDomTheme } from "./theme";
42
43
  */
43
44
  export type TimeGridSlot =
44
45
  | "header"
46
+ | "weekNumber"
45
47
  | "columnHeader"
46
48
  | "columnHeaderWeekday"
47
49
  | "columnHeaderDate"
@@ -59,10 +61,24 @@ export type TimeGridSlot =
59
61
  | "nowIndicator"
60
62
  | "createGhost";
61
63
 
62
- const HOURS = Array.from({ length: 24 }, (_, h) => h);
63
64
  const GUTTER_WIDTH = 56;
64
65
  const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
65
66
 
67
+ // Off-screen but readable by assistive tech: gives an element an accessible name
68
+ // without changing the visible layout.
69
+ const VISUALLY_HIDDEN: CSSProperties = {
70
+ position: "absolute",
71
+ width: 1,
72
+ height: 1,
73
+ padding: 0,
74
+ margin: -1,
75
+ overflow: "hidden",
76
+ clip: "rect(0 0 0 0)",
77
+ clipPath: "inset(50%)",
78
+ whiteSpace: "nowrap",
79
+ border: 0,
80
+ };
81
+
66
82
  /** Props passed to a custom time-grid event renderer. */
67
83
  export interface DomRenderEventArgs<T = unknown> {
68
84
  /** The event to render. */
@@ -116,6 +132,16 @@ export interface TimeGridProps<T = unknown> extends SlotStyleProps<TimeGridSlot>
116
132
  ampm?: boolean;
117
133
  /** Sub-divisions per hour for the grid lines, e.g. 2 for half-hour (default 1). */
118
134
  timeslots?: number;
135
+ /** First hour shown (0–23). Default 0. */
136
+ minHour?: number;
137
+ /** Last hour shown, exclusive (1–24). Default 24. */
138
+ maxHour?: number;
139
+ /** Hide the left hour-axis column (lines stay, labels/gutter go). Default false. */
140
+ hideHours?: boolean;
141
+ /** Show the ISO week number in the header gutter. Default false. */
142
+ showWeekNumber?: boolean;
143
+ /** Prefix for the week number, e.g. "W" → "W28". Default "W". */
144
+ weekNumberPrefix?: string;
119
145
  /** Shade the hours outside business hours; `null` shades the whole day. */
120
146
  businessHours?: BusinessHours;
121
147
  /** Show the current-time indicator on today's column (default true). */
@@ -139,6 +165,14 @@ export interface TimeGridProps<T = unknown> extends SlotStyleProps<TimeGridSlot>
139
165
  eventAccessibilityLabel?: EventAccessibilityLabeler<T>;
140
166
  /** Replace the hour-axis label. Receives the hour (0-23) and the `ampm` flag. */
141
167
  hourComponent?: (hour: number, ampm: boolean) => ReactNode;
168
+ /**
169
+ * Add arrow-key navigation between events. Up/Down move between a day's events by
170
+ * time, Left/Right jump to the nearest event in the adjacent day, Home/End go to
171
+ * the day's first/last event; Enter/Space activate. Additive: every event stays
172
+ * individually tabbable (so screen-reader users keep full access), so this is a
173
+ * convenience for sighted keyboard users. Default false.
174
+ */
175
+ keyboardEventNavigation?: boolean;
142
176
  /** Tap an event. */
143
177
  onPressEvent?: (event: CalendarEvent<T>) => void;
144
178
  /** Tap a day's column header. */
@@ -165,6 +199,10 @@ type DragState = {
165
199
  kind: "move" | "resize";
166
200
  startHours: number;
167
201
  durationHours: number;
202
+ /** Whole day columns the box has been dragged across, clamped to the view. */
203
+ dayDelta: number;
204
+ /** Pixel equivalent of dayDelta, applied as a transform on the dragged box. */
205
+ dayOffsetPx: number;
168
206
  moved: boolean;
169
207
  };
170
208
 
@@ -264,6 +302,22 @@ function DefaultDomEvent<T>({
264
302
  );
265
303
  }
266
304
 
305
+ const NOW_TICK_MS = 60_000;
306
+
307
+ // A `Date` that advances every minute while `enabled`, so the now-indicator
308
+ // tracks the wall clock instead of freezing at the last render. Mirrors the
309
+ // native renderer's `useNow`.
310
+ function useNow(enabled: boolean): Date {
311
+ const [now, setNow] = useState(() => new Date());
312
+ useEffect(() => {
313
+ if (!enabled) return;
314
+ setNow(new Date());
315
+ const id = setInterval(() => setNow(new Date()), NOW_TICK_MS);
316
+ return () => clearInterval(id);
317
+ }, [enabled]);
318
+ return now;
319
+ }
320
+
267
321
  /**
268
322
  * A day / week / N-day time grid rendered with plain DOM elements. Events are
269
323
  * positioned with the library's pure `layoutDayEvents`, so overlap columns and
@@ -290,6 +344,12 @@ export function TimeGrid<T = unknown>({
290
344
  dragStepMinutes = 15,
291
345
  ampm = false,
292
346
  timeslots = 1,
347
+ minHour = 0,
348
+ maxHour = 24,
349
+ hideHours = false,
350
+ showWeekNumber = false,
351
+ weekNumberPrefix = "W",
352
+ keyboardEventNavigation = false,
293
353
  businessHours,
294
354
  showNowIndicator = true,
295
355
  showAllDayEventCell = true,
@@ -316,6 +376,20 @@ export function TimeGrid<T = unknown>({
316
376
  const dfns = locale ? { locale } : undefined;
317
377
  const snapHours = dragStepMinutes / 60;
318
378
 
379
+ // The visible hour window [windowStart, windowEnd). Clamped the same way as the
380
+ // native renderer so out-of-range props can't invert or overflow the day.
381
+ const windowStart = Math.max(0, Math.min(minHour, 23));
382
+ const windowEnd = Math.max(windowStart + 1, Math.min(maxHour, 24));
383
+ const windowHours = windowEnd - windowStart;
384
+ const gutterWidth = hideHours ? 0 : GUTTER_WIDTH;
385
+ const visibleHours = useMemo(
386
+ () => Array.from({ length: windowHours }, (_, i) => windowStart + i),
387
+ [windowStart, windowHours],
388
+ );
389
+
390
+ // Ticks every minute so the red now-line follows the wall clock.
391
+ const now = useNow(showNowIndicator);
392
+
319
393
  const [hourHeight, setHourHeight] = useState(initialHourHeight);
320
394
  useEffect(() => setHourHeight(initialHourHeight), [initialHourHeight]);
321
395
  const hourHeightRef = useRef(hourHeight);
@@ -325,9 +399,14 @@ export function TimeGrid<T = unknown>({
325
399
  // handlers read, so they never see a stale state closure between events.
326
400
  const [drag, setDrag] = useState<DragState | null>(null);
327
401
  const dragRef = useRef<DragState | null>(null);
328
- const dragOrigin = useRef<{ pointerY: number; startHours: number; durationHours: number } | null>(
329
- null,
330
- );
402
+ const dragOrigin = useRef<{
403
+ pointerX: number;
404
+ pointerY: number;
405
+ startHours: number;
406
+ durationHours: number;
407
+ dayIndex: number;
408
+ dayWidth: number;
409
+ } | null>(null);
331
410
  const applyDrag = (next: DragState | null) => {
332
411
  dragRef.current = next;
333
412
  setDrag(next);
@@ -363,9 +442,13 @@ export function TimeGrid<T = unknown>({
363
442
 
364
443
  useEffect(() => {
365
444
  // On mount / when the offset prop changes, not on every zoom (hence the ref).
445
+ // The offset is measured from midnight, so subtract the window's start hour.
366
446
  if (scrollRef.current)
367
- scrollRef.current.scrollTop = (scrollOffsetMinutes / 60) * hourHeightRef.current;
368
- }, [scrollOffsetMinutes]);
447
+ scrollRef.current.scrollTop = Math.max(
448
+ 0,
449
+ (scrollOffsetMinutes / 60 - windowStart) * hourHeightRef.current,
450
+ );
451
+ }, [scrollOffsetMinutes, windowStart]);
369
452
 
370
453
  // Zoom: Ctrl/⌘ + wheel (native listener so we can preventDefault), plus
371
454
  // two-pointer pinch. Both scale hourHeight about the current view.
@@ -407,7 +490,7 @@ export function TimeGrid<T = unknown>({
407
490
  };
408
491
 
409
492
  const Renderer = renderEvent;
410
- const totalHeight = 24 * hourHeight;
493
+ const totalHeight = windowHours * hourHeight;
411
494
 
412
495
  const beginDrag = (
413
496
  e: ReactPointerEvent,
@@ -416,6 +499,7 @@ export function TimeGrid<T = unknown>({
416
499
  kind: "move" | "resize",
417
500
  startHours: number,
418
501
  durationHours: number,
502
+ dayIndex: number,
419
503
  ) => {
420
504
  if (!onDragEvent) return;
421
505
  e.stopPropagation();
@@ -424,8 +508,20 @@ export function TimeGrid<T = unknown>({
424
508
  } catch {
425
509
  // Pointer capture is best-effort; some environments reject it.
426
510
  }
427
- dragOrigin.current = { pointerY: e.clientY, startHours, durationHours };
428
- applyDrag({ key, kind, startHours, durationHours, moved: false });
511
+ // The event box is positioned inside its day column, so the parent's width
512
+ // is one day; columns are equal-width, so measuring one is enough. Resizes
513
+ // never move across days, so 0 is fine there.
514
+ const column = kind === "move" ? (e.currentTarget as HTMLElement).parentElement : null;
515
+ const dayWidth = column ? column.getBoundingClientRect().width : 0;
516
+ dragOrigin.current = {
517
+ pointerX: e.clientX,
518
+ pointerY: e.clientY,
519
+ startHours,
520
+ durationHours,
521
+ dayIndex,
522
+ dayWidth,
523
+ };
524
+ applyDrag({ key, kind, startHours, durationHours, dayDelta: 0, dayOffsetPx: 0, moved: false });
429
525
  onDragStart?.(event);
430
526
  };
431
527
  const cancelDrag = () => {
@@ -440,17 +536,25 @@ export function TimeGrid<T = unknown>({
440
536
  const dHours = (e.clientY - dragOrigin.current.pointerY) / hourHeightRef.current;
441
537
  const snap = (v: number) => Math.round(v / snapHours) * snapHours;
442
538
  if (d.kind === "move") {
539
+ // Guard the upper bound: an event taller than the window would otherwise
540
+ // invert the clamp (`windowEnd - duration < windowStart`) and teleport.
443
541
  const startHours = clamp(
444
542
  snap(dragOrigin.current.startHours + dHours),
445
- 0,
446
- 24 - d.durationHours,
543
+ windowStart,
544
+ Math.max(windowStart, windowEnd - d.durationHours),
447
545
  );
448
- applyDrag({ ...d, startHours, moved: true });
546
+ // Map the horizontal drag to whole day columns, clamped so the event
547
+ // can't leave the visible range (mirrors the native renderer).
548
+ const o = dragOrigin.current;
549
+ const rawDayDelta = o.dayWidth > 0 ? Math.round((e.clientX - o.pointerX) / o.dayWidth) : 0;
550
+ const targetDay = clamp(o.dayIndex + rawDayDelta, 0, days.length - 1);
551
+ const dayDelta = targetDay - o.dayIndex;
552
+ applyDrag({ ...d, startHours, dayDelta, dayOffsetPx: dayDelta * o.dayWidth, moved: true });
449
553
  } else {
450
554
  const durationHours = clamp(
451
555
  snap(dragOrigin.current.durationHours + dHours),
452
556
  snapHours,
453
- 24 - d.startHours,
557
+ Math.max(snapHours, windowEnd - d.startHours),
454
558
  );
455
559
  applyDrag({ ...d, durationHours, moved: true });
456
560
  }
@@ -473,7 +577,8 @@ export function TimeGrid<T = unknown>({
473
577
  onPress();
474
578
  return;
475
579
  }
476
- const base = startOfDay(day);
580
+ // The horizontal delta lands the event on another visible day column.
581
+ const base = startOfDay(addDays(day, d.dayDelta));
477
582
  const start = addMinutes(base, Math.round(d.startHours * 60));
478
583
  const end = addMinutes(base, Math.round((d.startHours + d.durationHours) * 60));
479
584
  onDragEvent?.(event, start, end);
@@ -520,10 +625,10 @@ export function TimeGrid<T = unknown>({
520
625
  const moved = Math.abs(endPx - o.startPx) > 4;
521
626
  const h = hourHeightRef.current;
522
627
  if (moved && onCreateEvent) {
523
- const range = cellRangeFromDrag(day, o.startPx, endPx, h, 0, dragStepMinutes);
628
+ const range = cellRangeFromDrag(day, o.startPx, endPx, h, windowStart, dragStepMinutes);
524
629
  if (range) onCreateEvent(range.start, range.end);
525
630
  } else if (onPressCell) {
526
- const at = cellRangeFromDrag(day, o.startPx, o.startPx, h, 0, dragStepMinutes);
631
+ const at = cellRangeFromDrag(day, o.startPx, o.startPx, h, windowStart, dragStepMinutes);
527
632
  if (at) onPressCell(at.start);
528
633
  }
529
634
  createOrigin.current = null;
@@ -543,11 +648,88 @@ export function TimeGrid<T = unknown>({
543
648
  () => days.map((day) => layoutDayEvents(events, day)),
544
649
  [days, events],
545
650
  );
546
- // The dom grid always renders a full 0–24 window (no minHour/maxHour props), so
547
- // closedHourBands uses its 0/24 defaults.
651
+
652
+ // Arrow-key navigation across events (`keyboardEventNavigation`). This is purely
653
+ // additive: every event stays a tab stop (so screen-reader users keep full
654
+ // access), and the arrow keys are a convenience for sighted keyboard users. It's
655
+ // deliberately NOT a roving tabindex — that needs a composite container role
656
+ // (grid/listbox), which this overlapping, absolutely-positioned layout can't
657
+ // honestly claim, and without one it would strip events from the tab order for
658
+ // exactly the screen-reader users it's meant to help. Events are keyed `day:idx`
659
+ // to match the rendered chips and sorted by start time so Up/Down step through a
660
+ // day chronologically.
661
+ const navByDay = useMemo(
662
+ () =>
663
+ positionedByDay.map((list, day) =>
664
+ list
665
+ .map((pe, idx) => ({ key: `${day}:${idx}`, start: pe.startHours, dur: pe.durationHours }))
666
+ .filter((n) => !(n.start >= windowEnd || n.start + n.dur <= windowStart))
667
+ .sort((a, b) => a.start - b.start),
668
+ ),
669
+ [positionedByDay, windowStart, windowEnd],
670
+ );
671
+ // The event to move focus to for an arrow/Home/End key, or null to stay put.
672
+ const nextEventKey = (currentKey: string, arrowKey: string): string | null => {
673
+ let day = -1;
674
+ let pos = -1;
675
+ for (let d = 0; d < navByDay.length; d++) {
676
+ const p = navByDay[d].findIndex((n) => n.key === currentKey);
677
+ if (p !== -1) {
678
+ day = d;
679
+ pos = p;
680
+ break;
681
+ }
682
+ }
683
+ if (day === -1) return null;
684
+ const start = navByDay[day][pos].start;
685
+ // The event in day `d` whose start time is closest to the current one.
686
+ const nearestInDay = (d: number): string | null => {
687
+ let best: string | null = null;
688
+ let bestDiff = Number.POSITIVE_INFINITY;
689
+ for (const n of navByDay[d]) {
690
+ const diff = Math.abs(n.start - start);
691
+ if (diff < bestDiff) {
692
+ bestDiff = diff;
693
+ best = n.key;
694
+ }
695
+ }
696
+ return best;
697
+ };
698
+ switch (arrowKey) {
699
+ case "ArrowDown":
700
+ return navByDay[day][Math.min(pos + 1, navByDay[day].length - 1)].key;
701
+ case "ArrowUp":
702
+ return navByDay[day][Math.max(pos - 1, 0)].key;
703
+ case "ArrowRight":
704
+ for (let d = day + 1; d < navByDay.length; d++) {
705
+ const k = nearestInDay(d);
706
+ if (k) return k;
707
+ }
708
+ return null;
709
+ case "ArrowLeft":
710
+ for (let d = day - 1; d >= 0; d--) {
711
+ const k = nearestInDay(d);
712
+ if (k) return k;
713
+ }
714
+ return null;
715
+ case "Home":
716
+ return navByDay[day][0].key;
717
+ case "End":
718
+ return navByDay[day][navByDay[day].length - 1].key;
719
+ default:
720
+ return null;
721
+ }
722
+ };
723
+ const onEventKeyDown = (currentKey: string, e: ReactKeyboardEvent) => {
724
+ const next = nextEventKey(currentKey, e.key);
725
+ if (!next) return;
726
+ e.preventDefault();
727
+ scrollRef.current?.querySelector<HTMLElement>(`[data-event-key="${next}"]`)?.focus();
728
+ };
729
+ // Shade only the closed hours inside the visible window.
548
730
  const bandsByDay = useMemo(
549
- () => days.map((day) => closedHourBands(day, businessHours)),
550
- [days, businessHours],
731
+ () => days.map((day) => closedHourBands(day, businessHours, windowStart, windowEnd)),
732
+ [days, businessHours, windowStart, windowEnd],
551
733
  );
552
734
  const gridLines = useMemo(() => {
553
735
  const hourLines = `repeating-linear-gradient(to bottom, transparent 0, transparent ${hourHeight - 1}px, ${theme.gridLine} ${hourHeight - 1}px, ${theme.gridLine} ${hourHeight}px)`;
@@ -574,36 +756,56 @@ export function TimeGrid<T = unknown>({
574
756
  themed: { borderBottom: `1px solid ${theme.gridLine}` },
575
757
  })}
576
758
  >
577
- <div style={{ width: GUTTER_WIDTH, flex: "none" }} />
759
+ <div
760
+ {...slot("weekNumber", {
761
+ base: {
762
+ width: gutterWidth,
763
+ flex: "none",
764
+ display: "flex",
765
+ alignItems: "center",
766
+ justifyContent: "center",
767
+ overflow: "hidden",
768
+ },
769
+ themed: { fontSize: 10, color: theme.textMuted },
770
+ })}
771
+ >
772
+ {showWeekNumber && gutterWidth > 0 && days[0]
773
+ ? // Reference the visible Thursday: an ISO week is defined by its Thursday,
774
+ // so a Sunday-start week (days[0] is Sunday, the previous ISO week's last
775
+ // day) still shows the week number its Mon–Sat body belongs to.
776
+ `${weekNumberPrefix}${getISOWeek(days.find((d) => d.getDay() === 4) ?? days[0])}`
777
+ : null}
778
+ </div>
578
779
  {days.map((day) => {
579
780
  const today = getIsToday(day);
580
- return (
581
- <button
582
- key={day.toISOString()}
583
- type="button"
584
- tabIndex={onPressDateHeader ? 0 : -1}
585
- aria-hidden={onPressDateHeader ? undefined : true}
586
- onClick={onPressDateHeader ? () => onPressDateHeader(day) : undefined}
587
- {...dataState({ "data-today": today })}
588
- {...slot("columnHeader", {
589
- base: {
590
- flex: 1,
591
- border: "none",
592
- background: "transparent",
593
- font: "inherit",
594
- cursor: onPressDateHeader ? "pointer" : "default",
595
- display: "flex",
596
- flexDirection: "column",
597
- alignItems: "center",
598
- gap: 2,
599
- },
600
- themed: { color: theme.textMuted, padding: "6px 0" },
601
- })}
602
- >
603
- <span {...slot("columnHeaderWeekday", { themed: { fontSize: 11, fontWeight: 600 } })}>
781
+ // Full, unambiguous date for assistive tech; the visible weekday + day
782
+ // number below are decorative (aria-hidden) so it isn't read twice.
783
+ const dateLabel = format(day, "EEEE, d MMMM yyyy", dfns);
784
+ const headerProps = slot("columnHeader", {
785
+ base: {
786
+ flex: 1,
787
+ border: "none",
788
+ background: "transparent",
789
+ font: "inherit",
790
+ cursor: onPressDateHeader ? "pointer" : "default",
791
+ display: "flex",
792
+ flexDirection: "column",
793
+ alignItems: "center",
794
+ gap: 2,
795
+ },
796
+ themed: { color: theme.textMuted, padding: "6px 0" },
797
+ });
798
+ const inner = (
799
+ <>
800
+ <span style={VISUALLY_HIDDEN}>{dateLabel}</span>
801
+ <span
802
+ aria-hidden
803
+ {...slot("columnHeaderWeekday", { themed: { fontSize: 11, fontWeight: 600 } })}
804
+ >
604
805
  {format(day, weekdayFormatToken(weekdayFormat), dfns)}
605
806
  </span>
606
807
  <span
808
+ aria-hidden
607
809
  {...dataState({ "data-today": today })}
608
810
  {...slot("columnHeaderDate", {
609
811
  base: {
@@ -624,7 +826,25 @@ export function TimeGrid<T = unknown>({
624
826
  >
625
827
  {format(day, "d", dfns)}
626
828
  </span>
829
+ </>
830
+ );
831
+ // Interactive → a real, labeled button. Otherwise a labeled, non-focusable
832
+ // element that screen readers can still announce (never `aria-hidden`, so
833
+ // the day columns aren't invisible to assistive tech).
834
+ return onPressDateHeader ? (
835
+ <button
836
+ key={day.toISOString()}
837
+ type="button"
838
+ onClick={() => onPressDateHeader(day)}
839
+ {...dataState({ "data-today": today })}
840
+ {...headerProps}
841
+ >
842
+ {inner}
627
843
  </button>
844
+ ) : (
845
+ <div key={day.toISOString()} {...dataState({ "data-today": today })} {...headerProps}>
846
+ {inner}
847
+ </div>
628
848
  );
629
849
  })}
630
850
  </div>
@@ -639,7 +859,15 @@ export function TimeGrid<T = unknown>({
639
859
  >
640
860
  <div
641
861
  {...slot("allDayLabel", {
642
- base: { width: GUTTER_WIDTH, flex: "none", textAlign: "right" },
862
+ // `minWidth: 0` + `overflow: hidden` so the text doesn't spill when
863
+ // `hideHours` collapses the gutter to zero width.
864
+ base: {
865
+ width: gutterWidth,
866
+ flex: "none",
867
+ minWidth: 0,
868
+ overflow: "hidden",
869
+ textAlign: "right",
870
+ },
643
871
  themed: { fontSize: 10, color: theme.textMuted, padding: "4px 6px 0 0" },
644
872
  })}
645
873
  >
@@ -734,31 +962,43 @@ export function TimeGrid<T = unknown>({
734
962
  }}
735
963
  >
736
964
  <div style={{ display: "flex", height: totalHeight, position: "relative" }}>
737
- {/* Hour gutter */}
738
- <div
739
- {...slot("hourGutter", {
740
- base: { width: GUTTER_WIDTH, flex: "none", position: "relative" },
741
- })}
742
- >
743
- {HOURS.map((h) => (
744
- <div
745
- key={h}
746
- {...slot("hourLabel", {
747
- base: { position: "absolute", top: h * hourHeight - 6, right: 6 },
748
- themed: { fontSize: 10, color: theme.textMuted },
749
- })}
750
- >
751
- {hourComponent ? hourComponent(h, ampm) : h === 0 ? "" : formatHour(h, { ampm })}
752
- </div>
753
- ))}
754
- </div>
965
+ {/* Hour gutter (hidden when hideHours; the grid lines stay). */}
966
+ {hideHours ? null : (
967
+ <div
968
+ {...slot("hourGutter", {
969
+ base: { width: gutterWidth, flex: "none", position: "relative" },
970
+ })}
971
+ >
972
+ {visibleHours.map((h) => (
973
+ <div
974
+ key={h}
975
+ {...slot("hourLabel", {
976
+ base: {
977
+ position: "absolute",
978
+ // Clamp so the top-of-grid label (`h === windowStart`) sits at
979
+ // the edge instead of clipping 6px above it.
980
+ top: Math.max(0, (h - windowStart) * hourHeight - 6),
981
+ right: 6,
982
+ },
983
+ themed: { fontSize: 10, color: theme.textMuted },
984
+ })}
985
+ >
986
+ {hourComponent ? hourComponent(h, ampm) : h === 0 ? "" : formatHour(h, { ampm })}
987
+ </div>
988
+ ))}
989
+ </div>
990
+ )}
755
991
 
756
992
  {/* Day columns */}
757
993
  {days.map((day, dayIndex) => {
758
994
  const positioned = positionedByDay[dayIndex];
759
- const showNow = showNowIndicator && isSameCalendarDay(day, new Date());
760
- const nowDate = new Date();
761
- const nowTop = ((nowDate.getHours() * 60 + nowDate.getMinutes()) / 60) * hourHeight;
995
+ const nowHours = (now.getHours() * 60 + now.getMinutes()) / 60;
996
+ const showNow =
997
+ showNowIndicator &&
998
+ isSameCalendarDay(day, now) &&
999
+ nowHours >= windowStart &&
1000
+ nowHours <= windowEnd;
1001
+ const nowTop = (nowHours - windowStart) * hourHeight;
762
1002
  const bands = bandsByDay[dayIndex];
763
1003
  const ghost = createBox?.dayIndex === dayIndex ? createBox : null;
764
1004
  return (
@@ -792,7 +1032,7 @@ export function TimeGrid<T = unknown>({
792
1032
  position: "absolute",
793
1033
  left: 0,
794
1034
  right: 0,
795
- top: b.start * hourHeight,
1035
+ top: (b.start - windowStart) * hourHeight,
796
1036
  height: (b.end - b.start) * hourHeight,
797
1037
  pointerEvents: "none",
798
1038
  zIndex: 0,
@@ -814,7 +1054,14 @@ export function TimeGrid<T = unknown>({
814
1054
  const active = drag?.key === key ? drag : null;
815
1055
  const startHours = active ? active.startHours : pe.startHours;
816
1056
  const durationHours = active ? active.durationHours : pe.durationHours;
817
- const top = startHours * hourHeight;
1057
+ // Drop events that fall entirely outside the visible window; those
1058
+ // that straddle an edge render clipped by the scroll container.
1059
+ if (
1060
+ !active &&
1061
+ (pe.startHours >= windowEnd || pe.startHours + pe.durationHours <= windowStart)
1062
+ )
1063
+ return null;
1064
+ const top = (startHours - windowStart) * hourHeight;
818
1065
  const boxHeight = Math.max(durationHours * hourHeight, 14);
819
1066
  const widthPct = 100 / pe.columns;
820
1067
  const onPress = () => onPressEvent?.(pe.event);
@@ -833,7 +1080,10 @@ export function TimeGrid<T = unknown>({
833
1080
  <div
834
1081
  key={idx}
835
1082
  role="button"
1083
+ // Always a tab stop: keyboard nav is additive, arrows only add a
1084
+ // faster path between events without removing any from Tab order.
836
1085
  tabIndex={0}
1086
+ data-event-key={keyboardEventNavigation ? key : undefined}
837
1087
  aria-label={
838
1088
  eventAccessibilityLabel
839
1089
  ? eventAccessibilityLabel(pe.event, { mode, isAllDay: false, ampm })
@@ -849,12 +1099,22 @@ export function TimeGrid<T = unknown>({
849
1099
  if (e.key === "Enter" || e.key === " ") {
850
1100
  e.preventDefault();
851
1101
  onPress();
1102
+ return;
852
1103
  }
1104
+ if (keyboardEventNavigation) onEventKeyDown(key, e);
853
1105
  }}
854
1106
  onPointerDown={
855
1107
  draggable
856
1108
  ? (e) =>
857
- beginDrag(e, pe.event, key, "move", pe.startHours, pe.durationHours)
1109
+ beginDrag(
1110
+ e,
1111
+ pe.event,
1112
+ key,
1113
+ "move",
1114
+ pe.startHours,
1115
+ pe.durationHours,
1116
+ dayIndex,
1117
+ )
858
1118
  : undefined
859
1119
  }
860
1120
  onPointerMove={draggable ? moveDrag : undefined}
@@ -879,6 +1139,11 @@ export function TimeGrid<T = unknown>({
879
1139
  touchAction: draggable ? "none" : "auto",
880
1140
  zIndex: active ? 3 : 1,
881
1141
  opacity: active ? 0.85 : 1,
1142
+ // Snap the dragged box over the target day column so the
1143
+ // drop location is visible before release.
1144
+ ...(active && active.dayOffsetPx !== 0
1145
+ ? { transform: `translateX(${active.dayOffsetPx}px)` }
1146
+ : null),
882
1147
  },
883
1148
  })}
884
1149
  >
@@ -891,7 +1156,15 @@ export function TimeGrid<T = unknown>({
891
1156
  <div
892
1157
  onPointerDown={(e) => {
893
1158
  e.stopPropagation();
894
- beginDrag(e, pe.event, key, "resize", pe.startHours, pe.durationHours);
1159
+ beginDrag(
1160
+ e,
1161
+ pe.event,
1162
+ key,
1163
+ "resize",
1164
+ pe.startHours,
1165
+ pe.durationHours,
1166
+ dayIndex,
1167
+ );
895
1168
  }}
896
1169
  style={{
897
1170
  position: "absolute",