@forcecalendar/interface 1.3.0 → 1.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": "@forcecalendar/interface",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "description": "Official interface layer for forceCalendar Core - Enterprise calendar components",
6
6
  "main": "dist/force-calendar-interface.umd.js",
@@ -403,6 +403,39 @@ export class ForceCalendar extends BaseComponent {
403
403
  outline-offset: -2px;
404
404
  }
405
405
 
406
+ .fc-event { touch-action: none; }
407
+
408
+ .fc-dragging {
409
+ opacity: 0.6;
410
+ z-index: 30;
411
+ pointer-events: none;
412
+ }
413
+
414
+ .fc-drop-target {
415
+ outline: 2px dashed var(--fc-primary-color);
416
+ outline-offset: -2px;
417
+ }
418
+
419
+ .fc-resize-handle {
420
+ position: absolute;
421
+ left: 0;
422
+ right: 0;
423
+ bottom: 0;
424
+ height: 6px;
425
+ cursor: ns-resize;
426
+ }
427
+
428
+ .fc-drag-selection {
429
+ position: absolute;
430
+ left: 2px;
431
+ right: 2px;
432
+ background: color-mix(in srgb, var(--fc-primary-color) 18%, transparent);
433
+ border: 1px solid var(--fc-primary-color);
434
+ border-radius: 4px;
435
+ pointer-events: none;
436
+ z-index: 20;
437
+ }
438
+
406
439
  .fc-nav-arrow:focus-visible,
407
440
  .fc-btn:focus-visible,
408
441
  .fc-btn-today:focus-visible,
@@ -834,6 +867,14 @@ export class ForceCalendar extends BaseComponent {
834
867
  }
835
868
  });
836
869
 
870
+ // Drag-to-create emits a range: surface it and open the form prefilled
871
+ this.addListener(this.shadowRoot, 'range-select', e => {
872
+ this.emit('calendar-range-select', e.detail);
873
+ if (modal) {
874
+ modal.open(e.detail.start);
875
+ }
876
+ });
877
+
837
878
  // Handle event saving
838
879
  if (modal) {
839
880
  this.addListener(modal, 'save', e => {
@@ -0,0 +1,362 @@
1
+ /**
2
+ * DragController - pointer-driven interactions for calendar views.
3
+ *
4
+ * Implements drag-to-move (month and time grids), drag-to-resize, and
5
+ * drag-to-create using Pointer Events only: no HTML5 drag-and-drop (which
6
+ * misbehaves in shadow roots), no dependencies, Locker Service safe.
7
+ *
8
+ * Keyboard users have equivalent paths via the WAI-ARIA grid navigation
9
+ * (arrow keys + Enter) that every view already implements.
10
+ */
11
+
12
+ const DRAG_THRESHOLD_PX = 4;
13
+ const SNAP_MINUTES = 15;
14
+ const PX_PER_MINUTE = 1; // time grids render 60px per hour
15
+
16
+ /** Snap a minute offset to the grid. Exported for tests. */
17
+ export function snapMinutes(minutes, snap = SNAP_MINUTES) {
18
+ return Math.round(minutes / snap) * snap;
19
+ }
20
+
21
+ /** Clamp a start minute so [start, start+duration] stays inside a day. */
22
+ export function clampStartMinutes(startMinutes, durationMinutes) {
23
+ return Math.max(0, Math.min(startMinutes, 24 * 60 - durationMinutes));
24
+ }
25
+
26
+ /**
27
+ * Combine a target day with an original start's time-of-day.
28
+ * Exported for tests.
29
+ */
30
+ export function moveDatePreservingTime(targetDay, originalStart) {
31
+ const next = new Date(targetDay);
32
+ next.setHours(
33
+ originalStart.getHours(),
34
+ originalStart.getMinutes(),
35
+ originalStart.getSeconds(),
36
+ originalStart.getMilliseconds()
37
+ );
38
+ return next;
39
+ }
40
+
41
+ export class DragController {
42
+ constructor(renderer) {
43
+ this.renderer = renderer;
44
+ this.container = renderer.container;
45
+ this.stateManager = renderer.stateManager;
46
+ this._active = null;
47
+ this._docListeners = [];
48
+ }
49
+
50
+ /** Month view: drag an event chip onto another day cell. */
51
+ enableMonthMove() {
52
+ this.renderer.addListener(this.container, 'pointerdown', e => {
53
+ if (e.button !== 0) return;
54
+ const eventEl = e.target.closest('.fc-event');
55
+ if (!eventEl || !this.container.contains(eventEl)) return;
56
+ const originCell = eventEl.closest('.fc-month-day');
57
+ if (!originCell) return;
58
+
59
+ this._arm(e, {
60
+ mode: 'month-move',
61
+ eventEl,
62
+ eventId: eventEl.dataset.eventId,
63
+ onDragMove: ev => this._monthDragMove(ev),
64
+ onDrop: ev => this._monthDrop(ev)
65
+ });
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Time grids (week/day): drag events vertically/across columns to move,
71
+ * drag the bottom handle to resize, drag empty grid to create.
72
+ */
73
+ enableTimeGrid(columnSelector) {
74
+ this._columnSelector = columnSelector;
75
+ this._injectResizeHandles();
76
+
77
+ this.renderer.addListener(this.container, 'pointerdown', e => {
78
+ if (e.button !== 0) return;
79
+
80
+ const handle = e.target.closest('.fc-resize-handle');
81
+ if (handle) {
82
+ const eventEl = handle.closest('.fc-timed-event');
83
+ if (!eventEl) return;
84
+ e.preventDefault();
85
+ this._arm(e, {
86
+ mode: 'resize',
87
+ eventEl,
88
+ eventId: eventEl.dataset.eventId,
89
+ originTop: parseFloat(eventEl.style.top) || 0,
90
+ originHeight: parseFloat(eventEl.style.height) || 30,
91
+ onDragMove: ev => this._resizeDragMove(ev),
92
+ onDrop: () => this._resizeDrop()
93
+ });
94
+ return;
95
+ }
96
+
97
+ const eventEl = e.target.closest('.fc-timed-event');
98
+ if (eventEl && this.container.contains(eventEl)) {
99
+ this._arm(e, {
100
+ mode: 'time-move',
101
+ eventEl,
102
+ eventId: eventEl.dataset.eventId,
103
+ originTop: parseFloat(eventEl.style.top) || 0,
104
+ originColumn: eventEl.closest(columnSelector),
105
+ onDragMove: ev => this._timeMoveDragMove(ev),
106
+ onDrop: () => this._timeMoveDrop()
107
+ });
108
+ return;
109
+ }
110
+
111
+ const column = e.target.closest(columnSelector);
112
+ if (column && !e.target.closest('.fc-event')) {
113
+ this._arm(e, {
114
+ mode: 'create',
115
+ column,
116
+ onDragMove: ev => this._createDragMove(ev),
117
+ onDrop: () => this._createDrop()
118
+ });
119
+ }
120
+ });
121
+ }
122
+
123
+ /** Track from pointerdown; promote to a drag past the threshold. */
124
+ _arm(e, spec) {
125
+ this._active = {
126
+ ...spec,
127
+ startX: e.clientX,
128
+ startY: e.clientY,
129
+ dragging: false
130
+ };
131
+ const move = ev => this._onPointerMove(ev);
132
+ const up = ev => this._onPointerUp(ev);
133
+ const cancel = () => this._cancel();
134
+ const key = ev => {
135
+ if (ev.key === 'Escape') this._cancel();
136
+ };
137
+ const doc = this.container.ownerDocument;
138
+ doc.addEventListener('pointermove', move);
139
+ doc.addEventListener('pointerup', up);
140
+ doc.addEventListener('pointercancel', cancel);
141
+ doc.addEventListener('keydown', key);
142
+ this._docListeners = [
143
+ ['pointermove', move],
144
+ ['pointerup', up],
145
+ ['pointercancel', cancel],
146
+ ['keydown', key]
147
+ ];
148
+ }
149
+
150
+ _onPointerMove(e) {
151
+ const a = this._active;
152
+ if (!a) return;
153
+ if (!a.dragging) {
154
+ if (
155
+ Math.abs(e.clientX - a.startX) < DRAG_THRESHOLD_PX &&
156
+ Math.abs(e.clientY - a.startY) < DRAG_THRESHOLD_PX
157
+ ) {
158
+ return;
159
+ }
160
+ a.dragging = true;
161
+ if (a.eventEl) {
162
+ a.eventEl.classList.add('fc-dragging');
163
+ }
164
+ }
165
+ e.preventDefault();
166
+ a.onDragMove(e);
167
+ }
168
+
169
+ _onPointerUp(e) {
170
+ const a = this._active;
171
+ this._teardownDocListeners();
172
+ if (!a) return;
173
+ if (a.dragging) {
174
+ // Swallow the click that follows a drag so it doesn't select/open
175
+ const doc = this.container.ownerDocument;
176
+ const swallow = ev => {
177
+ ev.stopPropagation();
178
+ ev.preventDefault();
179
+ };
180
+ doc.addEventListener('click', swallow, { capture: true, once: true });
181
+ setTimeout(() => doc.removeEventListener('click', swallow, { capture: true }), 0);
182
+ a.onDrop(e);
183
+ }
184
+ this._cleanupVisuals(a);
185
+ this._active = null;
186
+ }
187
+
188
+ _cancel() {
189
+ const a = this._active;
190
+ this._teardownDocListeners();
191
+ this._active = null;
192
+ if (a) this._cleanupVisuals(a);
193
+ }
194
+
195
+ _teardownDocListeners() {
196
+ const doc = this.container.ownerDocument;
197
+ for (const [name, fn] of this._docListeners) {
198
+ doc.removeEventListener(name, fn);
199
+ }
200
+ this._docListeners = [];
201
+ }
202
+
203
+ _cleanupVisuals(a) {
204
+ if (a.eventEl) {
205
+ a.eventEl.classList.remove('fc-dragging');
206
+ a.eventEl.style.transform = '';
207
+ }
208
+ this.container
209
+ .querySelectorAll('.fc-drop-target')
210
+ .forEach(el => el.classList.remove('fc-drop-target'));
211
+ this._selectionEl?.remove();
212
+ this._selectionEl = null;
213
+ }
214
+
215
+ // ----- month move -----
216
+
217
+ _cellAtPoint(x, y) {
218
+ for (const cell of this.container.querySelectorAll('.fc-month-day')) {
219
+ const r = cell.getBoundingClientRect();
220
+ if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) return cell;
221
+ }
222
+ return null;
223
+ }
224
+
225
+ _monthDragMove(e) {
226
+ const a = this._active;
227
+ a.eventEl.style.transform = `translate(${e.clientX - a.startX}px, ${e.clientY - a.startY}px)`;
228
+ const cell = this._cellAtPoint(e.clientX, e.clientY);
229
+ if (cell !== a.dropCell) {
230
+ a.dropCell?.classList.remove('fc-drop-target');
231
+ cell?.classList.add('fc-drop-target');
232
+ a.dropCell = cell;
233
+ }
234
+ }
235
+
236
+ _monthDrop() {
237
+ const a = this._active ?? {};
238
+ const cell = a.dropCell;
239
+ if (!cell) return;
240
+ const event = this.stateManager.getEvents().find(ev => ev.id === a.eventId);
241
+ if (!event) return;
242
+ const oldStart = new Date(event.start);
243
+ const newStart = moveDatePreservingTime(new Date(cell.dataset.date), oldStart);
244
+ const delta = newStart.getTime() - oldStart.getTime();
245
+ if (delta === 0) return;
246
+ this.stateManager.updateEvent(a.eventId, {
247
+ start: newStart,
248
+ end: new Date(new Date(event.end).getTime() + delta)
249
+ });
250
+ }
251
+
252
+ // ----- time-grid move -----
253
+
254
+ _columnAtPoint(x) {
255
+ for (const col of this.container.querySelectorAll(this._columnSelector)) {
256
+ const r = col.getBoundingClientRect();
257
+ if (x >= r.left && x < r.right) return col;
258
+ }
259
+ return null;
260
+ }
261
+
262
+ _timeMoveDragMove(e) {
263
+ const a = this._active;
264
+ const col = this._columnAtPoint(e.clientX) || a.originColumn;
265
+ a.dropColumn = col;
266
+ a.deltaMinutes = snapMinutes((e.clientY - a.startY) / PX_PER_MINUTE);
267
+ const originRect = a.originColumn.getBoundingClientRect();
268
+ const colRect = col.getBoundingClientRect();
269
+ a.eventEl.style.transform = `translate(${colRect.left - originRect.left}px, ${a.deltaMinutes * PX_PER_MINUTE}px)`;
270
+ }
271
+
272
+ _timeMoveDrop() {
273
+ const a = this._active ?? {};
274
+ const event = this.stateManager.getEvents().find(ev => ev.id === a.eventId);
275
+ if (!event || (!a.deltaMinutes && a.dropColumn === a.originColumn)) return;
276
+
277
+ const oldStart = new Date(event.start);
278
+ const duration = new Date(event.end).getTime() - oldStart.getTime();
279
+ const targetDay = a.dropColumn ? new Date(a.dropColumn.dataset.date) : oldStart;
280
+ const dayAligned = moveDatePreservingTime(targetDay, oldStart);
281
+ const startMinutes = clampStartMinutes(
282
+ dayAligned.getHours() * 60 + dayAligned.getMinutes() + (a.deltaMinutes || 0),
283
+ Math.round(duration / 60000)
284
+ );
285
+ const newStart = new Date(dayAligned);
286
+ newStart.setHours(Math.floor(startMinutes / 60), startMinutes % 60, 0, 0);
287
+ if (newStart.getTime() === oldStart.getTime()) return;
288
+ this.stateManager.updateEvent(a.eventId, {
289
+ start: newStart,
290
+ end: new Date(newStart.getTime() + duration)
291
+ });
292
+ }
293
+
294
+ // ----- resize -----
295
+
296
+ _resizeDragMove(e) {
297
+ const a = this._active;
298
+ const deltaMinutes = snapMinutes((e.clientY - a.startY) / PX_PER_MINUTE);
299
+ a.newHeight = Math.max(SNAP_MINUTES, a.originHeight + deltaMinutes);
300
+ a.newHeight = Math.min(a.newHeight, 24 * 60 - a.originTop);
301
+ a.eventEl.style.height = `${a.newHeight}px`;
302
+ }
303
+
304
+ _resizeDrop() {
305
+ const a = this._active ?? {};
306
+ if (!a.newHeight || a.newHeight === a.originHeight) return;
307
+ const event = this.stateManager.getEvents().find(ev => ev.id === a.eventId);
308
+ if (!event) return;
309
+ const newEnd = new Date(new Date(event.start).getTime() + a.newHeight * 60000);
310
+ this.stateManager.updateEvent(a.eventId, { end: newEnd });
311
+ }
312
+
313
+ // ----- drag-to-create -----
314
+
315
+ _createDragMove(e) {
316
+ const a = this._active;
317
+ const colRect = a.column.getBoundingClientRect();
318
+ const fromY = a.startY - colRect.top;
319
+ const toY = e.clientY - colRect.top;
320
+ a.fromMinutes = snapMinutes(Math.min(fromY, toY) / PX_PER_MINUTE);
321
+ a.toMinutes = snapMinutes(Math.max(fromY, toY) / PX_PER_MINUTE);
322
+ a.fromMinutes = Math.max(0, a.fromMinutes);
323
+ a.toMinutes = Math.min(24 * 60, Math.max(a.toMinutes, a.fromMinutes + SNAP_MINUTES));
324
+
325
+ if (!this._selectionEl) {
326
+ this._selectionEl = this.container.ownerDocument.createElement('div');
327
+ this._selectionEl.className = 'fc-drag-selection';
328
+ a.column.appendChild(this._selectionEl);
329
+ }
330
+ this._selectionEl.style.top = `${a.fromMinutes * PX_PER_MINUTE}px`;
331
+ this._selectionEl.style.height = `${(a.toMinutes - a.fromMinutes) * PX_PER_MINUTE}px`;
332
+ }
333
+
334
+ _createDrop() {
335
+ const a = this._active ?? {};
336
+ if (a.fromMinutes == null) return;
337
+ const start = new Date(a.column.dataset.date);
338
+ start.setHours(Math.floor(a.fromMinutes / 60), a.fromMinutes % 60, 0, 0);
339
+ const end = new Date(a.column.dataset.date);
340
+ end.setHours(Math.floor(a.toMinutes / 60), a.toMinutes % 60, 0, 0);
341
+ this.container.dispatchEvent(
342
+ new CustomEvent('range-select', {
343
+ detail: { start, end },
344
+ bubbles: true,
345
+ composed: true
346
+ })
347
+ );
348
+ }
349
+
350
+ /** Append a resize handle to every timed event (idempotent per render). */
351
+ _injectResizeHandles() {
352
+ for (const el of this.container.querySelectorAll('.fc-timed-event')) {
353
+ if (el.querySelector('.fc-resize-handle')) continue;
354
+ const handle = this.container.ownerDocument.createElement('div');
355
+ handle.className = 'fc-resize-handle';
356
+ handle.setAttribute('aria-hidden', 'true');
357
+ el.appendChild(handle);
358
+ }
359
+ }
360
+ }
361
+
362
+ export default DragController;
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { BaseViewRenderer } from './BaseViewRenderer.js';
8
+ import { DragController } from '../core/DragController.js';
8
9
  import { DateUtils } from '../utils/DateUtils.js';
9
10
 
10
11
  export class DayViewRenderer extends BaseViewRenderer {
@@ -216,6 +217,9 @@ export class DayViewRenderer extends BaseViewRenderer {
216
217
  const current = this.stateManager.getState().currentDate || new Date();
217
218
  const label = new Intl.DateTimeFormat(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }).format(current);
218
219
  this._enhanceTimeGridAccessibility('.fc-day-column', label);
220
+
221
+ // Drag to move/resize events and drag empty grid to create
222
+ new DragController(this).enableTimeGrid('.fc-day-column');
219
223
  }
220
224
 
221
225
  _scrollToCurrentTime() {
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { BaseViewRenderer } from './BaseViewRenderer.js';
8
8
  import { DateUtils } from '../utils/DateUtils.js';
9
+ import { DragController } from '../core/DragController.js';
9
10
 
10
11
  export class MonthViewRenderer extends BaseViewRenderer {
11
12
  constructor(container, stateManager) {
@@ -228,6 +229,9 @@ export class MonthViewRenderer extends BaseViewRenderer {
228
229
 
229
230
  // Common event handlers (event clicks)
230
231
  this.attachCommonEventHandlers();
232
+
233
+ // Drag an event chip onto another day to move it
234
+ new DragController(this).enableMonthMove();
231
235
  }
232
236
 
233
237
  /**
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { BaseViewRenderer } from './BaseViewRenderer.js';
8
8
  import { DateUtils } from '../utils/DateUtils.js';
9
+ import { DragController } from '../core/DragController.js';
9
10
 
10
11
  export class WeekViewRenderer extends BaseViewRenderer {
11
12
  constructor(container, stateManager) {
@@ -196,6 +197,9 @@ export class WeekViewRenderer extends BaseViewRenderer {
196
197
  const first = days[0] ? new Date(days[0].date) : new Date();
197
198
  const label = `Week of ${new Intl.DateTimeFormat(locale, { month: 'long', day: 'numeric', year: 'numeric' }).format(first)}`;
198
199
  this._enhanceTimeGridAccessibility('.fc-week-day-column', label);
200
+
201
+ // Drag to move/resize events and drag empty grid to create
202
+ new DragController(this).enableTimeGrid('.fc-week-day-column');
199
203
  }
200
204
 
201
205
  _scrollToCurrentTime() {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * DragController - pointer-driven interactions for calendar views.
3
+ *
4
+ * Implements drag-to-move (month and time grids), drag-to-resize, and
5
+ * drag-to-create using Pointer Events only: no HTML5 drag-and-drop (which
6
+ * misbehaves in shadow roots), no dependencies, Locker Service safe.
7
+ *
8
+ * Keyboard users have equivalent paths via the WAI-ARIA grid navigation
9
+ * (arrow keys + Enter) that every view already implements.
10
+ */
11
+ /** Snap a minute offset to the grid. Exported for tests. */
12
+ export declare function snapMinutes(minutes: any, snap?: number): number;
13
+ /** Clamp a start minute so [start, start+duration] stays inside a day. */
14
+ export declare function clampStartMinutes(startMinutes: any, durationMinutes: any): number;
15
+ /**
16
+ * Combine a target day with an original start's time-of-day.
17
+ * Exported for tests.
18
+ */
19
+ export declare function moveDatePreservingTime(targetDay: any, originalStart: any): Date;
20
+ export declare class DragController {
21
+ renderer: any;
22
+ container: any;
23
+ stateManager: any;
24
+ _active: any;
25
+ _docListeners: any[];
26
+ _columnSelector: any;
27
+ _selectionEl: any;
28
+ constructor(renderer: any);
29
+ /** Month view: drag an event chip onto another day cell. */
30
+ enableMonthMove(): void;
31
+ /**
32
+ * Time grids (week/day): drag events vertically/across columns to move,
33
+ * drag the bottom handle to resize, drag empty grid to create.
34
+ */
35
+ enableTimeGrid(columnSelector: any): void;
36
+ /** Track from pointerdown; promote to a drag past the threshold. */
37
+ _arm(e: any, spec: any): void;
38
+ _onPointerMove(e: any): void;
39
+ _onPointerUp(e: any): void;
40
+ _cancel(): void;
41
+ _teardownDocListeners(): void;
42
+ _cleanupVisuals(a: any): void;
43
+ _cellAtPoint(x: any, y: any): any;
44
+ _monthDragMove(e: any): void;
45
+ _monthDrop(): void;
46
+ _columnAtPoint(x: any): any;
47
+ _timeMoveDragMove(e: any): void;
48
+ _timeMoveDrop(): void;
49
+ _resizeDragMove(e: any): void;
50
+ _resizeDrop(): void;
51
+ _createDragMove(e: any): void;
52
+ _createDrop(): void;
53
+ /** Append a resize handle to every timed event (idempotent per render). */
54
+ _injectResizeHandles(): void;
55
+ }
56
+ export default DragController;