@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/dist/force-calendar-interface.esm.js +352 -16
- package/dist/force-calendar-interface.esm.js.map +1 -1
- package/dist/force-calendar-interface.umd.js +43 -9
- package/dist/force-calendar-interface.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ForceCalendar.js +42 -0
- package/src/core/DragController.js +362 -0
- package/src/renderers/BaseViewRenderer.js +140 -0
- package/src/renderers/DayViewRenderer.js +10 -1
- package/src/renderers/MonthViewRenderer.js +4 -0
- package/src/renderers/WeekViewRenderer.js +11 -1
- package/types/core/DragController.d.ts +56 -0
- package/types/renderers/BaseViewRenderer.d.ts +29 -0
package/package.json
CHANGED
|
@@ -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(
|
|
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(
|
|
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() {
|