@super-calendar/dom 2.3.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +80 -222
- package/dist/index.d.ts +80 -222
- package/dist/index.js +429 -69
- package/dist/index.mjs +432 -72
- package/package.json +2 -2
- package/src/Calendar.tsx +192 -50
- package/src/ResourceTimeline.tsx +215 -9
- package/src/TimeGrid.tsx +345 -72
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@super-calendar/dom",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Virtualized month / week / day calendar and date picker for react-dom. No React Native, no react-native-web.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"calendar",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"access": "public"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@super-calendar/core": "2.
|
|
51
|
+
"@super-calendar/core": "2.4.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@legendapp/list": ">=3",
|
package/src/Calendar.tsx
CHANGED
|
@@ -1,14 +1,33 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
import {
|
|
2
|
+
addDays,
|
|
3
|
+
addMonths,
|
|
4
|
+
endOfDay,
|
|
5
|
+
endOfMonth,
|
|
6
|
+
endOfWeek,
|
|
7
|
+
type Locale,
|
|
8
|
+
startOfDay,
|
|
9
|
+
startOfMonth,
|
|
10
|
+
startOfWeek,
|
|
11
|
+
} from "date-fns";
|
|
12
|
+
import {
|
|
13
|
+
type CSSProperties,
|
|
14
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
15
|
+
type ReactElement,
|
|
16
|
+
type ReactNode,
|
|
17
|
+
useMemo,
|
|
18
|
+
} from "react";
|
|
19
|
+
import {
|
|
20
|
+
type BusinessHours,
|
|
21
|
+
type CalendarEvent,
|
|
22
|
+
type DateRange,
|
|
23
|
+
type DateSelectionConstraints,
|
|
24
|
+
type EventAccessibilityLabeler,
|
|
25
|
+
eventsInTimeZone,
|
|
26
|
+
expandRecurringEvents,
|
|
27
|
+
getViewDays,
|
|
28
|
+
type TimeGridMode,
|
|
29
|
+
type WeekdayFormat,
|
|
30
|
+
type WeekStartsOn,
|
|
12
31
|
} from "@super-calendar/core";
|
|
13
32
|
import { Agenda, type AgendaSlot, type DomAgendaEvent } from "./Agenda";
|
|
14
33
|
import { type DomMonthEvent, MonthView, type MonthViewSlot } from "./MonthView";
|
|
@@ -33,6 +52,13 @@ export interface CalendarProps<T = unknown>
|
|
|
33
52
|
mode?: "month" | "schedule" | TimeGridMode;
|
|
34
53
|
/** Controlled anchor date. Change it (e.g. from your own header) to navigate. */
|
|
35
54
|
date: Date;
|
|
55
|
+
/**
|
|
56
|
+
* Fires with the next/previous period's date when the user pages the focused
|
|
57
|
+
* calendar with **PageDown** / **PageUp** (a month in `month`, a week in
|
|
58
|
+
* `schedule`, else the view's day span). It's controlled, so update `date` in
|
|
59
|
+
* response. Omit it to leave paging to your own controls.
|
|
60
|
+
*/
|
|
61
|
+
onChangeDate?: (date: Date) => void;
|
|
36
62
|
/** Your events. */
|
|
37
63
|
events?: CalendarEvent<T>[];
|
|
38
64
|
/** First day of the week. Sunday = 0 (default) … Saturday = 6. */
|
|
@@ -41,6 +67,12 @@ export interface CalendarProps<T = unknown>
|
|
|
41
67
|
numberOfDays?: number;
|
|
42
68
|
/** date-fns locale for titles, headers, and time labels. */
|
|
43
69
|
locale?: Locale;
|
|
70
|
+
/**
|
|
71
|
+
* Display events in this IANA time zone (e.g. `"America/New_York"`), DST-correct
|
|
72
|
+
* and independent of the device zone. Display-only: it shifts the wall-clock the
|
|
73
|
+
* grid lays out from, it doesn't change your `Date`s. Uses `eventsInTimeZone`.
|
|
74
|
+
*/
|
|
75
|
+
timeZone?: string;
|
|
44
76
|
/** Theme overrides; falls back to the default light theme. */
|
|
45
77
|
theme?: Partial<DomCalendarTheme>;
|
|
46
78
|
/** Height of the scroll viewport, in px. */
|
|
@@ -68,6 +100,16 @@ export interface CalendarProps<T = unknown>
|
|
|
68
100
|
scrollOffsetMinutes?: number;
|
|
69
101
|
/** Sub-divisions per hour for the grid lines. */
|
|
70
102
|
timeslots?: number;
|
|
103
|
+
/** First hour shown (0–23). Default 0. */
|
|
104
|
+
minHour?: number;
|
|
105
|
+
/** Last hour shown, exclusive (1–24). Default 24. */
|
|
106
|
+
maxHour?: number;
|
|
107
|
+
/** Hide the left hour-axis column (lines stay, labels/gutter go). Default false. */
|
|
108
|
+
hideHours?: boolean;
|
|
109
|
+
/** Show the ISO week number in the header gutter. Default false. */
|
|
110
|
+
showWeekNumber?: boolean;
|
|
111
|
+
/** Prefix for the week number, e.g. "W" → "W28". Default "W". */
|
|
112
|
+
weekNumberPrefix?: string;
|
|
71
113
|
/** Shade the hours outside business hours. */
|
|
72
114
|
businessHours?: BusinessHours;
|
|
73
115
|
/** Show the current-time indicator (default true). */
|
|
@@ -90,6 +132,11 @@ export interface CalendarProps<T = unknown>
|
|
|
90
132
|
renderTimeEvent?: DomRenderEvent<T>;
|
|
91
133
|
/** Replace the hour-axis label. Receives the hour (0–23) and the `ampm` flag. */
|
|
92
134
|
hourComponent?: (hour: number, ampm: boolean) => ReactNode;
|
|
135
|
+
/**
|
|
136
|
+
* Add arrow-key navigation between time-grid events (a convenience for sighted
|
|
137
|
+
* keyboard users; every event stays individually tabbable). Time-grid modes only.
|
|
138
|
+
*/
|
|
139
|
+
keyboardEventNavigation?: boolean;
|
|
93
140
|
|
|
94
141
|
// --- Month mode ---
|
|
95
142
|
/** Max chips per day before a "+N more" row. */
|
|
@@ -124,6 +171,48 @@ export interface CalendarProps<T = unknown>
|
|
|
124
171
|
renderScheduleEvent?: DomAgendaEvent<T>;
|
|
125
172
|
}
|
|
126
173
|
|
|
174
|
+
// Stable empty array so a missing `events` prop doesn't churn `displayEvents`
|
|
175
|
+
// identity (and bust child memoization) on every re-render.
|
|
176
|
+
const EMPTY_EVENTS: CalendarEvent<never>[] = [];
|
|
177
|
+
|
|
178
|
+
// Recurrence is expanded over the range a mode renders: the month grid for
|
|
179
|
+
// `month`, the day columns for the time-grid modes, and a forward window for the
|
|
180
|
+
// `schedule` agenda (which has no bounded viewport of its own).
|
|
181
|
+
function expansionRange(
|
|
182
|
+
mode: "month" | "schedule" | TimeGridMode,
|
|
183
|
+
date: Date,
|
|
184
|
+
weekStartsOn: WeekStartsOn,
|
|
185
|
+
numberOfDays: number | undefined,
|
|
186
|
+
): [Date, Date] {
|
|
187
|
+
if (mode === "month") {
|
|
188
|
+
return [
|
|
189
|
+
startOfWeek(startOfMonth(date), { weekStartsOn }),
|
|
190
|
+
endOfWeek(endOfMonth(date), { weekStartsOn }),
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
if (mode === "schedule") {
|
|
194
|
+
// The agenda lists forward from the anchor date; three months is a sensible
|
|
195
|
+
// default look-ahead. Pre-expand for a different window.
|
|
196
|
+
return [startOfDay(date), endOfDay(addMonths(date, 3))];
|
|
197
|
+
}
|
|
198
|
+
const days = getViewDays(mode, date, weekStartsOn, numberOfDays ?? 1);
|
|
199
|
+
return [startOfDay(days[0]), endOfDay(days[days.length - 1])];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The date one page away in `direction` (+1 next, −1 previous): a month for
|
|
203
|
+
// `month`, a week for `schedule`, else the view's day span.
|
|
204
|
+
function pageStep(
|
|
205
|
+
mode: "month" | "schedule" | TimeGridMode,
|
|
206
|
+
date: Date,
|
|
207
|
+
direction: number,
|
|
208
|
+
weekStartsOn: WeekStartsOn,
|
|
209
|
+
numberOfDays: number | undefined,
|
|
210
|
+
): Date {
|
|
211
|
+
if (mode === "month") return addMonths(date, direction);
|
|
212
|
+
if (mode === "schedule") return addDays(date, direction * 7);
|
|
213
|
+
return addDays(date, direction * getViewDays(mode, date, weekStartsOn, numberOfDays ?? 1).length);
|
|
214
|
+
}
|
|
215
|
+
|
|
127
216
|
/**
|
|
128
217
|
* Batteries-included entry point for the react-dom renderer: it picks the right
|
|
129
218
|
* view for `mode`. `month` renders a single {@link MonthView}; `schedule` renders
|
|
@@ -139,9 +228,11 @@ export function Calendar<T = unknown>({
|
|
|
139
228
|
mode = "week",
|
|
140
229
|
date,
|
|
141
230
|
events,
|
|
231
|
+
onChangeDate,
|
|
142
232
|
weekStartsOn = 0,
|
|
143
233
|
numberOfDays,
|
|
144
234
|
locale,
|
|
235
|
+
timeZone,
|
|
145
236
|
theme,
|
|
146
237
|
height,
|
|
147
238
|
className,
|
|
@@ -153,6 +244,11 @@ export function Calendar<T = unknown>({
|
|
|
153
244
|
hourHeight,
|
|
154
245
|
scrollOffsetMinutes,
|
|
155
246
|
timeslots,
|
|
247
|
+
minHour,
|
|
248
|
+
maxHour,
|
|
249
|
+
hideHours,
|
|
250
|
+
showWeekNumber,
|
|
251
|
+
weekNumberPrefix,
|
|
156
252
|
businessHours,
|
|
157
253
|
showNowIndicator,
|
|
158
254
|
showAllDayEventCell,
|
|
@@ -164,6 +260,7 @@ export function Calendar<T = unknown>({
|
|
|
164
260
|
onPressDateHeader,
|
|
165
261
|
renderTimeEvent,
|
|
166
262
|
hourComponent,
|
|
263
|
+
keyboardEventNavigation,
|
|
167
264
|
// month
|
|
168
265
|
maxVisibleEventCount,
|
|
169
266
|
moreLabel,
|
|
@@ -185,10 +282,41 @@ export function Calendar<T = unknown>({
|
|
|
185
282
|
classNames,
|
|
186
283
|
styles,
|
|
187
284
|
}: CalendarProps<T>): ReactElement {
|
|
285
|
+
// Materialise recurring events over the range this mode renders, then apply the
|
|
286
|
+
// display zone. Non-recurring and already-expanded events pass through
|
|
287
|
+
// untouched, so identity is preserved for the common (no-recurrence) case.
|
|
288
|
+
const displayEvents = useMemo(() => {
|
|
289
|
+
let out: CalendarEvent<T>[] = events ?? EMPTY_EVENTS;
|
|
290
|
+
if (out.some((e) => e.recurrence)) {
|
|
291
|
+
let [start, end] = expansionRange(mode, date, weekStartsOn, numberOfDays);
|
|
292
|
+
// A display zone shifts each occurrence's wall-clock by up to ~a day, which
|
|
293
|
+
// can move an edge occurrence into a visible column; widen so it's generated.
|
|
294
|
+
if (timeZone) {
|
|
295
|
+
start = addDays(start, -1);
|
|
296
|
+
end = addDays(end, 1);
|
|
297
|
+
}
|
|
298
|
+
out = expandRecurringEvents(out, start, end);
|
|
299
|
+
}
|
|
300
|
+
if (timeZone) out = eventsInTimeZone(out, timeZone);
|
|
301
|
+
return out;
|
|
302
|
+
// `date.getTime()`: recompute on the instant, not a re-created Date identity.
|
|
303
|
+
}, [events, mode, date.getTime(), weekStartsOn, numberOfDays, timeZone]);
|
|
304
|
+
|
|
305
|
+
// PageDown / PageUp page the calendar when the consumer opts in with
|
|
306
|
+
// `onChangeDate`; keydowns bubble up from the focused grid to this handler.
|
|
307
|
+
const handlePageKeys = (e: ReactKeyboardEvent) => {
|
|
308
|
+
if (!onChangeDate) return;
|
|
309
|
+
if (e.key === "PageDown") onChangeDate(pageStep(mode, date, 1, weekStartsOn, numberOfDays));
|
|
310
|
+
else if (e.key === "PageUp") onChangeDate(pageStep(mode, date, -1, weekStartsOn, numberOfDays));
|
|
311
|
+
else return;
|
|
312
|
+
e.preventDefault();
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
let view: ReactElement;
|
|
188
316
|
if (mode === "schedule") {
|
|
189
|
-
|
|
317
|
+
view = (
|
|
190
318
|
<Agenda<T>
|
|
191
|
-
events={
|
|
319
|
+
events={displayEvents}
|
|
192
320
|
locale={locale}
|
|
193
321
|
ampm={ampm}
|
|
194
322
|
theme={theme}
|
|
@@ -204,13 +332,11 @@ export function Calendar<T = unknown>({
|
|
|
204
332
|
onPressDay={onPressDay}
|
|
205
333
|
/>
|
|
206
334
|
);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (mode === "month") {
|
|
210
|
-
return (
|
|
335
|
+
} else if (mode === "month") {
|
|
336
|
+
view = (
|
|
211
337
|
<MonthView<T>
|
|
212
338
|
date={date}
|
|
213
|
-
events={
|
|
339
|
+
events={displayEvents}
|
|
214
340
|
weekStartsOn={weekStartsOn}
|
|
215
341
|
weekdayFormat={weekdayFormat}
|
|
216
342
|
locale={locale}
|
|
@@ -237,40 +363,56 @@ export function Calendar<T = unknown>({
|
|
|
237
363
|
eventAccessibilityLabel={eventAccessibilityLabel}
|
|
238
364
|
/>
|
|
239
365
|
);
|
|
366
|
+
} else {
|
|
367
|
+
view = (
|
|
368
|
+
<TimeGrid<T>
|
|
369
|
+
date={date}
|
|
370
|
+
mode={mode}
|
|
371
|
+
events={displayEvents}
|
|
372
|
+
weekStartsOn={weekStartsOn}
|
|
373
|
+
weekdayFormat={weekdayFormat}
|
|
374
|
+
numberOfDays={numberOfDays}
|
|
375
|
+
locale={locale}
|
|
376
|
+
theme={theme}
|
|
377
|
+
height={height}
|
|
378
|
+
className={className}
|
|
379
|
+
style={style}
|
|
380
|
+
classNames={classNames}
|
|
381
|
+
styles={styles}
|
|
382
|
+
ampm={ampm}
|
|
383
|
+
hourHeight={hourHeight}
|
|
384
|
+
scrollOffsetMinutes={scrollOffsetMinutes}
|
|
385
|
+
timeslots={timeslots}
|
|
386
|
+
minHour={minHour}
|
|
387
|
+
maxHour={maxHour}
|
|
388
|
+
hideHours={hideHours}
|
|
389
|
+
showWeekNumber={showWeekNumber}
|
|
390
|
+
weekNumberPrefix={weekNumberPrefix}
|
|
391
|
+
businessHours={businessHours}
|
|
392
|
+
showNowIndicator={showNowIndicator}
|
|
393
|
+
showAllDayEventCell={showAllDayEventCell}
|
|
394
|
+
dragStepMinutes={dragStepMinutes}
|
|
395
|
+
onPressEvent={onPressEvent}
|
|
396
|
+
onPressCell={onPressCell}
|
|
397
|
+
onCreateEvent={onCreateEvent}
|
|
398
|
+
onDragStart={onDragStart}
|
|
399
|
+
onDragEvent={onDragEvent}
|
|
400
|
+
onPressDateHeader={onPressDateHeader}
|
|
401
|
+
renderEvent={renderTimeEvent}
|
|
402
|
+
eventAccessibilityLabel={eventAccessibilityLabel}
|
|
403
|
+
hourComponent={hourComponent}
|
|
404
|
+
keyboardEventNavigation={keyboardEventNavigation}
|
|
405
|
+
/>
|
|
406
|
+
);
|
|
240
407
|
}
|
|
241
408
|
|
|
409
|
+
// `display: contents` adds no layout box, so the view renders exactly as before
|
|
410
|
+
// while keydowns from the focused grid still bubble to the paging handler (a
|
|
411
|
+
// no-op when `onChangeDate` is unset). Rendered unconditionally so toggling the
|
|
412
|
+
// handler never remounts the view subtree.
|
|
242
413
|
return (
|
|
243
|
-
<
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
events={events}
|
|
247
|
-
weekStartsOn={weekStartsOn}
|
|
248
|
-
weekdayFormat={weekdayFormat}
|
|
249
|
-
numberOfDays={numberOfDays}
|
|
250
|
-
locale={locale}
|
|
251
|
-
theme={theme}
|
|
252
|
-
height={height}
|
|
253
|
-
className={className}
|
|
254
|
-
style={style}
|
|
255
|
-
classNames={classNames}
|
|
256
|
-
styles={styles}
|
|
257
|
-
ampm={ampm}
|
|
258
|
-
hourHeight={hourHeight}
|
|
259
|
-
scrollOffsetMinutes={scrollOffsetMinutes}
|
|
260
|
-
timeslots={timeslots}
|
|
261
|
-
businessHours={businessHours}
|
|
262
|
-
showNowIndicator={showNowIndicator}
|
|
263
|
-
showAllDayEventCell={showAllDayEventCell}
|
|
264
|
-
dragStepMinutes={dragStepMinutes}
|
|
265
|
-
onPressEvent={onPressEvent}
|
|
266
|
-
onPressCell={onPressCell}
|
|
267
|
-
onCreateEvent={onCreateEvent}
|
|
268
|
-
onDragStart={onDragStart}
|
|
269
|
-
onDragEvent={onDragEvent}
|
|
270
|
-
onPressDateHeader={onPressDateHeader}
|
|
271
|
-
renderEvent={renderTimeEvent}
|
|
272
|
-
eventAccessibilityLabel={eventAccessibilityLabel}
|
|
273
|
-
hourComponent={hourComponent}
|
|
274
|
-
/>
|
|
414
|
+
<div style={{ display: "contents" }} onKeyDown={handlePageKeys}>
|
|
415
|
+
{view}
|
|
416
|
+
</div>
|
|
275
417
|
);
|
|
276
418
|
}
|
package/src/ResourceTimeline.tsx
CHANGED
|
@@ -42,16 +42,28 @@ export interface Resource {
|
|
|
42
42
|
/** Props passed to a custom resource-timeline event renderer. */
|
|
43
43
|
export interface ResourceEventArgs<T = unknown> {
|
|
44
44
|
event: CalendarEvent<T>;
|
|
45
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Pixel width of the event bar. In the vertical orientation the columns flex
|
|
47
|
+
* to the container, so this is 0 there; size against `height` instead.
|
|
48
|
+
*/
|
|
46
49
|
width: number;
|
|
50
|
+
/** Pixel height of the event bar; set in the vertical orientation. */
|
|
51
|
+
height?: number;
|
|
47
52
|
onPress: () => void;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
/** Props for {@link ResourceTimeline}. */
|
|
51
56
|
export interface ResourceTimelineProps<T = unknown> extends SlotStyleProps<ResourceTimelineSlot> {
|
|
52
|
-
/** The day to lay out along the
|
|
57
|
+
/** The day to lay out along the time axis. */
|
|
53
58
|
date: Date;
|
|
54
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Lay the day out along the horizontal axis with resources as rows (the
|
|
61
|
+
* default), or down the vertical axis with resources as columns, like the
|
|
62
|
+
* time grid. Vertical reads better on narrow screens: the columns share the
|
|
63
|
+
* width instead of the axis scrolling sideways.
|
|
64
|
+
*/
|
|
65
|
+
orientation?: "horizontal" | "vertical";
|
|
66
|
+
/** The resource lanes: rows when horizontal, columns when vertical. */
|
|
55
67
|
resources: Resource[];
|
|
56
68
|
/** Events; each is placed in the row named by `resourceId(event)`. */
|
|
57
69
|
events: CalendarEvent<T>[];
|
|
@@ -61,11 +73,13 @@ export interface ResourceTimelineProps<T = unknown> extends SlotStyleProps<Resou
|
|
|
61
73
|
startHour?: number;
|
|
62
74
|
/** Last hour shown, exclusive (default 24). */
|
|
63
75
|
endHour?: number;
|
|
64
|
-
/** Pixels per hour along the axis (default 80). */
|
|
76
|
+
/** Pixels per hour along the horizontal axis (default 80). Horizontal only. */
|
|
65
77
|
hourWidth?: number;
|
|
66
|
-
/**
|
|
78
|
+
/** Pixels per hour down the vertical axis (default 48). Vertical only. */
|
|
79
|
+
hourHeight?: number;
|
|
80
|
+
/** Height of each resource row in px (default 56). Horizontal only. */
|
|
67
81
|
rowHeight?: number;
|
|
68
|
-
/** Width of the left resource-label column in px (default 140). */
|
|
82
|
+
/** Width of the left resource-label column in px (default 140). Horizontal only. */
|
|
69
83
|
labelWidth?: number;
|
|
70
84
|
/** 12-hour AM/PM axis labels (default false). */
|
|
71
85
|
ampm?: boolean;
|
|
@@ -90,6 +104,9 @@ export interface ResourceTimelineProps<T = unknown> extends SlotStyleProps<Resou
|
|
|
90
104
|
|
|
91
105
|
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
92
106
|
|
|
107
|
+
// Hour-gutter width in the vertical orientation, matching the time grid's axis.
|
|
108
|
+
const GUTTER_WIDTH = 56;
|
|
109
|
+
|
|
93
110
|
type DragState = {
|
|
94
111
|
key: string;
|
|
95
112
|
kind: "move" | "resize";
|
|
@@ -100,9 +117,13 @@ type DragState = {
|
|
|
100
117
|
|
|
101
118
|
function DefaultBar<T>({
|
|
102
119
|
event,
|
|
120
|
+
height,
|
|
103
121
|
boxProps,
|
|
104
122
|
theme,
|
|
105
123
|
}: ResourceEventArgs<T> & { boxProps: ResolvedSlot; theme: DomCalendarTheme }) {
|
|
124
|
+
// Vertical bars gate the time line on their height (short slots show just the
|
|
125
|
+
// title); horizontal bars keep it always, as before.
|
|
126
|
+
const showTime = height != null ? height > 34 : true;
|
|
106
127
|
const time = eventTimeLabel({
|
|
107
128
|
mode: "day",
|
|
108
129
|
isAllDay: false,
|
|
@@ -139,7 +160,7 @@ function DefaultBar<T>({
|
|
|
139
160
|
>
|
|
140
161
|
{event.title}
|
|
141
162
|
</div>
|
|
142
|
-
{time ? <div style={{ opacity: 0.75, fontSize: 11 }}>{time}</div> : null}
|
|
163
|
+
{time && showTime ? <div style={{ opacity: 0.75, fontSize: 11 }}>{time}</div> : null}
|
|
143
164
|
</div>
|
|
144
165
|
);
|
|
145
166
|
}
|
|
@@ -162,12 +183,14 @@ function DefaultBar<T>({
|
|
|
162
183
|
*/
|
|
163
184
|
export function ResourceTimeline<T = unknown>({
|
|
164
185
|
date,
|
|
186
|
+
orientation = "horizontal",
|
|
165
187
|
resources,
|
|
166
188
|
events,
|
|
167
189
|
resourceId = (event) => (event as { resourceId?: string }).resourceId,
|
|
168
190
|
startHour = 0,
|
|
169
191
|
endHour = 24,
|
|
170
192
|
hourWidth = 80,
|
|
193
|
+
hourHeight = 48,
|
|
171
194
|
rowHeight = 56,
|
|
172
195
|
labelWidth = 140,
|
|
173
196
|
ampm = false,
|
|
@@ -185,6 +208,9 @@ export function ResourceTimeline<T = unknown>({
|
|
|
185
208
|
const slot = createSlots<ResourceTimelineSlot>({ classNames, styles });
|
|
186
209
|
const Renderer = renderEvent;
|
|
187
210
|
const snapHours = dragStepMinutes / 60;
|
|
211
|
+
const vertical = orientation === "vertical";
|
|
212
|
+
// Pixels per hour along whichever axis carries the time.
|
|
213
|
+
const hourSize = vertical ? hourHeight : hourWidth;
|
|
188
214
|
|
|
189
215
|
// `drag` drives the visual; `dragRef` is the source of truth the pointer
|
|
190
216
|
// handlers read so they never see a stale closure between events.
|
|
@@ -210,13 +236,13 @@ export function ResourceTimeline<T = unknown>({
|
|
|
210
236
|
} catch {
|
|
211
237
|
// Pointer capture is best-effort.
|
|
212
238
|
}
|
|
213
|
-
origin.current = { x: e.clientX, startHours, durationHours };
|
|
239
|
+
origin.current = { x: vertical ? e.clientY : e.clientX, startHours, durationHours };
|
|
214
240
|
applyDrag({ key, kind, startHours, durationHours, moved: false });
|
|
215
241
|
};
|
|
216
242
|
const moveDrag = (e: ReactPointerEvent) => {
|
|
217
243
|
const d = dragRef.current;
|
|
218
244
|
if (!d || !origin.current) return;
|
|
219
|
-
const dHours = (e.clientX - origin.current.x) /
|
|
245
|
+
const dHours = ((vertical ? e.clientY : e.clientX) - origin.current.x) / hourSize;
|
|
220
246
|
const snap = (v: number) => Math.round(v / snapHours) * snapHours;
|
|
221
247
|
if (d.kind === "move") {
|
|
222
248
|
const startHours = clamp(
|
|
@@ -275,6 +301,186 @@ export function ResourceTimeline<T = unknown>({
|
|
|
275
301
|
return map;
|
|
276
302
|
}, [resources, events, resourceId, date]);
|
|
277
303
|
|
|
304
|
+
if (vertical) {
|
|
305
|
+
// Time flows down like the time grid: hour gutter on the left, one flexed
|
|
306
|
+
// column per resource, so narrow screens share the width instead of
|
|
307
|
+
// scrolling sideways.
|
|
308
|
+
const trackHeight = (endHour - startHour) * hourHeight;
|
|
309
|
+
const vGridLines = `repeating-linear-gradient(to bottom, transparent 0, transparent ${hourHeight - 1}px, ${theme.gridLine} ${hourHeight - 1}px, ${theme.gridLine} ${hourHeight}px)`;
|
|
310
|
+
return (
|
|
311
|
+
<div
|
|
312
|
+
className={className}
|
|
313
|
+
style={{ fontFamily: theme.fontFamily, color: theme.text, overflowY: "auto", ...style }}
|
|
314
|
+
>
|
|
315
|
+
{/* Header: corner above the hour gutter + one label per resource column */}
|
|
316
|
+
<div
|
|
317
|
+
{...slot("header", {
|
|
318
|
+
// Sticky so the resource labels stay visible while the hours scroll
|
|
319
|
+
// (matching the native renderer's fixed header row).
|
|
320
|
+
base: { display: "flex", position: "sticky", top: 0, zIndex: 2 },
|
|
321
|
+
themed: { borderBottom: `1px solid ${theme.gridLine}`, background: theme.surface },
|
|
322
|
+
})}
|
|
323
|
+
>
|
|
324
|
+
<div {...slot("corner", { base: { width: GUTTER_WIDTH, flex: "none" } })} />
|
|
325
|
+
{resources.map((resource) => (
|
|
326
|
+
<div
|
|
327
|
+
key={resource.id}
|
|
328
|
+
{...slot("resourceLabel", {
|
|
329
|
+
base: {
|
|
330
|
+
flex: 1,
|
|
331
|
+
minWidth: 0,
|
|
332
|
+
padding: "6px 4px",
|
|
333
|
+
textAlign: "center",
|
|
334
|
+
overflow: "hidden",
|
|
335
|
+
textOverflow: "ellipsis",
|
|
336
|
+
whiteSpace: "nowrap",
|
|
337
|
+
boxSizing: "border-box",
|
|
338
|
+
},
|
|
339
|
+
themed: {
|
|
340
|
+
fontSize: 13,
|
|
341
|
+
fontWeight: 600,
|
|
342
|
+
borderLeft: `1px solid ${theme.gridLine}`,
|
|
343
|
+
},
|
|
344
|
+
})}
|
|
345
|
+
>
|
|
346
|
+
{resource.title ?? resource.id}
|
|
347
|
+
</div>
|
|
348
|
+
))}
|
|
349
|
+
</div>
|
|
350
|
+
<div style={{ display: "flex" }}>
|
|
351
|
+
<div
|
|
352
|
+
{...slot("timeAxis", {
|
|
353
|
+
base: {
|
|
354
|
+
position: "relative",
|
|
355
|
+
width: GUTTER_WIDTH,
|
|
356
|
+
flex: "none",
|
|
357
|
+
height: trackHeight,
|
|
358
|
+
},
|
|
359
|
+
})}
|
|
360
|
+
>
|
|
361
|
+
{hours.map((h) => (
|
|
362
|
+
<div
|
|
363
|
+
key={h}
|
|
364
|
+
{...slot("hourTick", {
|
|
365
|
+
base: {
|
|
366
|
+
position: "absolute",
|
|
367
|
+
top: Math.max(0, (h - startHour) * hourHeight - 6),
|
|
368
|
+
right: 6,
|
|
369
|
+
},
|
|
370
|
+
themed: { fontSize: 10, color: theme.textMuted },
|
|
371
|
+
})}
|
|
372
|
+
>
|
|
373
|
+
{formatHour(h, { ampm })}
|
|
374
|
+
</div>
|
|
375
|
+
))}
|
|
376
|
+
</div>
|
|
377
|
+
{resources.map((resource) => {
|
|
378
|
+
const positioned = byResource.get(resource.id) ?? [];
|
|
379
|
+
return (
|
|
380
|
+
<div
|
|
381
|
+
key={resource.id}
|
|
382
|
+
{...slot("row", {
|
|
383
|
+
base: { flex: 1, minWidth: 0 },
|
|
384
|
+
themed: { borderLeft: `1px solid ${theme.gridLine}` },
|
|
385
|
+
})}
|
|
386
|
+
>
|
|
387
|
+
<div {...slot("track", { base: { position: "relative", height: trackHeight } })}>
|
|
388
|
+
<div
|
|
389
|
+
aria-hidden
|
|
390
|
+
{...slot("gridLines", {
|
|
391
|
+
base: { position: "absolute", inset: 0, pointerEvents: "none" },
|
|
392
|
+
themed: { backgroundImage: vGridLines },
|
|
393
|
+
})}
|
|
394
|
+
/>
|
|
395
|
+
{positioned.map((pe, idx) => {
|
|
396
|
+
const key = `${resource.id}:${idx}`;
|
|
397
|
+
const active = drag?.key === key ? drag : null;
|
|
398
|
+
const startH = active ? active.startHours : pe.startHours;
|
|
399
|
+
const durH = active ? active.durationHours : pe.durationHours;
|
|
400
|
+
const top = clamp(startH - startHour, 0, endHour - startHour) * hourHeight;
|
|
401
|
+
const bottom =
|
|
402
|
+
clamp(startH + durH - startHour, 0, endHour - startHour) * hourHeight;
|
|
403
|
+
const height = Math.max(bottom - top, 2);
|
|
404
|
+
// Overlapping events share the column as side-by-side sub-lanes.
|
|
405
|
+
const lanePct = 100 / pe.columns;
|
|
406
|
+
const onPress = () => onPressEvent?.(pe.event);
|
|
407
|
+
const args: ResourceEventArgs<T> = {
|
|
408
|
+
event: pe.event,
|
|
409
|
+
width: 0,
|
|
410
|
+
height,
|
|
411
|
+
onPress,
|
|
412
|
+
};
|
|
413
|
+
const draggable = !!onDragEvent;
|
|
414
|
+
return (
|
|
415
|
+
<button
|
|
416
|
+
key={idx}
|
|
417
|
+
type="button"
|
|
418
|
+
onClick={draggable ? undefined : onPress}
|
|
419
|
+
aria-label={pe.event.title}
|
|
420
|
+
{...dataState({ "data-dragging": !!active })}
|
|
421
|
+
onPointerDown={
|
|
422
|
+
draggable
|
|
423
|
+
? (e) => beginDrag(e, key, "move", pe.startHours, pe.durationHours)
|
|
424
|
+
: undefined
|
|
425
|
+
}
|
|
426
|
+
onPointerMove={draggable ? moveDrag : undefined}
|
|
427
|
+
onPointerUp={draggable ? (e) => endDrag(e, pe.event, onPress) : undefined}
|
|
428
|
+
onPointerCancel={draggable ? cancelDrag : undefined}
|
|
429
|
+
{...slot("event", {
|
|
430
|
+
base: {
|
|
431
|
+
position: "absolute",
|
|
432
|
+
top,
|
|
433
|
+
height,
|
|
434
|
+
left: `${pe.column * lanePct}%`,
|
|
435
|
+
width: `${lanePct}%`,
|
|
436
|
+
padding: 1,
|
|
437
|
+
border: "none",
|
|
438
|
+
background: "transparent",
|
|
439
|
+
cursor: draggable ? "grab" : "pointer",
|
|
440
|
+
touchAction: draggable ? "none" : "auto",
|
|
441
|
+
font: "inherit",
|
|
442
|
+
textAlign: "left",
|
|
443
|
+
boxSizing: "border-box",
|
|
444
|
+
zIndex: active ? 3 : 1,
|
|
445
|
+
opacity: active ? 0.85 : 1,
|
|
446
|
+
},
|
|
447
|
+
})}
|
|
448
|
+
>
|
|
449
|
+
{Renderer ? (
|
|
450
|
+
<Renderer {...args} />
|
|
451
|
+
) : (
|
|
452
|
+
<DefaultBar {...args} theme={theme} boxProps={slot("eventBox")} />
|
|
453
|
+
)}
|
|
454
|
+
{draggable ? (
|
|
455
|
+
<span
|
|
456
|
+
aria-hidden
|
|
457
|
+
onPointerDown={(e) => {
|
|
458
|
+
e.stopPropagation();
|
|
459
|
+
beginDrag(e, key, "resize", pe.startHours, pe.durationHours);
|
|
460
|
+
}}
|
|
461
|
+
style={{
|
|
462
|
+
position: "absolute",
|
|
463
|
+
left: 0,
|
|
464
|
+
right: 0,
|
|
465
|
+
bottom: 0,
|
|
466
|
+
height: 8,
|
|
467
|
+
cursor: "ns-resize",
|
|
468
|
+
touchAction: "none",
|
|
469
|
+
}}
|
|
470
|
+
/>
|
|
471
|
+
) : null}
|
|
472
|
+
</button>
|
|
473
|
+
);
|
|
474
|
+
})}
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
);
|
|
478
|
+
})}
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
278
484
|
const gridLines = `repeating-linear-gradient(to right, transparent 0, transparent ${hourWidth - 1}px, ${theme.gridLine} ${hourWidth - 1}px, ${theme.gridLine} ${hourWidth}px)`;
|
|
279
485
|
|
|
280
486
|
return (
|