@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/dist/index.d.mts +94 -223
- package/dist/index.d.ts +94 -223
- package/dist/index.js +640 -139
- package/dist/index.mjs +643 -142
- package/package.json +2 -2
- package/src/Calendar.tsx +192 -50
- package/src/ResourceTimeline.tsx +406 -11
- package/src/TimeGrid.tsx +345 -72
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<{
|
|
329
|
-
|
|
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 = (
|
|
368
|
-
|
|
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 =
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
446
|
-
|
|
543
|
+
windowStart,
|
|
544
|
+
Math.max(windowStart, windowEnd - d.durationHours),
|
|
447
545
|
);
|
|
448
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
547
|
-
//
|
|
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
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
|
760
|
-
const
|
|
761
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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",
|