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