@forcecalendar/interface 1.2.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.2.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",
@@ -397,11 +397,45 @@ export class ForceCalendar extends BaseComponent {
397
397
  }
398
398
 
399
399
  .fc-month-day:focus-visible,
400
+ .fc-hour-slot:focus-visible,
400
401
  .fc-event:focus-visible {
401
402
  outline: 2px solid var(--fc-primary-color);
402
403
  outline-offset: -2px;
403
404
  }
404
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
+
405
439
  .fc-nav-arrow:focus-visible,
406
440
  .fc-btn:focus-visible,
407
441
  .fc-btn-today:focus-visible,
@@ -833,6 +867,14 @@ export class ForceCalendar extends BaseComponent {
833
867
  }
834
868
  });
835
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
+
836
878
  // Handle event saving
837
879
  if (modal) {
838
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;
@@ -329,6 +329,146 @@ export class BaseViewRenderer {
329
329
  }
330
330
  }
331
331
 
332
+ /**
333
+ * WAI-ARIA semantics and keyboard navigation for time grids (week/day
334
+ * views). The DOM is column-major (a column per day, an hour line per
335
+ * slot), so each day column is exposed as a row of 24 hour gridcells.
336
+ * Arrow keys navigate visually: Up/Down moves hours, Left/Right moves
337
+ * days, Home/End jumps to the day's bounds, PageUp/PageDown navigates
338
+ * periods, Enter/Space selects the slot's date and time.
339
+ * @param {string} columnSelector - Selector for the day columns
340
+ * @param {string} gridLabel - Accessible label for the grid
341
+ */
342
+ _enhanceTimeGridAccessibility(columnSelector, gridLabel) {
343
+ const grid = this.container.querySelector('.fc-time-grid');
344
+ if (!grid) return;
345
+ grid.setAttribute('role', 'grid');
346
+ grid.setAttribute('aria-label', gridLabel);
347
+ const gutter = this.container.querySelector('.fc-time-gutter');
348
+ if (gutter) gutter.setAttribute('aria-hidden', 'true');
349
+
350
+ const locale = this.stateManager.getState().config.locale || 'en-US';
351
+ const dayFormatter = new Intl.DateTimeFormat(locale, {
352
+ weekday: 'long',
353
+ month: 'long',
354
+ day: 'numeric'
355
+ });
356
+
357
+ const columns = Array.from(this.container.querySelectorAll(columnSelector));
358
+ for (const col of columns) {
359
+ col.setAttribute('role', 'row');
360
+ const colDate = new Date(col.dataset.date);
361
+ const colLabel = dayFormatter.format(colDate);
362
+ col.setAttribute('aria-label', colLabel);
363
+ for (const slot of col.querySelectorAll('.fc-hour-slot')) {
364
+ slot.setAttribute('role', 'gridcell');
365
+ slot.setAttribute('tabindex', '-1');
366
+ slot.setAttribute(
367
+ 'aria-label',
368
+ `${colLabel}, ${this.formatHour(Number(slot.dataset.hour))}`
369
+ );
370
+ }
371
+ }
372
+
373
+ this._applySlotRovingTabindex(columns);
374
+
375
+ if (this._pendingSlotFocus) {
376
+ const { colIndex, hour } = this._pendingSlotFocus;
377
+ this._pendingSlotFocus = null;
378
+ const target = this._slotAt(columns, colIndex, hour);
379
+ if (target) {
380
+ this._applySlotRovingTabindex(columns, target);
381
+ target.focus();
382
+ }
383
+ }
384
+
385
+ this.addListener(this.container, 'keydown', e => {
386
+ const slot = e.target.closest('.fc-hour-slot');
387
+ if (!slot || !this.container.contains(slot)) return;
388
+ const col = slot.closest(columnSelector);
389
+ const colIndex = columns.indexOf(col);
390
+ const hour = Number(slot.dataset.hour);
391
+ let target = null;
392
+
393
+ switch (e.key) {
394
+ case 'ArrowDown':
395
+ target = this._slotAt(columns, colIndex, hour + 1);
396
+ break;
397
+ case 'ArrowUp':
398
+ target = this._slotAt(columns, colIndex, hour - 1);
399
+ break;
400
+ case 'ArrowRight':
401
+ target = this._slotAt(columns, colIndex + 1, hour);
402
+ break;
403
+ case 'ArrowLeft':
404
+ target = this._slotAt(columns, colIndex - 1, hour);
405
+ break;
406
+ case 'Home':
407
+ target = this._slotAt(columns, colIndex, 0);
408
+ break;
409
+ case 'End':
410
+ target = this._slotAt(columns, colIndex, 23);
411
+ break;
412
+ case 'PageUp':
413
+ case 'PageDown':
414
+ e.preventDefault();
415
+ this._pendingSlotFocus = { colIndex, hour };
416
+ if (e.key === 'PageDown') {
417
+ this.stateManager.next();
418
+ } else {
419
+ this.stateManager.previous();
420
+ }
421
+ return;
422
+ case 'Enter':
423
+ case ' ': {
424
+ e.preventDefault();
425
+ const date = new Date(col.dataset.date);
426
+ date.setHours(hour, 0, 0, 0);
427
+ this._pendingSlotFocus = { colIndex, hour };
428
+ this.stateManager.selectDate(date);
429
+ return;
430
+ }
431
+ default:
432
+ return;
433
+ }
434
+
435
+ e.preventDefault();
436
+ if (target) {
437
+ this._applySlotRovingTabindex(columns, target);
438
+ target.scrollIntoView?.({ block: 'nearest' });
439
+ target.focus();
440
+ }
441
+ });
442
+ }
443
+
444
+ /**
445
+ * Get the hour slot at a column/hour position
446
+ * @private
447
+ */
448
+ _slotAt(columns, colIndex, hour) {
449
+ if (colIndex < 0 || colIndex >= columns.length || hour < 0 || hour > 23) return null;
450
+ return columns[colIndex].querySelector(`.fc-hour-slot[data-hour="${hour}"]`);
451
+ }
452
+
453
+ /**
454
+ * Keep exactly one hour slot tabbable. Defaults to 9:00 AM in the
455
+ * first column (today's column when present).
456
+ * @private
457
+ */
458
+ _applySlotRovingTabindex(columns, active = null) {
459
+ const slots = this.container.querySelectorAll('.fc-hour-slot');
460
+ if (slots.length === 0) return;
461
+ if (!active) {
462
+ const todayCol =
463
+ columns.find(c => this.isToday(new Date(c.dataset.date))) || columns[0];
464
+ active = todayCol && todayCol.querySelector('.fc-hour-slot[data-hour="9"]');
465
+ active = active || slots[0];
466
+ }
467
+ for (const s of slots) {
468
+ s.setAttribute('tabindex', s === active ? '0' : '-1');
469
+ }
470
+ }
471
+
332
472
  /**
333
473
  * Make rendered events keyboard-reachable and screen-reader labeled.
334
474
  * Runs after every render, across all views.
@@ -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 {
@@ -165,7 +166,7 @@ export class DayViewRenderer extends BaseViewRenderer {
165
166
  return `
166
167
  <div class="fc-day-column" data-date="${dayDate.toISOString()}" style="position: relative; cursor: pointer;">
167
168
  <!-- Hour grid lines -->
168
- ${hours.map(() => `<div style="height: ${this.hourHeight}px; border-bottom: 1px solid var(--fc-background-hover);"></div>`).join('')}
169
+ ${hours.map(h => `<div class="fc-hour-slot" data-hour="${h}" style="height: ${this.hourHeight}px; border-bottom: 1px solid var(--fc-background-hover);"></div>`).join('')}
169
170
 
170
171
  <!-- Now indicator for today -->
171
172
  ${isToday ? this.renderNowIndicator() : ''}
@@ -211,6 +212,14 @@ export class DayViewRenderer extends BaseViewRenderer {
211
212
 
212
213
  // Common event handlers (event clicks)
213
214
  this.attachCommonEventHandlers();
215
+
216
+ const locale = this.stateManager.getState().config.locale || 'en-US';
217
+ const current = this.stateManager.getState().currentDate || new Date();
218
+ const label = new Intl.DateTimeFormat(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }).format(current);
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');
214
223
  }
215
224
 
216
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) {
@@ -144,7 +145,7 @@ export class WeekViewRenderer extends BaseViewRenderer {
144
145
  return `
145
146
  <div class="fc-week-day-column" data-date="${day.date.toISOString()}" style="border-right: 1px solid var(--fc-border-color); position: relative; cursor: pointer;">
146
147
  <!-- Hour grid lines -->
147
- ${hours.map(() => `<div style="height: ${this.hourHeight}px; border-bottom: 1px solid var(--fc-background-hover);"></div>`).join('')}
148
+ ${hours.map(h => `<div class="fc-hour-slot" data-hour="${h}" style="height: ${this.hourHeight}px; border-bottom: 1px solid var(--fc-background-hover);"></div>`).join('')}
148
149
 
149
150
  <!-- Now indicator for today -->
150
151
  ${day.isToday ? this.renderNowIndicator() : ''}
@@ -190,6 +191,15 @@ export class WeekViewRenderer extends BaseViewRenderer {
190
191
 
191
192
  // Common event handlers (event clicks)
192
193
  this.attachCommonEventHandlers();
194
+
195
+ const days = this.stateManager.getViewData()?.days || [];
196
+ const locale = this.stateManager.getState().config.locale || 'en-US';
197
+ const first = days[0] ? new Date(days[0].date) : new Date();
198
+ const label = `Week of ${new Intl.DateTimeFormat(locale, { month: 'long', day: 'numeric', year: 'numeric' }).format(first)}`;
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');
193
203
  }
194
204
 
195
205
  _scrollToCurrentTime() {