@dxos/react-ui-calendar 0.9.0 → 0.9.1-staging.ee54ba693a
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/lib/browser/index.mjs +582 -107
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +582 -107
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/components/Calendar/Calendar.d.ts +25 -35
- package/dist/types/src/components/Calendar/Calendar.d.ts.map +1 -1
- package/dist/types/src/components/Calendar/Calendar.stories.d.ts +2 -0
- package/dist/types/src/components/Calendar/Calendar.stories.d.ts.map +1 -1
- package/dist/types/src/components/Calendar/Week.d.ts +30 -0
- package/dist/types/src/components/Calendar/Week.d.ts.map +1 -0
- package/dist/types/src/components/Calendar/Weekdays.d.ts +18 -0
- package/dist/types/src/components/Calendar/Weekdays.d.ts.map +1 -0
- package/dist/types/src/components/Calendar/context.d.ts +40 -0
- package/dist/types/src/components/Calendar/context.d.ts.map +1 -0
- package/dist/types/src/components/Calendar/util.d.ts +36 -0
- package/dist/types/src/components/Calendar/util.d.ts.map +1 -1
- package/dist/types/src/components/Calendar/util.test.d.ts +2 -0
- package/dist/types/src/components/Calendar/util.test.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +5 -5
- package/dist/types/src/types.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +19 -19
- package/src/components/Calendar/Calendar.stories.tsx +43 -3
- package/src/components/Calendar/Calendar.tsx +118 -97
- package/src/components/Calendar/Week.tsx +488 -0
- package/src/components/Calendar/Weekdays.tsx +57 -0
- package/src/components/Calendar/context.ts +60 -0
- package/src/components/Calendar/util.test.ts +90 -0
- package/src/components/Calendar/util.ts +92 -1
- package/src/types.ts +5 -5
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { addDays, startOfDay, startOfWeek } from 'date-fns';
|
|
6
|
+
import React, {
|
|
7
|
+
type PointerEvent as ReactPointerEvent,
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useLayoutEffect,
|
|
11
|
+
useMemo,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
} from 'react';
|
|
15
|
+
|
|
16
|
+
import { composable, composableProps } from '@dxos/react-ui';
|
|
17
|
+
import { mx } from '@dxos/ui-theme';
|
|
18
|
+
|
|
19
|
+
import { useCalendarContext } from './context';
|
|
20
|
+
import {
|
|
21
|
+
MINUTES_PER_DAY,
|
|
22
|
+
SNAP_MINUTES,
|
|
23
|
+
getRowIndex,
|
|
24
|
+
gridEpoch,
|
|
25
|
+
isSameDay,
|
|
26
|
+
layoutDayEvents,
|
|
27
|
+
minutesOfDay,
|
|
28
|
+
minutesToY,
|
|
29
|
+
setMinutesOfDay,
|
|
30
|
+
snapMinutes,
|
|
31
|
+
yToMinutes,
|
|
32
|
+
} from './util';
|
|
33
|
+
import { Weekdays } from './Weekdays';
|
|
34
|
+
|
|
35
|
+
const CALENDAR_WEEK_NAME = 'CalendarWeek';
|
|
36
|
+
|
|
37
|
+
const HOUR_HEIGHT = 48; // px per hour.
|
|
38
|
+
const GUTTER_WIDTH = 56; // px, hour-label column.
|
|
39
|
+
const RESIZE_HANDLE = 6; // px, top/bottom hit zone for resizing.
|
|
40
|
+
const MIN_DURATION = SNAP_MINUTES; // Minimum event duration (minutes).
|
|
41
|
+
const INITIAL_HOUR = 8; // Hour scrolled into view on mount.
|
|
42
|
+
|
|
43
|
+
/** An event rendered on the week's time grid. Times are interpreted in local time. */
|
|
44
|
+
export type CalendarEvent = {
|
|
45
|
+
id: string;
|
|
46
|
+
title?: string;
|
|
47
|
+
start: Date;
|
|
48
|
+
end: Date;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type CalendarWeekProps = {
|
|
52
|
+
/** Any date within the week to display; the week is derived via `weekStartsOn`. Defaults to today. */
|
|
53
|
+
date?: Date;
|
|
54
|
+
events?: CalendarEvent[];
|
|
55
|
+
/** Fired when the user drags out a new time slot on an empty part of a day column. */
|
|
56
|
+
onEventCreate?: (event: { start: Date; end: Date }) => void;
|
|
57
|
+
/** Fired when an event is moved (duration preserved) or resized (one edge changed). */
|
|
58
|
+
onEventUpdate?: (event: { id: string; start: Date; end: Date }) => void;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type GestureKind = 'create' | 'move' | 'resize-start' | 'resize-end';
|
|
62
|
+
|
|
63
|
+
// A live drag gesture. `anchorMinutes` is the fixed edge for create/resize; `grabOffset` is the
|
|
64
|
+
// pointer's offset (minutes) into the event when moving, so the event doesn't jump under the cursor.
|
|
65
|
+
type Gesture = {
|
|
66
|
+
kind: GestureKind;
|
|
67
|
+
day: Date;
|
|
68
|
+
eventId?: string;
|
|
69
|
+
anchorMinutes: number;
|
|
70
|
+
grabOffset: number;
|
|
71
|
+
durationMinutes: number;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// A pending edit (the gesture's live result) overlaid on the source events while dragging.
|
|
75
|
+
type Draft = { start: Date; end: Date; eventId?: string };
|
|
76
|
+
|
|
77
|
+
const CalendarWeek = composable<HTMLDivElement, CalendarWeekProps>(
|
|
78
|
+
({ classNames, date, events = [], onEventCreate, onEventUpdate, ...props }, forwardedRef) => {
|
|
79
|
+
const { weekStartsOn, event: scrollEvent, setIndex } = useCalendarContext(CALENDAR_WEEK_NAME);
|
|
80
|
+
const today = useMemo(() => new Date(), []);
|
|
81
|
+
|
|
82
|
+
// Anchor of the displayed week. Seeded from the `date` prop and re-synced when it changes, but also
|
|
83
|
+
// driven by the shared scroll/select signal (e.g. the Toolbar's Today button, controller.scrollTo).
|
|
84
|
+
const [viewDate, setViewDate] = useState<Date>(() => date ?? today);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (date) {
|
|
87
|
+
setViewDate(date);
|
|
88
|
+
}
|
|
89
|
+
}, [date]);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
return scrollEvent.on(({ date }) => setViewDate(date));
|
|
92
|
+
}, [scrollEvent]);
|
|
93
|
+
|
|
94
|
+
const weekDays = useMemo(() => {
|
|
95
|
+
const weekStart = startOfWeek(viewDate, { weekStartsOn });
|
|
96
|
+
return Array.from({ length: 7 }, (_, index) => startOfDay(addDays(weekStart, index)));
|
|
97
|
+
}, [viewDate, weekStartsOn]);
|
|
98
|
+
|
|
99
|
+
// Report the displayed week to the shared context so the Toolbar's month/year label tracks it.
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
setIndex(getRowIndex(gridEpoch, weekDays[0], weekStartsOn));
|
|
102
|
+
}, [weekDays, weekStartsOn, setIndex]);
|
|
103
|
+
|
|
104
|
+
// Index events by day so each column lays out independently.
|
|
105
|
+
const eventsByDay = useMemo(() => {
|
|
106
|
+
const byDay = weekDays.map(() => [] as CalendarEvent[]);
|
|
107
|
+
for (const event of events) {
|
|
108
|
+
const dayIndex = weekDays.findIndex((day) => isSameDay(day, event.start));
|
|
109
|
+
if (dayIndex >= 0) {
|
|
110
|
+
byDay[dayIndex].push(event);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return byDay;
|
|
114
|
+
}, [events, weekDays]);
|
|
115
|
+
|
|
116
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
117
|
+
useLayoutEffect(() => {
|
|
118
|
+
scrollRef.current?.scrollTo({ top: minutesToY(INITIAL_HOUR * 60, HOUR_HEIGHT) });
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
//
|
|
122
|
+
// Drag gesture: create / move / resize. All vertical-only, snapped to `SNAP_MINUTES`.
|
|
123
|
+
//
|
|
124
|
+
const gestureRef = useRef<Gesture | undefined>(undefined);
|
|
125
|
+
const columnsRef = useRef<(HTMLDivElement | null)[]>([]);
|
|
126
|
+
const [draft, setDraft] = useState<Draft | undefined>(undefined);
|
|
127
|
+
|
|
128
|
+
// All day columns share the same vertical geometry, so any column resolves clientY → minutes.
|
|
129
|
+
const pointerMinutes = useCallback((clientY: number): number => {
|
|
130
|
+
const rect = columnsRef.current.find(Boolean)?.getBoundingClientRect();
|
|
131
|
+
if (!rect) {
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
return Math.max(0, Math.min(MINUTES_PER_DAY, yToMinutes(clientY - rect.top, HOUR_HEIGHT)));
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
// The day column under clientX (used to move events across days); undefined when outside all columns.
|
|
138
|
+
const dayFromX = useCallback(
|
|
139
|
+
(clientX: number): Date | undefined => {
|
|
140
|
+
const index = columnsRef.current.findIndex((node) => {
|
|
141
|
+
const rect = node?.getBoundingClientRect();
|
|
142
|
+
return rect && clientX >= rect.left && clientX < rect.right;
|
|
143
|
+
});
|
|
144
|
+
return index >= 0 ? weekDays[index] : undefined;
|
|
145
|
+
},
|
|
146
|
+
[weekDays],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const applyGesture = useCallback(
|
|
150
|
+
(clientX: number, clientY: number): Draft | undefined => {
|
|
151
|
+
const gesture = gestureRef.current;
|
|
152
|
+
if (!gesture) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const { kind, day, eventId, anchorMinutes, grabOffset, durationMinutes } = gesture;
|
|
156
|
+
const raw = pointerMinutes(clientY);
|
|
157
|
+
switch (kind) {
|
|
158
|
+
case 'create': {
|
|
159
|
+
const focus = snapMinutes(raw);
|
|
160
|
+
const from = Math.min(anchorMinutes, focus);
|
|
161
|
+
const to = Math.max(anchorMinutes, focus);
|
|
162
|
+
const end = Math.max(to, from + MIN_DURATION);
|
|
163
|
+
return { eventId, start: setMinutesOfDay(day, from), end: setMinutesOfDay(day, end) };
|
|
164
|
+
}
|
|
165
|
+
case 'move': {
|
|
166
|
+
// Moving may cross day columns; the time-of-day and duration are preserved.
|
|
167
|
+
const targetDay = dayFromX(clientX) ?? day;
|
|
168
|
+
let start = snapMinutes(raw - grabOffset);
|
|
169
|
+
start = Math.max(0, Math.min(MINUTES_PER_DAY - durationMinutes, start));
|
|
170
|
+
return {
|
|
171
|
+
eventId,
|
|
172
|
+
start: setMinutesOfDay(targetDay, start),
|
|
173
|
+
end: setMinutesOfDay(targetDay, start + durationMinutes),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
case 'resize-start': {
|
|
177
|
+
const start = Math.min(snapMinutes(raw), anchorMinutes - MIN_DURATION);
|
|
178
|
+
return { eventId, start: setMinutesOfDay(day, start), end: setMinutesOfDay(day, anchorMinutes) };
|
|
179
|
+
}
|
|
180
|
+
case 'resize-end': {
|
|
181
|
+
const end = Math.max(snapMinutes(raw), anchorMinutes + MIN_DURATION);
|
|
182
|
+
return { eventId, start: setMinutesOfDay(day, anchorMinutes), end: setMinutesOfDay(day, end) };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
[dayFromX, pointerMinutes],
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// Latest commit callbacks, read at pointerup so the listeners attached on pointerdown never go stale.
|
|
190
|
+
const callbacksRef = useRef({ onEventCreate, onEventUpdate });
|
|
191
|
+
callbacksRef.current = { onEventCreate, onEventUpdate };
|
|
192
|
+
|
|
193
|
+
// Removes the active gesture's window listeners; replaced on each `beginGesture` and called on unmount.
|
|
194
|
+
const detachRef = useRef<() => void>(() => {});
|
|
195
|
+
|
|
196
|
+
const beginGesture = useCallback(
|
|
197
|
+
(gesture: Gesture, ev: ReactPointerEvent) => {
|
|
198
|
+
ev.preventDefault();
|
|
199
|
+
ev.stopPropagation();
|
|
200
|
+
gestureRef.current = gesture;
|
|
201
|
+
setDraft(applyGesture(ev.clientX, ev.clientY));
|
|
202
|
+
|
|
203
|
+
// Attach listeners imperatively (not via effect) so no pointer event is missed in the window
|
|
204
|
+
// between pointerdown and the next render.
|
|
205
|
+
const handleMove = (moveEv: PointerEvent) => {
|
|
206
|
+
if (gestureRef.current) {
|
|
207
|
+
setDraft(applyGesture(moveEv.clientX, moveEv.clientY));
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const handleUp = (upEv: PointerEvent) => {
|
|
212
|
+
const active = gestureRef.current;
|
|
213
|
+
const result = applyGesture(upEv.clientX, upEv.clientY);
|
|
214
|
+
detachRef.current();
|
|
215
|
+
gestureRef.current = undefined;
|
|
216
|
+
setDraft(undefined);
|
|
217
|
+
if (active && result) {
|
|
218
|
+
if (active.kind === 'create') {
|
|
219
|
+
callbacksRef.current.onEventCreate?.({ start: result.start, end: result.end });
|
|
220
|
+
} else if (result.eventId) {
|
|
221
|
+
callbacksRef.current.onEventUpdate?.({ id: result.eventId, start: result.start, end: result.end });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
detachRef.current = () => {
|
|
227
|
+
window.removeEventListener('pointermove', handleMove);
|
|
228
|
+
window.removeEventListener('pointerup', handleUp);
|
|
229
|
+
window.removeEventListener('pointercancel', handleUp);
|
|
230
|
+
};
|
|
231
|
+
window.addEventListener('pointermove', handleMove);
|
|
232
|
+
window.addEventListener('pointerup', handleUp);
|
|
233
|
+
window.addEventListener('pointercancel', handleUp);
|
|
234
|
+
},
|
|
235
|
+
[applyGesture],
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
useEffect(() => () => detachRef.current(), []);
|
|
239
|
+
|
|
240
|
+
const handleColumnPointerDown = useCallback(
|
|
241
|
+
(day: Date, event: ReactPointerEvent<HTMLDivElement>) => {
|
|
242
|
+
// Only the column background starts a create gesture; events stop propagation.
|
|
243
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
244
|
+
const anchor = snapMinutes(yToMinutes(event.clientY - rect.top, HOUR_HEIGHT));
|
|
245
|
+
beginGesture({ kind: 'create', day, anchorMinutes: anchor, grabOffset: 0, durationMinutes: 0 }, event);
|
|
246
|
+
},
|
|
247
|
+
[beginGesture],
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Render an event block bound to the gesture handlers for a given day column. `day` is the column
|
|
251
|
+
// the block currently sits in (its original day, or the target day while being dragged across).
|
|
252
|
+
const renderEvent = useCallback(
|
|
253
|
+
(event: CalendarEvent, day: Date, start: Date, end: Date, columnIndex: number, columnCount: number) => (
|
|
254
|
+
<EventBlock
|
|
255
|
+
key={event.id}
|
|
256
|
+
event={event}
|
|
257
|
+
start={start}
|
|
258
|
+
end={end}
|
|
259
|
+
columnIndex={columnIndex}
|
|
260
|
+
columnCount={columnCount}
|
|
261
|
+
onMoveStart={(ev) =>
|
|
262
|
+
beginGesture(
|
|
263
|
+
{
|
|
264
|
+
kind: 'move',
|
|
265
|
+
day,
|
|
266
|
+
eventId: event.id,
|
|
267
|
+
anchorMinutes: 0,
|
|
268
|
+
grabOffset: pointerMinutes(ev.clientY) - minutesOfDay(event.start),
|
|
269
|
+
durationMinutes: minutesOfDay(event.end) - minutesOfDay(event.start),
|
|
270
|
+
},
|
|
271
|
+
ev,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
onResizeStart={(ev) =>
|
|
275
|
+
beginGesture(
|
|
276
|
+
{
|
|
277
|
+
kind: 'resize-start',
|
|
278
|
+
day,
|
|
279
|
+
eventId: event.id,
|
|
280
|
+
anchorMinutes: minutesOfDay(event.end),
|
|
281
|
+
grabOffset: 0,
|
|
282
|
+
durationMinutes: 0,
|
|
283
|
+
},
|
|
284
|
+
ev,
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
onResizeEnd={(ev) =>
|
|
288
|
+
beginGesture(
|
|
289
|
+
{
|
|
290
|
+
kind: 'resize-end',
|
|
291
|
+
day,
|
|
292
|
+
eventId: event.id,
|
|
293
|
+
anchorMinutes: minutesOfDay(event.start),
|
|
294
|
+
grabOffset: 0,
|
|
295
|
+
durationMinutes: 0,
|
|
296
|
+
},
|
|
297
|
+
ev,
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
/>
|
|
301
|
+
),
|
|
302
|
+
[beginGesture, pointerMinutes],
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// The event under an active move, resolved once so the origin column can drop it and the target
|
|
306
|
+
// column can render it while the pointer is over a different day.
|
|
307
|
+
const draggedEvent = draft?.eventId ? events.find((event) => event.id === draft.eventId) : undefined;
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<div
|
|
311
|
+
{...composableProps(props, {
|
|
312
|
+
classNames: ['flex flex-col h-full w-full overflow-hidden outline-hidden', classNames],
|
|
313
|
+
})}
|
|
314
|
+
ref={forwardedRef}
|
|
315
|
+
>
|
|
316
|
+
<Weekdays weekStartsOn={weekStartsOn} gutter={GUTTER_WIDTH} dates={weekDays} />
|
|
317
|
+
|
|
318
|
+
<div ref={scrollRef} className='flex-1 overflow-y-auto _scrollbar-thin'>
|
|
319
|
+
<div
|
|
320
|
+
className='grid relative'
|
|
321
|
+
style={{
|
|
322
|
+
height: minutesToY(MINUTES_PER_DAY, HOUR_HEIGHT),
|
|
323
|
+
gridTemplateColumns: `${GUTTER_WIDTH}px repeat(7, 1fr)`,
|
|
324
|
+
}}
|
|
325
|
+
>
|
|
326
|
+
{/* Hour gutter + faint hour lines spanning all columns. */}
|
|
327
|
+
<div className='relative'>
|
|
328
|
+
{Array.from({ length: 24 }, (_, hour) => (
|
|
329
|
+
<div
|
|
330
|
+
key={hour}
|
|
331
|
+
className='absolute right-1 -translate-y-1/2 text-xs text-description tabular-nums'
|
|
332
|
+
style={{ top: minutesToY(hour * 60, HOUR_HEIGHT) }}
|
|
333
|
+
>
|
|
334
|
+
{hour === 0 ? '' : `${hour.toString().padStart(2, '0')}:00`}
|
|
335
|
+
</div>
|
|
336
|
+
))}
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
{weekDays.map((day, dayIndex) => {
|
|
340
|
+
const dayEvents = eventsByDay[dayIndex];
|
|
341
|
+
const layout = layoutDayEvents(dayEvents);
|
|
342
|
+
const isToday = isSameDay(day, today);
|
|
343
|
+
// The draft when it currently belongs to this column (origin for resize, target for a cross-day move).
|
|
344
|
+
const draftHere = draft && isSameDay(day, draft.start) ? draft : undefined;
|
|
345
|
+
return (
|
|
346
|
+
<div
|
|
347
|
+
key={day.toISOString()}
|
|
348
|
+
ref={(node) => {
|
|
349
|
+
columnsRef.current[dayIndex] = node;
|
|
350
|
+
}}
|
|
351
|
+
data-date={day.toISOString()}
|
|
352
|
+
className={mx(
|
|
353
|
+
'relative border-l border-separator cursor-cell select-none',
|
|
354
|
+
dayIndex === 6 && 'border-r',
|
|
355
|
+
isToday && 'bg-primary-500/5',
|
|
356
|
+
)}
|
|
357
|
+
onPointerDown={(ev) => handleColumnPointerDown(day, ev)}
|
|
358
|
+
>
|
|
359
|
+
{/* Faint hour lines. */}
|
|
360
|
+
{Array.from({ length: 24 }, (_, hour) => (
|
|
361
|
+
<div
|
|
362
|
+
key={hour}
|
|
363
|
+
className='absolute inset-x-0 border-t border-separator/60'
|
|
364
|
+
style={{ top: minutesToY(hour * 60, HOUR_HEIGHT) }}
|
|
365
|
+
/>
|
|
366
|
+
))}
|
|
367
|
+
|
|
368
|
+
{/* Events. */}
|
|
369
|
+
{dayEvents.map((event, index) => {
|
|
370
|
+
const slot = layout.get(index) ?? { columnIndex: 0, columnCount: 1 };
|
|
371
|
+
const editing = draft && draft.eventId === event.id ? draft : undefined;
|
|
372
|
+
// Dropped from this column while the move drags it onto another day.
|
|
373
|
+
if (editing && !isSameDay(editing.start, day)) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
const start = editing ? editing.start : event.start;
|
|
377
|
+
const end = editing ? editing.end : event.end;
|
|
378
|
+
return renderEvent(event, day, start, end, slot.columnIndex, slot.columnCount);
|
|
379
|
+
})}
|
|
380
|
+
|
|
381
|
+
{/* Event being dragged in from another day — rendered full-width above this column's events. */}
|
|
382
|
+
{draftHere &&
|
|
383
|
+
draggedEvent &&
|
|
384
|
+
!dayEvents.some((event) => event.id === draggedEvent.id) &&
|
|
385
|
+
renderEvent(draggedEvent, day, draftHere.start, draftHere.end, 0, 1)}
|
|
386
|
+
|
|
387
|
+
{/* Pending create rectangle. */}
|
|
388
|
+
{draftHere && !draftHere.eventId && <PendingBlock start={draftHere.start} end={draftHere.end} />}
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
})}
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
);
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
CalendarWeek.displayName = CALENDAR_WEEK_NAME;
|
|
400
|
+
|
|
401
|
+
//
|
|
402
|
+
// EventBlock
|
|
403
|
+
//
|
|
404
|
+
|
|
405
|
+
const formatTime = (date: Date): string => {
|
|
406
|
+
const minutes = minutesOfDay(date);
|
|
407
|
+
const hour = Math.floor(minutes / 60);
|
|
408
|
+
const minute = Math.round(minutes % 60);
|
|
409
|
+
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
type EventBlockProps = {
|
|
413
|
+
event: CalendarEvent;
|
|
414
|
+
start: Date;
|
|
415
|
+
end: Date;
|
|
416
|
+
columnIndex: number;
|
|
417
|
+
columnCount: number;
|
|
418
|
+
onMoveStart: (ev: ReactPointerEvent<HTMLDivElement>) => void;
|
|
419
|
+
onResizeStart: (ev: ReactPointerEvent<HTMLDivElement>) => void;
|
|
420
|
+
onResizeEnd: (ev: ReactPointerEvent<HTMLDivElement>) => void;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const EventBlock = ({
|
|
424
|
+
event,
|
|
425
|
+
start,
|
|
426
|
+
end,
|
|
427
|
+
columnIndex,
|
|
428
|
+
columnCount,
|
|
429
|
+
onMoveStart,
|
|
430
|
+
onResizeStart,
|
|
431
|
+
onResizeEnd,
|
|
432
|
+
}: EventBlockProps) => {
|
|
433
|
+
const top = minutesToY(minutesOfDay(start), HOUR_HEIGHT);
|
|
434
|
+
const height = Math.max(minutesToY(minutesOfDay(end) - minutesOfDay(start), HOUR_HEIGHT), RESIZE_HANDLE * 2);
|
|
435
|
+
// Leave a 1px gutter between side-by-side columns.
|
|
436
|
+
const widthPct = 100 / columnCount;
|
|
437
|
+
return (
|
|
438
|
+
<div
|
|
439
|
+
className='absolute rounded-sm bg-primary-500/80 text-inverse-fg overflow-hidden cursor-move shadow-sm'
|
|
440
|
+
style={{
|
|
441
|
+
top,
|
|
442
|
+
height,
|
|
443
|
+
left: `calc(${columnIndex * widthPct}% + 1px)`,
|
|
444
|
+
width: `calc(${widthPct}% - 2px)`,
|
|
445
|
+
}}
|
|
446
|
+
onPointerDown={onMoveStart}
|
|
447
|
+
>
|
|
448
|
+
<div
|
|
449
|
+
className='absolute inset-x-0 top-0 cursor-ns-resize'
|
|
450
|
+
style={{ height: RESIZE_HANDLE }}
|
|
451
|
+
onPointerDown={onResizeStart}
|
|
452
|
+
/>
|
|
453
|
+
<div className='px-1 py-0.5 text-xs leading-tight'>
|
|
454
|
+
<div className='font-medium truncate'>{event.title ?? '(untitled)'}</div>
|
|
455
|
+
{/* <div className='tabular-nums opacity-80'>
|
|
456
|
+
{formatTime(start)}–{formatTime(end)}
|
|
457
|
+
</div> */}
|
|
458
|
+
</div>
|
|
459
|
+
<div
|
|
460
|
+
className='absolute inset-x-0 bottom-0 cursor-ns-resize'
|
|
461
|
+
style={{ height: RESIZE_HANDLE }}
|
|
462
|
+
onPointerDown={onResizeEnd}
|
|
463
|
+
/>
|
|
464
|
+
</div>
|
|
465
|
+
);
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
//
|
|
469
|
+
// PendingBlock
|
|
470
|
+
//
|
|
471
|
+
|
|
472
|
+
const PendingBlock = ({ start, end }: { start: Date; end: Date }) => {
|
|
473
|
+
const top = minutesToY(minutesOfDay(start), HOUR_HEIGHT);
|
|
474
|
+
const height = minutesToY(minutesOfDay(end) - minutesOfDay(start), HOUR_HEIGHT);
|
|
475
|
+
return (
|
|
476
|
+
<div
|
|
477
|
+
className='absolute inset-x-0 rounded bg-primary-500/40 border border-primary-500 pointer-events-none'
|
|
478
|
+
style={{ top, height }}
|
|
479
|
+
>
|
|
480
|
+
<div className='px-1 py-0.5 text-xs tabular-nums text-inverse-fg'>
|
|
481
|
+
{formatTime(start)}–{formatTime(end)}
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
);
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
export { CalendarWeek };
|
|
488
|
+
export type { CalendarWeekProps };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Day, addDays, format, startOfWeek } from 'date-fns';
|
|
6
|
+
import React, { useMemo } from 'react';
|
|
7
|
+
|
|
8
|
+
import { mx } from '@dxos/ui-theme';
|
|
9
|
+
|
|
10
|
+
import { isSameDay } from './util';
|
|
11
|
+
|
|
12
|
+
export type WeekdaysProps = {
|
|
13
|
+
weekStartsOn: Day;
|
|
14
|
+
/** Fixed column width in px (e.g. the month grid's cells); omit for flexible `1fr` columns. */
|
|
15
|
+
columnWidth?: number;
|
|
16
|
+
/** Leading spacer width in px, aligning the labels over a body gutter (e.g. the week view's hour column). */
|
|
17
|
+
gutter?: number;
|
|
18
|
+
/** When provided, the matching day-of-month number is rendered beneath each label (week view). */
|
|
19
|
+
dates?: Date[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Shared day-of-week header for the calendar views. Renders seven short day labels
|
|
24
|
+
* ordered by `weekStartsOn`; when `dates` is supplied it also shows the day number and
|
|
25
|
+
* highlights today.
|
|
26
|
+
*/
|
|
27
|
+
export const Weekdays = ({ weekStartsOn, columnWidth, gutter, dates }: WeekdaysProps) => {
|
|
28
|
+
const labels = useMemo(() => {
|
|
29
|
+
const weekStart = startOfWeek(new Date(), { weekStartsOn });
|
|
30
|
+
return Array.from({ length: 7 }, (_, index) => format(addDays(weekStart, index), 'EEE'));
|
|
31
|
+
}, [weekStartsOn]);
|
|
32
|
+
|
|
33
|
+
const today = useMemo(() => new Date(), []);
|
|
34
|
+
const columnTemplate = columnWidth ? `repeat(7, ${columnWidth}px)` : 'repeat(7, 1fr)';
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className='grid w-full shrink-0'
|
|
39
|
+
style={{ gridTemplateColumns: gutter ? `${gutter}px ${columnTemplate}` : columnTemplate }}
|
|
40
|
+
>
|
|
41
|
+
{gutter != null && <div aria-hidden />}
|
|
42
|
+
{labels.map((label, index) => {
|
|
43
|
+
const date = dates?.[index];
|
|
44
|
+
const isToday = !!date && isSameDay(date, today);
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
key={index}
|
|
48
|
+
className={mx('flex flex-col items-center p-2 text-sm font-thin', isToday && 'text-accent-text')}
|
|
49
|
+
>
|
|
50
|
+
<span>{label}</span>
|
|
51
|
+
{date && <span className='text-lg font-normal tabular-nums'>{date.getDate()}</span>}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
})}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { createContext } from '@radix-ui/react-context';
|
|
6
|
+
import { type Day } from 'date-fns';
|
|
7
|
+
import { type Dispatch, type SetStateAction } from 'react';
|
|
8
|
+
|
|
9
|
+
import { type Event } from '@dxos/async';
|
|
10
|
+
|
|
11
|
+
//
|
|
12
|
+
// Range
|
|
13
|
+
//
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Inclusive date range. `from <= to`. Both endpoints are anchored at the
|
|
17
|
+
* start of their day; callers should not rely on time-of-day precision.
|
|
18
|
+
*/
|
|
19
|
+
export type Range = {
|
|
20
|
+
from: Date;
|
|
21
|
+
to: Date;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
//
|
|
25
|
+
// Controller
|
|
26
|
+
//
|
|
27
|
+
|
|
28
|
+
export type CalendarController = {
|
|
29
|
+
/** Bring a date into view without changing the selection. */
|
|
30
|
+
scrollTo: (date: Date) => void;
|
|
31
|
+
/** Set the grid's selected day (and scroll it into view) — e.g. when the active event changes. */
|
|
32
|
+
select: (date: Date) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
//
|
|
36
|
+
// Context
|
|
37
|
+
//
|
|
38
|
+
|
|
39
|
+
/** Imperative grid signal: `scroll` brings a date into view; `select` also sets it as the selected day. */
|
|
40
|
+
export type CalendarScrollEvent = {
|
|
41
|
+
type: 'scroll' | 'select';
|
|
42
|
+
date: Date;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type CalendarContextValue = {
|
|
46
|
+
weekStartsOn: Day;
|
|
47
|
+
event: Event<CalendarScrollEvent>;
|
|
48
|
+
index: number | undefined;
|
|
49
|
+
setIndex: Dispatch<SetStateAction<number | undefined>>;
|
|
50
|
+
selected: Date | undefined;
|
|
51
|
+
setSelected: Dispatch<SetStateAction<Date | undefined>>;
|
|
52
|
+
/** Committed date range, set by the most recent drag or shift+arrow selection. */
|
|
53
|
+
range: Range | undefined;
|
|
54
|
+
setRange: Dispatch<SetStateAction<Range | undefined>>;
|
|
55
|
+
/** Live drag preview; non-undefined only while the user is dragging. */
|
|
56
|
+
pendingRange: Range | undefined;
|
|
57
|
+
setPendingRange: Dispatch<SetStateAction<Range | undefined>>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const [CalendarContextProvider, useCalendarContext] = createContext<CalendarContextValue>('Calendar');
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { layoutDayEvents, minutesOfDay, minutesToY, setMinutesOfDay, snapMinutes, yToMinutes } from './util';
|
|
8
|
+
|
|
9
|
+
// 2026-06-16 is a Tuesday.
|
|
10
|
+
const day = new Date(2026, 5, 16);
|
|
11
|
+
const at = (hours: number, minutes = 0) => setMinutesOfDay(day, hours * 60 + minutes);
|
|
12
|
+
|
|
13
|
+
describe('time helpers', () => {
|
|
14
|
+
test('minutesOfDay / setMinutesOfDay round-trip', ({ expect }) => {
|
|
15
|
+
const date = at(9, 30);
|
|
16
|
+
expect(minutesOfDay(date)).toEqual(9 * 60 + 30);
|
|
17
|
+
expect(setMinutesOfDay(day, 9 * 60 + 30).getTime()).toEqual(date.getTime());
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('yToMinutes / minutesToY are inverses', ({ expect }) => {
|
|
21
|
+
const hourHeight = 48;
|
|
22
|
+
expect(yToMinutes(48, hourHeight)).toEqual(60);
|
|
23
|
+
expect(minutesToY(60, hourHeight)).toEqual(48);
|
|
24
|
+
expect(minutesToY(yToMinutes(123, hourHeight), hourHeight)).toBeCloseTo(123);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('snapMinutes rounds to step and clamps to the day', ({ expect }) => {
|
|
28
|
+
expect(snapMinutes(7)).toEqual(0);
|
|
29
|
+
expect(snapMinutes(8)).toEqual(15);
|
|
30
|
+
expect(snapMinutes(22, 15)).toEqual(15);
|
|
31
|
+
expect(snapMinutes(23, 15)).toEqual(30);
|
|
32
|
+
expect(snapMinutes(-10)).toEqual(0);
|
|
33
|
+
expect(snapMinutes(24 * 60 + 100)).toEqual(24 * 60);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('layoutDayEvents', () => {
|
|
38
|
+
test('non-overlapping events each get the full width', ({ expect }) => {
|
|
39
|
+
const events = [
|
|
40
|
+
{ start: at(9), end: at(10) },
|
|
41
|
+
{ start: at(11), end: at(12) },
|
|
42
|
+
];
|
|
43
|
+
const layout = layoutDayEvents(events);
|
|
44
|
+
expect(layout.get(0)).toEqual({ columnIndex: 0, columnCount: 1 });
|
|
45
|
+
expect(layout.get(1)).toEqual({ columnIndex: 0, columnCount: 1 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('two overlapping events split into two columns', ({ expect }) => {
|
|
49
|
+
const events = [
|
|
50
|
+
{ start: at(9), end: at(11) },
|
|
51
|
+
{ start: at(10), end: at(12) },
|
|
52
|
+
];
|
|
53
|
+
const layout = layoutDayEvents(events);
|
|
54
|
+
expect(layout.get(0)).toEqual({ columnIndex: 0, columnCount: 2 });
|
|
55
|
+
expect(layout.get(1)).toEqual({ columnIndex: 1, columnCount: 2 });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('result is independent of input order', ({ expect }) => {
|
|
59
|
+
const events = [
|
|
60
|
+
{ start: at(10), end: at(12) },
|
|
61
|
+
{ start: at(9), end: at(11) },
|
|
62
|
+
];
|
|
63
|
+
const layout = layoutDayEvents(events);
|
|
64
|
+
expect(layout.get(1)).toEqual({ columnIndex: 0, columnCount: 2 });
|
|
65
|
+
expect(layout.get(0)).toEqual({ columnIndex: 1, columnCount: 2 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a freed column is reused within the same cluster', ({ expect }) => {
|
|
69
|
+
// C overlaps B (which holds the cluster open) but starts after A ends, so it reuses A's column.
|
|
70
|
+
const events = [
|
|
71
|
+
{ start: at(9), end: at(10) }, // A
|
|
72
|
+
{ start: at(9, 30), end: at(12) }, // B
|
|
73
|
+
{ start: at(10, 30), end: at(11) }, // C
|
|
74
|
+
];
|
|
75
|
+
const layout = layoutDayEvents(events);
|
|
76
|
+
expect(layout.get(0)).toEqual({ columnIndex: 0, columnCount: 2 });
|
|
77
|
+
expect(layout.get(1)).toEqual({ columnIndex: 1, columnCount: 2 });
|
|
78
|
+
expect(layout.get(2)).toEqual({ columnIndex: 0, columnCount: 2 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('adjacent (touching) events do not overlap', ({ expect }) => {
|
|
82
|
+
const events = [
|
|
83
|
+
{ start: at(9), end: at(10) },
|
|
84
|
+
{ start: at(10), end: at(11) },
|
|
85
|
+
];
|
|
86
|
+
const layout = layoutDayEvents(events);
|
|
87
|
+
expect(layout.get(0)).toEqual({ columnIndex: 0, columnCount: 1 });
|
|
88
|
+
expect(layout.get(1)).toEqual({ columnIndex: 0, columnCount: 1 });
|
|
89
|
+
});
|
|
90
|
+
});
|