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