@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@super-calendar/dom",
3
- "version": "2.3.1",
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.3.1"
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 type { Locale } from "date-fns";
2
- import type { CSSProperties, ReactElement, ReactNode } from "react";
3
- import type {
4
- BusinessHours,
5
- CalendarEvent,
6
- DateRange,
7
- DateSelectionConstraints,
8
- EventAccessibilityLabeler,
9
- TimeGridMode,
10
- WeekdayFormat,
11
- WeekStartsOn,
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
- return (
317
+ view = (
190
318
  <Agenda<T>
191
- events={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={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
- <TimeGrid<T>
244
- date={date}
245
- mode={mode}
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
  }
@@ -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
- /** Pixel width of the event bar. */
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 horizontal axis. */
57
+ /** The day to lay out along the time axis. */
53
58
  date: Date;
54
- /** The rows, top to bottom. */
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
- /** Height of each resource row in px (default 56). */
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) / hourWidth;
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 (