@mintplayer/ng-bootstrap 21.30.0 → 21.31.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/fesm2022/mintplayer-ng-bootstrap-a11y.mjs +455 -0
- package/fesm2022/mintplayer-ng-bootstrap-a11y.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-bootstrap-accordion.mjs +8 -5
- package/fesm2022/mintplayer-ng-bootstrap-accordion.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-breadcrumb.mjs +10 -4
- package/fesm2022/mintplayer-ng-bootstrap-breadcrumb.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-button-group.mjs +7 -4
- package/fesm2022/mintplayer-ng-bootstrap-button-group.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-calendar.mjs +131 -3
- package/fesm2022/mintplayer-ng-bootstrap-calendar.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-carousel.mjs +80 -48
- package/fesm2022/mintplayer-ng-bootstrap-carousel.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-code-snippet.mjs +4 -1
- package/fesm2022/mintplayer-ng-bootstrap-code-snippet.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-color-picker.mjs +218 -14
- package/fesm2022/mintplayer-ng-bootstrap-color-picker.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-datatable.mjs +4 -3
- package/fesm2022/mintplayer-ng-bootstrap-datatable.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-datepicker.mjs +2 -2
- package/fesm2022/mintplayer-ng-bootstrap-datepicker.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-dock.mjs +294 -3
- package/fesm2022/mintplayer-ng-bootstrap-dock.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-dropdown-menu.mjs +163 -18
- package/fesm2022/mintplayer-ng-bootstrap-dropdown-menu.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-dropdown.mjs +179 -7
- package/fesm2022/mintplayer-ng-bootstrap-dropdown.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-file-upload.mjs +14 -4
- package/fesm2022/mintplayer-ng-bootstrap-file-upload.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-has-overlay.mjs +14 -0
- package/fesm2022/mintplayer-ng-bootstrap-has-overlay.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-list-group.mjs +2 -1
- package/fesm2022/mintplayer-ng-bootstrap-list-group.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-marquee.mjs +7 -4
- package/fesm2022/mintplayer-ng-bootstrap-marquee.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-modal.mjs +70 -6
- package/fesm2022/mintplayer-ng-bootstrap-modal.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-multiselect.mjs +5 -4
- package/fesm2022/mintplayer-ng-bootstrap-multiselect.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-navbar-toggler.mjs +6 -6
- package/fesm2022/mintplayer-ng-bootstrap-navbar-toggler.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-navbar.mjs +45 -13
- package/fesm2022/mintplayer-ng-bootstrap-navbar.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-offcanvas.mjs +51 -5
- package/fesm2022/mintplayer-ng-bootstrap-offcanvas.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-pagination.mjs +5 -3
- package/fesm2022/mintplayer-ng-bootstrap-pagination.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-placeholder.mjs +18 -4
- package/fesm2022/mintplayer-ng-bootstrap-placeholder.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-playlist-toggler.mjs +6 -6
- package/fesm2022/mintplayer-ng-bootstrap-playlist-toggler.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-popover.mjs +61 -6
- package/fesm2022/mintplayer-ng-bootstrap-popover.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-priority-nav.mjs +19 -4
- package/fesm2022/mintplayer-ng-bootstrap-priority-nav.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-progress-bar.mjs +8 -5
- package/fesm2022/mintplayer-ng-bootstrap-progress-bar.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-range.mjs +4 -3
- package/fesm2022/mintplayer-ng-bootstrap-range.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-rating.mjs +34 -4
- package/fesm2022/mintplayer-ng-bootstrap-rating.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-reduced-motion.mjs +59 -0
- package/fesm2022/mintplayer-ng-bootstrap-reduced-motion.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-bootstrap-resizable.mjs +91 -2
- package/fesm2022/mintplayer-ng-bootstrap-resizable.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-scheduler.mjs +16 -5
- package/fesm2022/mintplayer-ng-bootstrap-scheduler.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-scrollspy.mjs +2 -2
- package/fesm2022/mintplayer-ng-bootstrap-scrollspy.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-searchbox.mjs +28 -5
- package/fesm2022/mintplayer-ng-bootstrap-searchbox.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-select.mjs +4 -3
- package/fesm2022/mintplayer-ng-bootstrap-select.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-select2.mjs +18 -4
- package/fesm2022/mintplayer-ng-bootstrap-select2.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-signature-pad.mjs +4 -3
- package/fesm2022/mintplayer-ng-bootstrap-signature-pad.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-tab-control.mjs +2 -2
- package/fesm2022/mintplayer-ng-bootstrap-tab-control.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-table.mjs +10 -3
- package/fesm2022/mintplayer-ng-bootstrap-table.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-tile-manager.mjs +143 -29
- package/fesm2022/mintplayer-ng-bootstrap-tile-manager.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-timepicker.mjs +2 -2
- package/fesm2022/mintplayer-ng-bootstrap-timepicker.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-toast.mjs +7 -4
- package/fesm2022/mintplayer-ng-bootstrap-toast.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-toggle-button.mjs +42 -21
- package/fesm2022/mintplayer-ng-bootstrap-toggle-button.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-tooltip.mjs +33 -4
- package/fesm2022/mintplayer-ng-bootstrap-tooltip.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-treeview.mjs +17 -7
- package/fesm2022/mintplayer-ng-bootstrap-treeview.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-typeahead.mjs +50 -8
- package/fesm2022/mintplayer-ng-bootstrap-typeahead.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-virtual-datatable.mjs +34 -12
- package/fesm2022/mintplayer-ng-bootstrap-virtual-datatable.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-web-components-a11y.mjs +74 -0
- package/fesm2022/mintplayer-ng-bootstrap-web-components-a11y.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-bootstrap-web-components-scheduler.mjs +1476 -71
- package/fesm2022/mintplayer-ng-bootstrap-web-components-scheduler.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-web-components-splitter.mjs +194 -2
- package/fesm2022/mintplayer-ng-bootstrap-web-components-splitter.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-web-components-tab-control.mjs +4 -0
- package/fesm2022/mintplayer-ng-bootstrap-web-components-tab-control.mjs.map +1 -1
- package/package.json +14 -2
- package/types/mintplayer-ng-bootstrap-a11y.d.ts +196 -0
- package/types/mintplayer-ng-bootstrap-accordion.d.ts +4 -2
- package/types/mintplayer-ng-bootstrap-breadcrumb.d.ts +2 -1
- package/types/mintplayer-ng-bootstrap-button-group.d.ts +2 -1
- package/types/mintplayer-ng-bootstrap-calendar.d.ts +32 -0
- package/types/mintplayer-ng-bootstrap-carousel.d.ts +56 -3
- package/types/mintplayer-ng-bootstrap-code-snippet.d.ts +1 -0
- package/types/mintplayer-ng-bootstrap-color-picker.d.ts +75 -4
- package/types/mintplayer-ng-bootstrap-datatable.d.ts +1 -1
- package/types/mintplayer-ng-bootstrap-dock.d.ts +51 -0
- package/types/mintplayer-ng-bootstrap-dropdown-menu.d.ts +54 -9
- package/types/mintplayer-ng-bootstrap-dropdown.d.ts +57 -2
- package/types/mintplayer-ng-bootstrap-file-upload.d.ts +4 -1
- package/types/mintplayer-ng-bootstrap-has-overlay.d.ts +14 -0
- package/types/mintplayer-ng-bootstrap-marquee.d.ts +2 -1
- package/types/mintplayer-ng-bootstrap-modal.d.ts +25 -1
- package/types/mintplayer-ng-bootstrap-multiselect.d.ts +2 -1
- package/types/mintplayer-ng-bootstrap-navbar-toggler.d.ts +4 -2
- package/types/mintplayer-ng-bootstrap-navbar.d.ts +25 -1
- package/types/mintplayer-ng-bootstrap-offcanvas.d.ts +23 -1
- package/types/mintplayer-ng-bootstrap-pagination.d.ts +3 -1
- package/types/mintplayer-ng-bootstrap-placeholder.d.ts +5 -1
- package/types/mintplayer-ng-bootstrap-playlist-toggler.d.ts +4 -2
- package/types/mintplayer-ng-bootstrap-popover.d.ts +21 -1
- package/types/mintplayer-ng-bootstrap-priority-nav.d.ts +4 -1
- package/types/mintplayer-ng-bootstrap-progress-bar.d.ts +4 -2
- package/types/mintplayer-ng-bootstrap-range.d.ts +2 -1
- package/types/mintplayer-ng-bootstrap-rating.d.ts +3 -0
- package/types/mintplayer-ng-bootstrap-reduced-motion.d.ts +36 -0
- package/types/mintplayer-ng-bootstrap-resizable.d.ts +4 -0
- package/types/mintplayer-ng-bootstrap-scheduler.d.ts +42 -9
- package/types/mintplayer-ng-bootstrap-scrollspy.d.ts +1 -1
- package/types/mintplayer-ng-bootstrap-searchbox.d.ts +8 -1
- package/types/mintplayer-ng-bootstrap-select.d.ts +2 -1
- package/types/mintplayer-ng-bootstrap-select2.d.ts +3 -0
- package/types/mintplayer-ng-bootstrap-signature-pad.d.ts +2 -1
- package/types/mintplayer-ng-bootstrap-table.d.ts +8 -1
- package/types/mintplayer-ng-bootstrap-tile-manager.d.ts +21 -2
- package/types/mintplayer-ng-bootstrap-toast.d.ts +6 -1
- package/types/mintplayer-ng-bootstrap-toggle-button.d.ts +11 -0
- package/types/mintplayer-ng-bootstrap-tooltip.d.ts +5 -0
- package/types/mintplayer-ng-bootstrap-treeview.d.ts +12 -1
- package/types/mintplayer-ng-bootstrap-typeahead.d.ts +11 -3
- package/types/mintplayer-ng-bootstrap-virtual-datatable.d.ts +14 -1
- package/types/mintplayer-ng-bootstrap-web-components-a11y.d.ts +34 -0
- package/types/mintplayer-ng-bootstrap-web-components-scheduler-core.d.ts +35 -11
- package/types/mintplayer-ng-bootstrap-web-components-scheduler.d.ts +246 -0
- package/types/mintplayer-ng-bootstrap-web-components-splitter.d.ts +95 -37
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { unsafeCSS, LitElement, html } from 'lit';
|
|
2
|
+
import { LiveAnnouncerController } from '@mintplayer/ng-bootstrap/web-components/a11y';
|
|
2
3
|
import { DEFAULT_OPTIONS, dateService, timelineService, getContrastColor, resourceService, isResourceGroup, isResource, generateEventId } from '@mintplayer/ng-bootstrap/web-components/scheduler-core';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -20,6 +21,13 @@ function createInitialState(options = {}) {
|
|
|
20
21
|
collapsedGroups: new Set(),
|
|
21
22
|
isMouseDown: false,
|
|
22
23
|
isLoading: false,
|
|
24
|
+
focusedCell: null,
|
|
25
|
+
focusedResourceId: null,
|
|
26
|
+
selectionAnchor: null,
|
|
27
|
+
selectionExtent: null,
|
|
28
|
+
selectionResourceId: null,
|
|
29
|
+
keyboardMoveEventId: null,
|
|
30
|
+
focusedDate: null,
|
|
23
31
|
};
|
|
24
32
|
}
|
|
25
33
|
/**
|
|
@@ -252,8 +260,154 @@ class SchedulerStateManager {
|
|
|
252
260
|
gotoDate(date) {
|
|
253
261
|
this.setState({ date });
|
|
254
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* Move the keyboard focus to a cell. Setting `clearSelection` (default)
|
|
265
|
+
* also drops any active range selection — used for plain Arrow nav, where
|
|
266
|
+
* Shift would have stayed held to keep the range alive.
|
|
267
|
+
*/
|
|
268
|
+
setFocusedCell(cell, resourceId = null, clearSelection = true) {
|
|
269
|
+
if (clearSelection) {
|
|
270
|
+
this.setState({
|
|
271
|
+
focusedCell: cell,
|
|
272
|
+
focusedResourceId: resourceId,
|
|
273
|
+
selectionAnchor: null,
|
|
274
|
+
selectionExtent: null,
|
|
275
|
+
selectionResourceId: null,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
this.setState({
|
|
280
|
+
focusedCell: cell,
|
|
281
|
+
focusedResourceId: resourceId,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Begin or extend a range selection. Anchor is set on first call (when
|
|
287
|
+
* Shift is first held), pinned at the *previously-focused* cell. Extent
|
|
288
|
+
* moves with each subsequent Shift+Arrow.
|
|
289
|
+
*/
|
|
290
|
+
extendSelection(extent, resourceId = null) {
|
|
291
|
+
this.setState((state) => {
|
|
292
|
+
const anchor = state.selectionAnchor ?? state.focusedCell ?? extent;
|
|
293
|
+
const pinnedResource = state.selectionResourceId ?? state.focusedResourceId ?? resourceId;
|
|
294
|
+
return {
|
|
295
|
+
selectionAnchor: anchor,
|
|
296
|
+
selectionExtent: extent,
|
|
297
|
+
selectionResourceId: pinnedResource,
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Clear any active range selection without touching the focused cell.
|
|
303
|
+
*/
|
|
304
|
+
clearSelection() {
|
|
305
|
+
this.setState({
|
|
306
|
+
selectionAnchor: null,
|
|
307
|
+
selectionExtent: null,
|
|
308
|
+
selectionResourceId: null,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Move the keyboard focus to a calendar date — used by month and year views
|
|
313
|
+
* (PRD scheduler-controlled-selection §5). Pass `null` to clear.
|
|
314
|
+
*/
|
|
315
|
+
setFocusedDate(date) {
|
|
316
|
+
this.setState({ focusedDate: date });
|
|
317
|
+
}
|
|
255
318
|
}
|
|
256
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Build the descriptive aria-label for an event block. Used by every view.
|
|
322
|
+
* Format: "{title}, {start}–{end} on {resource}". Resource is omitted when
|
|
323
|
+
* the event has no resource or the caller doesn't have it (week/day views).
|
|
324
|
+
*/
|
|
325
|
+
function formatEventAriaLabel(event, resourceTitle, timeFormat = '24h') {
|
|
326
|
+
const start = dateService.formatTime(event.start, timeFormat);
|
|
327
|
+
const end = dateService.formatTime(event.end, timeFormat);
|
|
328
|
+
const day = event.start.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' });
|
|
329
|
+
const parts = [`${event.title}, ${start}–${end}`, day];
|
|
330
|
+
if (resourceTitle)
|
|
331
|
+
parts.push(`on ${resourceTitle}`);
|
|
332
|
+
return parts.join(', ');
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Normalised selection range — covers anchor.start through extent.end (or
|
|
336
|
+
* the reverse if the user shift-arrowed backwards), with the resource pinned
|
|
337
|
+
* at the anchor (timeline only).
|
|
338
|
+
*/
|
|
339
|
+
function selectionRange(state) {
|
|
340
|
+
const { selectionAnchor, selectionExtent, selectionResourceId } = state;
|
|
341
|
+
if (!selectionAnchor || !selectionExtent)
|
|
342
|
+
return null;
|
|
343
|
+
const startTime = Math.min(selectionAnchor.start.getTime(), selectionExtent.start.getTime());
|
|
344
|
+
const endTime = Math.max(selectionAnchor.end.getTime(), selectionExtent.end.getTime());
|
|
345
|
+
return {
|
|
346
|
+
start: new Date(startTime),
|
|
347
|
+
end: new Date(endTime),
|
|
348
|
+
resourceId: selectionResourceId,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Whether a slot's [start, end) interval intersects the active selection range.
|
|
353
|
+
* Used by every time-grid view to drive the `.selected` / aria-selected styling
|
|
354
|
+
* on slot DOM. Cross-day spans (D1) light up naturally because intersection
|
|
355
|
+
* holds for every slot inside the linear time-range.
|
|
356
|
+
*/
|
|
357
|
+
function isSlotInSelection(slot, state, resourceId = null) {
|
|
358
|
+
const range = selectionRange(state);
|
|
359
|
+
if (!range)
|
|
360
|
+
return false;
|
|
361
|
+
if (range.resourceId && resourceId && range.resourceId !== resourceId)
|
|
362
|
+
return false;
|
|
363
|
+
return slot.start.getTime() < range.end.getTime() && slot.end.getTime() > range.start.getTime();
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Live-region announcement for a focused cell. Read after each Arrow nav.
|
|
367
|
+
* Includes weekday + date so screen readers don't lose the user across
|
|
368
|
+
* cross-day moves.
|
|
369
|
+
*/
|
|
370
|
+
function formatCellAnnouncement(slot, timeFormat = '24h', resourceTitle = null) {
|
|
371
|
+
const day = slot.start.toLocaleDateString(undefined, {
|
|
372
|
+
weekday: 'long',
|
|
373
|
+
month: 'short',
|
|
374
|
+
day: 'numeric',
|
|
375
|
+
});
|
|
376
|
+
const time = dateService.formatTime(slot.start, timeFormat);
|
|
377
|
+
const parts = [`${day}, ${time}`];
|
|
378
|
+
if (resourceTitle)
|
|
379
|
+
parts.push(resourceTitle);
|
|
380
|
+
return parts.join(', ');
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Live-region announcement after Shift+Arrow grows or shrinks the range.
|
|
384
|
+
* Reads "Selection: {start time, day} to {end time, day}, {N} slots".
|
|
385
|
+
*/
|
|
386
|
+
function formatSelectionAnnouncement(state, slotDuration, timeFormat = '24h') {
|
|
387
|
+
const range = selectionRange(state);
|
|
388
|
+
if (!range)
|
|
389
|
+
return '';
|
|
390
|
+
const startStr = `${dateService.formatTime(range.start, timeFormat)} ${range.start.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })}`;
|
|
391
|
+
const endStr = `${dateService.formatTime(range.end, timeFormat)} ${range.end.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })}`;
|
|
392
|
+
const slotMs = slotDuration * 1000;
|
|
393
|
+
const slotCount = Math.max(1, Math.round((range.end.getTime() - range.start.getTime()) / slotMs));
|
|
394
|
+
return `Selection: ${startStr} to ${endStr}, ${slotCount} slot${slotCount === 1 ? '' : 's'}`;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Live-region announcement for an in-progress keyboard event move.
|
|
398
|
+
*/
|
|
399
|
+
function formatMoveAnnouncement(start, end, timeFormat = '24h') {
|
|
400
|
+
const day = start.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
|
401
|
+
return `Moved to ${dateService.formatTime(start, timeFormat)}–${dateService.formatTime(end, timeFormat)}, ${day}`;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Live-region announcement for an in-progress keyboard event resize. The
|
|
405
|
+
* `edge` field tells the user which side they're stretching, so they can
|
|
406
|
+
* tell Shift+ArrowDown (end edge) from Alt+Shift+ArrowDown (start edge).
|
|
407
|
+
*/
|
|
408
|
+
function formatResizeAnnouncement(start, end, edge, timeFormat = '24h') {
|
|
409
|
+
return `Resized ${edge} edge to ${dateService.formatTime(start, timeFormat)}–${dateService.formatTime(end, timeFormat)}`;
|
|
410
|
+
}
|
|
257
411
|
/**
|
|
258
412
|
* Base class for scheduler views
|
|
259
413
|
*/
|
|
@@ -305,21 +459,47 @@ class BaseView {
|
|
|
305
459
|
* Year view renderer
|
|
306
460
|
*/
|
|
307
461
|
class YearView extends BaseView {
|
|
462
|
+
constructor() {
|
|
463
|
+
super(...arguments);
|
|
464
|
+
/** Cache of `.scheduler-year-month` cards keyed by `YYYY-MM`. */
|
|
465
|
+
this.monthCards = new Map();
|
|
466
|
+
}
|
|
308
467
|
render() {
|
|
309
468
|
this.clearContainer();
|
|
310
469
|
this.container.classList.add('scheduler-year-view');
|
|
311
470
|
const { date, options } = this.state;
|
|
312
471
|
const months = dateService.getYearMonths(date);
|
|
313
472
|
const grid = this.createElement('div', 'scheduler-year-grid');
|
|
473
|
+
this.monthCards.clear();
|
|
314
474
|
for (const month of months) {
|
|
315
475
|
const monthEl = this.createMonthCard(month);
|
|
316
476
|
grid.appendChild(monthEl);
|
|
317
477
|
}
|
|
318
478
|
this.container.appendChild(grid);
|
|
479
|
+
// Phase B: apply roving tabindex once cards are in place.
|
|
480
|
+
this.updateMonthCardFocus();
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Build a `YYYY-MM` key from a Date, which is the unit of focus on year
|
|
484
|
+
* view (PRD scheduler-controlled-selection §5.2 — year cells are months,
|
|
485
|
+
* not days).
|
|
486
|
+
*/
|
|
487
|
+
static monthKey(d) {
|
|
488
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
319
489
|
}
|
|
320
490
|
createMonthCard(month) {
|
|
321
491
|
const { events, options } = this.state;
|
|
322
492
|
const card = this.createElement('div', 'scheduler-year-month');
|
|
493
|
+
// Phase B keyboard nav: the *card* is the focusable cell on year view —
|
|
494
|
+
// not the inner day cells, which stay non-tabbable so screen readers
|
|
495
|
+
// describe months, not days.
|
|
496
|
+
const monthKey = YearView.monthKey(month);
|
|
497
|
+
card.setAttribute('role', 'gridcell');
|
|
498
|
+
card.setAttribute('tabindex', '-1');
|
|
499
|
+
card.setAttribute('aria-selected', 'false');
|
|
500
|
+
card.id = `scheduler-cell-y-${monthKey}`;
|
|
501
|
+
this.setData(card, { month: month.toISOString() });
|
|
502
|
+
this.monthCards.set(monthKey, card);
|
|
323
503
|
// Month header
|
|
324
504
|
const header = this.createElement('div', 'scheduler-year-month-header');
|
|
325
505
|
header.textContent = dateService.getMonthName(month, options.locale);
|
|
@@ -372,12 +552,39 @@ class YearView extends BaseView {
|
|
|
372
552
|
card.appendChild(miniMonth);
|
|
373
553
|
return card;
|
|
374
554
|
}
|
|
555
|
+
/**
|
|
556
|
+
* Apply roving tabindex based on `state.focusedDate`'s month. Year-view's
|
|
557
|
+
* focus unit is a month, so we strip down `focusedDate` to its `YYYY-MM`
|
|
558
|
+
* key. Falls back to the displayed year's first month if no focus yet.
|
|
559
|
+
*/
|
|
560
|
+
updateMonthCardFocus() {
|
|
561
|
+
const focused = this.state.focusedDate;
|
|
562
|
+
let promoted = false;
|
|
563
|
+
const focusedKey = focused ? YearView.monthKey(focused) : null;
|
|
564
|
+
for (const [key, card] of this.monthCards) {
|
|
565
|
+
const isFocused = key === focusedKey;
|
|
566
|
+
card.setAttribute('tabindex', isFocused ? '0' : '-1');
|
|
567
|
+
card.setAttribute('aria-selected', isFocused ? 'true' : 'false');
|
|
568
|
+
if (isFocused)
|
|
569
|
+
promoted = true;
|
|
570
|
+
}
|
|
571
|
+
if (!promoted) {
|
|
572
|
+
this.monthCards.values().next().value?.setAttribute('tabindex', '0');
|
|
573
|
+
}
|
|
574
|
+
}
|
|
375
575
|
update(state) {
|
|
576
|
+
const yearChanged = this.state.date.getFullYear() !== state.date.getFullYear();
|
|
376
577
|
this.state = state;
|
|
377
|
-
|
|
378
|
-
|
|
578
|
+
if (yearChanged) {
|
|
579
|
+
this.render();
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
// Same year displayed — pick up focused-date changes without re-rendering
|
|
583
|
+
// the whole grid (avoids losing focus mid-keypress).
|
|
584
|
+
this.updateMonthCardFocus();
|
|
379
585
|
}
|
|
380
586
|
destroy() {
|
|
587
|
+
this.monthCards.clear();
|
|
381
588
|
this.clearContainer();
|
|
382
589
|
}
|
|
383
590
|
}
|
|
@@ -415,6 +622,17 @@ class MonthView extends BaseView {
|
|
|
415
622
|
this.container.appendChild(grid);
|
|
416
623
|
// Render events
|
|
417
624
|
this.renderEvents();
|
|
625
|
+
// Apply roving tabindex now that all cells are in place.
|
|
626
|
+
this.updateDayCellFocus();
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Build a `YYYY-MM-DD` key from a Date using *local* components. Using
|
|
630
|
+
* `toISOString()` here would shift the key in any non-UTC timezone (a
|
|
631
|
+
* local midnight on May 12 in CEST is May 11 22:00 UTC), and the resulting
|
|
632
|
+
* cell IDs would not match the visible day numbers.
|
|
633
|
+
*/
|
|
634
|
+
static dayKey(d) {
|
|
635
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
418
636
|
}
|
|
419
637
|
createDayCell(day) {
|
|
420
638
|
const { date } = this.state;
|
|
@@ -425,6 +643,14 @@ class MonthView extends BaseView {
|
|
|
425
643
|
if (dateService.isToday(day)) {
|
|
426
644
|
cell.classList.add('today');
|
|
427
645
|
}
|
|
646
|
+
// Phase B keyboard nav: each day cell is a roving-tabindex `gridcell`
|
|
647
|
+
// with a stable id keyed off the local ISO date. `updateDayCellFocus()`
|
|
648
|
+
// picks the focused cell out of the cache and promotes its tabindex to 0.
|
|
649
|
+
const key = MonthView.dayKey(day);
|
|
650
|
+
cell.setAttribute('role', 'gridcell');
|
|
651
|
+
cell.setAttribute('tabindex', '-1');
|
|
652
|
+
cell.setAttribute('aria-selected', 'false');
|
|
653
|
+
cell.id = `scheduler-cell-m-${key}`;
|
|
428
654
|
// Day number
|
|
429
655
|
const dayNumber = this.createElement('div', 'day-number');
|
|
430
656
|
dayNumber.textContent = String(day.getDate());
|
|
@@ -433,11 +659,36 @@ class MonthView extends BaseView {
|
|
|
433
659
|
const eventsContainer = this.createElement('div', 'month-events');
|
|
434
660
|
cell.appendChild(eventsContainer);
|
|
435
661
|
// Store reference
|
|
436
|
-
const key = day.toISOString().split('T')[0];
|
|
437
662
|
this.dayCells.set(key, cell);
|
|
438
663
|
this.setData(cell, { date: key });
|
|
439
664
|
return cell;
|
|
440
665
|
}
|
|
666
|
+
/**
|
|
667
|
+
* Apply roving tabindex based on `state.focusedDate`. The grid must always
|
|
668
|
+
* have exactly one tab-reachable cell, so when no date is focused yet we
|
|
669
|
+
* fall back to today (if visible) or the first day of the displayed month.
|
|
670
|
+
*/
|
|
671
|
+
updateDayCellFocus() {
|
|
672
|
+
const focused = this.state.focusedDate;
|
|
673
|
+
let promoted = false;
|
|
674
|
+
const focusedKey = focused ? MonthView.dayKey(focused) : null;
|
|
675
|
+
for (const [key, cell] of this.dayCells) {
|
|
676
|
+
const isFocused = key === focusedKey;
|
|
677
|
+
cell.setAttribute('tabindex', isFocused ? '0' : '-1');
|
|
678
|
+
cell.setAttribute('aria-selected', isFocused ? 'true' : 'false');
|
|
679
|
+
if (isFocused)
|
|
680
|
+
promoted = true;
|
|
681
|
+
}
|
|
682
|
+
if (!promoted) {
|
|
683
|
+
// Fallback: today's cell if it's in the displayed month, else the first
|
|
684
|
+
// cell that belongs to the displayed month (skip leading other-month
|
|
685
|
+
// spillover).
|
|
686
|
+
const fallback = Array.from(this.dayCells.values()).find((c) => c.classList.contains('today') && !c.classList.contains('other-month')) ||
|
|
687
|
+
Array.from(this.dayCells.values()).find((c) => !c.classList.contains('other-month')) ||
|
|
688
|
+
this.dayCells.values().next().value;
|
|
689
|
+
fallback?.setAttribute('tabindex', '0');
|
|
690
|
+
}
|
|
691
|
+
}
|
|
441
692
|
renderEvents() {
|
|
442
693
|
const { date, events, options } = this.state;
|
|
443
694
|
const monthStart = dateService.getMonthStart(date);
|
|
@@ -458,7 +709,7 @@ class MonthView extends BaseView {
|
|
|
458
709
|
const current = new Date(eventStart);
|
|
459
710
|
current.setHours(0, 0, 0, 0);
|
|
460
711
|
while (current <= eventEnd) {
|
|
461
|
-
const key =
|
|
712
|
+
const key = MonthView.dayKey(current);
|
|
462
713
|
if (!eventsByDay.has(key)) {
|
|
463
714
|
eventsByDay.set(key, []);
|
|
464
715
|
}
|
|
@@ -508,6 +759,8 @@ class MonthView extends BaseView {
|
|
|
508
759
|
return;
|
|
509
760
|
}
|
|
510
761
|
this.renderEvents();
|
|
762
|
+
// Pick up focused-date changes that don't require a re-render (within month).
|
|
763
|
+
this.updateDayCellFocus();
|
|
511
764
|
}
|
|
512
765
|
optionsRequireRerender(oldOpts, newOpts) {
|
|
513
766
|
return oldOpts.firstDayOfWeek !== newOpts.firstDayOfWeek ||
|
|
@@ -581,6 +834,10 @@ class WeekView extends BaseView {
|
|
|
581
834
|
const slotEnd = new Date(day);
|
|
582
835
|
slotEnd.setHours(slotTemplate.end.getHours(), slotTemplate.end.getMinutes(), 0, 0);
|
|
583
836
|
const slotEl = this.createElement('div', 'scheduler-time-slot');
|
|
837
|
+
slotEl.setAttribute('role', 'gridcell');
|
|
838
|
+
slotEl.setAttribute('tabindex', '-1');
|
|
839
|
+
slotEl.setAttribute('aria-selected', 'false');
|
|
840
|
+
slotEl.id = `scheduler-cell-w-${dayIndex}-${slotIndex}`;
|
|
584
841
|
this.setData(slotEl, {
|
|
585
842
|
dayIndex,
|
|
586
843
|
slotIndex,
|
|
@@ -601,6 +858,8 @@ class WeekView extends BaseView {
|
|
|
601
858
|
this.container.appendChild(timeGrid);
|
|
602
859
|
// Render events
|
|
603
860
|
this.renderEvents();
|
|
861
|
+
// Reflect any pre-existing focused cell / selection (e.g. after view switch).
|
|
862
|
+
this.updateCellFocusAndSelection();
|
|
604
863
|
// Render now indicator
|
|
605
864
|
this.renderNowIndicator(days, slots);
|
|
606
865
|
}
|
|
@@ -623,9 +882,44 @@ class WeekView extends BaseView {
|
|
|
623
882
|
else {
|
|
624
883
|
this.renderEvents();
|
|
625
884
|
}
|
|
885
|
+
// Reflect focusedCell + selection range into per-slot ARIA + tabindex.
|
|
886
|
+
this.updateCellFocusAndSelection();
|
|
626
887
|
// Render preview event
|
|
627
888
|
this.renderPreviewEvent();
|
|
628
889
|
}
|
|
890
|
+
/**
|
|
891
|
+
* Apply roving tabindex and aria-selected to each cached slot element. Also
|
|
892
|
+
* toggles the `.selected` class so the existing `.scheduler-time-slot.selected`
|
|
893
|
+
* styling lights up cells in the keyboard-driven range. A linear time-range
|
|
894
|
+
* selection (PRD D1) lights up every slot whose [start, end) intersects
|
|
895
|
+
* the range, including across day boundaries on week view.
|
|
896
|
+
*/
|
|
897
|
+
updateCellFocusAndSelection() {
|
|
898
|
+
const focused = this.state.focusedCell;
|
|
899
|
+
let foundFocused = false;
|
|
900
|
+
let firstEl = null;
|
|
901
|
+
for (const [, slotEl] of this.slotElements) {
|
|
902
|
+
if (!firstEl)
|
|
903
|
+
firstEl = slotEl;
|
|
904
|
+
const startStr = slotEl.dataset['start'];
|
|
905
|
+
const endStr = slotEl.dataset['end'];
|
|
906
|
+
if (!startStr || !endStr)
|
|
907
|
+
continue;
|
|
908
|
+
const slot = { start: new Date(startStr), end: new Date(endStr) };
|
|
909
|
+
const isFocused = !!focused && slot.start.getTime() === focused.start.getTime();
|
|
910
|
+
slotEl.setAttribute('tabindex', isFocused ? '0' : '-1');
|
|
911
|
+
const inSelection = isSlotInSelection(slot, this.state, null);
|
|
912
|
+
slotEl.setAttribute('aria-selected', inSelection ? 'true' : 'false');
|
|
913
|
+
slotEl.classList.toggle('selected', inSelection);
|
|
914
|
+
if (isFocused)
|
|
915
|
+
foundFocused = true;
|
|
916
|
+
}
|
|
917
|
+
// Grid must be Tab-reachable: if focusedCell hasn't been set yet (first
|
|
918
|
+
// mount, or focused cell isn't visible after a date change), fall back
|
|
919
|
+
// to the top-left cell so Tab from the header lands somewhere.
|
|
920
|
+
if (!foundFocused && firstEl)
|
|
921
|
+
firstEl.setAttribute('tabindex', '0');
|
|
922
|
+
}
|
|
629
923
|
optionsRequireRerender(oldOpts, newOpts) {
|
|
630
924
|
return oldOpts.slotDuration !== newOpts.slotDuration ||
|
|
631
925
|
oldOpts.timeFormat !== newOpts.timeFormat ||
|
|
@@ -674,8 +968,17 @@ class WeekView extends BaseView {
|
|
|
674
968
|
createEventElement(part, trackIndex, totalTracks, colspan, slotDuration) {
|
|
675
969
|
const event = part.event;
|
|
676
970
|
const eventEl = this.createElement('div', 'scheduler-event');
|
|
677
|
-
|
|
678
|
-
|
|
971
|
+
const isSelected = this.state.selectedEvent?.id === event.id;
|
|
972
|
+
const inMoveMode = this.state.keyboardMoveEventId === event.id;
|
|
973
|
+
eventEl.setAttribute('role', 'button');
|
|
974
|
+
// Every event is in the Tab order (PRD §6.1) — Tab cycles through events
|
|
975
|
+
// in document order. Selected event still gets aria-current for SR cue.
|
|
976
|
+
eventEl.setAttribute('tabindex', '0');
|
|
977
|
+
eventEl.setAttribute('aria-label', formatEventAriaLabel(event, null, this.state.options.timeFormat));
|
|
978
|
+
if (inMoveMode)
|
|
979
|
+
eventEl.setAttribute('aria-pressed', 'true');
|
|
980
|
+
if (isSelected) {
|
|
981
|
+
eventEl.setAttribute('aria-current', 'true');
|
|
679
982
|
eventEl.classList.add('selected');
|
|
680
983
|
}
|
|
681
984
|
// Calculate position
|
|
@@ -903,6 +1206,10 @@ class DayView extends BaseView {
|
|
|
903
1206
|
for (let slotIndex = 0; slotIndex < slots.length; slotIndex++) {
|
|
904
1207
|
const slot = slots[slotIndex];
|
|
905
1208
|
const slotEl = this.createElement('div', 'scheduler-time-slot');
|
|
1209
|
+
slotEl.setAttribute('role', 'gridcell');
|
|
1210
|
+
slotEl.setAttribute('tabindex', '-1');
|
|
1211
|
+
slotEl.setAttribute('aria-selected', 'false');
|
|
1212
|
+
slotEl.id = `scheduler-cell-d-${slotIndex}`;
|
|
906
1213
|
this.setData(slotEl, {
|
|
907
1214
|
slotIndex,
|
|
908
1215
|
start: slot.start.toISOString(),
|
|
@@ -919,11 +1226,41 @@ class DayView extends BaseView {
|
|
|
919
1226
|
this.container.appendChild(timeGrid);
|
|
920
1227
|
// Render events
|
|
921
1228
|
this.renderEvents();
|
|
1229
|
+
// Reflect any pre-existing focused cell / selection.
|
|
1230
|
+
this.updateCellFocusAndSelection();
|
|
922
1231
|
// Render now indicator
|
|
923
1232
|
if (dateService.isToday(date) && options.nowIndicator) {
|
|
924
1233
|
this.renderNowIndicator(dayColumn);
|
|
925
1234
|
}
|
|
926
1235
|
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Apply roving tabindex + aria-selected + `.selected` class to each cached
|
|
1238
|
+
* slot element based on focusedCell and selection range.
|
|
1239
|
+
*/
|
|
1240
|
+
updateCellFocusAndSelection() {
|
|
1241
|
+
const focused = this.state.focusedCell;
|
|
1242
|
+
let foundFocused = false;
|
|
1243
|
+
let firstEl = null;
|
|
1244
|
+
for (const [, slotEl] of this.slotElements) {
|
|
1245
|
+
if (!firstEl)
|
|
1246
|
+
firstEl = slotEl;
|
|
1247
|
+
const startStr = slotEl.dataset['start'];
|
|
1248
|
+
const endStr = slotEl.dataset['end'];
|
|
1249
|
+
if (!startStr || !endStr)
|
|
1250
|
+
continue;
|
|
1251
|
+
const slot = { start: new Date(startStr), end: new Date(endStr) };
|
|
1252
|
+
const isFocused = !!focused && slot.start.getTime() === focused.start.getTime();
|
|
1253
|
+
slotEl.setAttribute('tabindex', isFocused ? '0' : '-1');
|
|
1254
|
+
const inSelection = isSlotInSelection(slot, this.state, null);
|
|
1255
|
+
slotEl.setAttribute('aria-selected', inSelection ? 'true' : 'false');
|
|
1256
|
+
slotEl.classList.toggle('selected', inSelection);
|
|
1257
|
+
if (isFocused)
|
|
1258
|
+
foundFocused = true;
|
|
1259
|
+
}
|
|
1260
|
+
// Fallback so the grid is always Tab-reachable (see week-view note).
|
|
1261
|
+
if (!foundFocused && firstEl)
|
|
1262
|
+
firstEl.setAttribute('tabindex', '0');
|
|
1263
|
+
}
|
|
927
1264
|
renderEvents() {
|
|
928
1265
|
if (!this.eventsContainer)
|
|
929
1266
|
return;
|
|
@@ -955,8 +1292,16 @@ class DayView extends BaseView {
|
|
|
955
1292
|
createEventElement(part, trackIndex, totalTracks, colspan, slotDuration) {
|
|
956
1293
|
const event = part.event;
|
|
957
1294
|
const eventEl = this.createElement('div', 'scheduler-event');
|
|
958
|
-
|
|
959
|
-
|
|
1295
|
+
const isSelected = this.state.selectedEvent?.id === event.id;
|
|
1296
|
+
const inMoveMode = this.state.keyboardMoveEventId === event.id;
|
|
1297
|
+
eventEl.setAttribute('role', 'button');
|
|
1298
|
+
// Every event is Tab-reachable (PRD §6.1).
|
|
1299
|
+
eventEl.setAttribute('tabindex', '0');
|
|
1300
|
+
eventEl.setAttribute('aria-label', formatEventAriaLabel(event, null, this.state.options.timeFormat));
|
|
1301
|
+
if (inMoveMode)
|
|
1302
|
+
eventEl.setAttribute('aria-pressed', 'true');
|
|
1303
|
+
if (isSelected) {
|
|
1304
|
+
eventEl.setAttribute('aria-current', 'true');
|
|
960
1305
|
eventEl.classList.add('selected');
|
|
961
1306
|
}
|
|
962
1307
|
// Calculate position
|
|
@@ -1051,6 +1396,8 @@ class DayView extends BaseView {
|
|
|
1051
1396
|
this.updateGreyedSlots();
|
|
1052
1397
|
// Re-render events
|
|
1053
1398
|
this.renderEvents();
|
|
1399
|
+
// Refresh cell focus + selection styling.
|
|
1400
|
+
this.updateCellFocusAndSelection();
|
|
1054
1401
|
// Render preview event
|
|
1055
1402
|
this.renderPreviewEvent();
|
|
1056
1403
|
}
|
|
@@ -1131,12 +1478,20 @@ class TimelineView extends BaseView {
|
|
|
1131
1478
|
this.container.classList.add('scheduler-timeline-view');
|
|
1132
1479
|
const { date, options, resources, collapsedGroups } = this.state;
|
|
1133
1480
|
const days = dateService.getWeekDays(date, options.firstDayOfWeek);
|
|
1134
|
-
|
|
1481
|
+
const flattenedPreview = resourceService.flatten(resources, collapsedGroups);
|
|
1482
|
+
const visibleRowCount = flattenedPreview.filter((f) => f.visible).length + 1; // +1 for the day-header row
|
|
1483
|
+
// Create timeline structure with role=grid (APG Grid pattern, PRD §10 Q5)
|
|
1135
1484
|
const timeline = this.createElement('div', 'scheduler-timeline');
|
|
1136
|
-
|
|
1485
|
+
timeline.setAttribute('role', 'grid');
|
|
1486
|
+
timeline.setAttribute('aria-label', `Resource timeline for week starting ${dateService.formatDateWithWeekday(days[0], options.locale)}`);
|
|
1487
|
+
timeline.setAttribute('aria-rowcount', String(visibleRowCount));
|
|
1488
|
+
// Header (row containing day labels)
|
|
1137
1489
|
const header = this.createElement('div', 'scheduler-timeline-header');
|
|
1138
|
-
|
|
1490
|
+
header.setAttribute('role', 'row');
|
|
1491
|
+
header.setAttribute('aria-rowindex', '1');
|
|
1492
|
+
// Resource column header (top-left corner)
|
|
1139
1493
|
const resourceHeader = this.createElement('div', 'scheduler-resource-header');
|
|
1494
|
+
resourceHeader.setAttribute('role', 'columnheader');
|
|
1140
1495
|
resourceHeader.textContent = 'Resources';
|
|
1141
1496
|
header.appendChild(resourceHeader);
|
|
1142
1497
|
// Time slots header
|
|
@@ -1146,6 +1501,7 @@ class TimelineView extends BaseView {
|
|
|
1146
1501
|
// Day header spanning multiple slots
|
|
1147
1502
|
const daySlots = slots.length;
|
|
1148
1503
|
const dayHeader = this.createElement('div', 'scheduler-timeline-slot-header');
|
|
1504
|
+
dayHeader.setAttribute('role', 'columnheader');
|
|
1149
1505
|
dayHeader.style.width = `${daySlots * this.slotWidth}px`;
|
|
1150
1506
|
dayHeader.textContent = dateService.formatDateWithWeekday(day, options.locale);
|
|
1151
1507
|
dayHeader.style.borderBottom = '1px solid var(--scheduler-border-color)';
|
|
@@ -1163,6 +1519,7 @@ class TimelineView extends BaseView {
|
|
|
1163
1519
|
const slots = dateService.getTimeSlots(day, options.slotDuration, options.slotMinTime, options.slotMaxTime);
|
|
1164
1520
|
for (const slot of slots) {
|
|
1165
1521
|
const slotHeader = this.createElement('div', 'scheduler-timeline-slot-header');
|
|
1522
|
+
slotHeader.setAttribute('role', 'columnheader');
|
|
1166
1523
|
slotHeader.style.width = `${this.slotWidth}px`;
|
|
1167
1524
|
slotHeader.textContent = dateService.formatTime(slot.start, options.timeFormat);
|
|
1168
1525
|
slotHeader.style.fontSize = '10px';
|
|
@@ -1175,29 +1532,75 @@ class TimelineView extends BaseView {
|
|
|
1175
1532
|
const body = this.createElement('div', 'scheduler-timeline-body');
|
|
1176
1533
|
// Flatten resources
|
|
1177
1534
|
const flattened = resourceService.flatten(resources, collapsedGroups);
|
|
1535
|
+
let rowIndex = 2; // 1 = header row above
|
|
1178
1536
|
for (const flat of flattened) {
|
|
1179
1537
|
if (!flat.visible)
|
|
1180
1538
|
continue;
|
|
1181
1539
|
const row = this.createResourceRow(flat, days);
|
|
1540
|
+
row.setAttribute('aria-rowindex', String(rowIndex));
|
|
1182
1541
|
body.appendChild(row);
|
|
1542
|
+
rowIndex++;
|
|
1183
1543
|
}
|
|
1184
1544
|
timeline.appendChild(body);
|
|
1185
1545
|
this.container.appendChild(timeline);
|
|
1186
1546
|
// Render events
|
|
1187
1547
|
this.renderEvents(days);
|
|
1548
|
+
// Reflect any pre-existing focused cell / selection.
|
|
1549
|
+
this.updateCellFocusAndSelection();
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Apply roving tabindex + aria-selected + `.selected` class to each
|
|
1553
|
+
* timeline-slot element. Selection is constrained to the resource pinned
|
|
1554
|
+
* at the anchor (PRD D1: cross-resource selection ignored).
|
|
1555
|
+
*/
|
|
1556
|
+
updateCellFocusAndSelection() {
|
|
1557
|
+
const focused = this.state.focusedCell;
|
|
1558
|
+
const focusedResourceId = this.state.focusedResourceId;
|
|
1559
|
+
const slots = this.container.querySelectorAll('.scheduler-timeline-slot');
|
|
1560
|
+
let foundFocused = false;
|
|
1561
|
+
let firstEl = null;
|
|
1562
|
+
slots.forEach((slotEl) => {
|
|
1563
|
+
if (!firstEl)
|
|
1564
|
+
firstEl = slotEl;
|
|
1565
|
+
const startStr = slotEl.dataset['start'];
|
|
1566
|
+
const endStr = slotEl.dataset['end'];
|
|
1567
|
+
const resourceId = slotEl.dataset['resourceId'] ?? null;
|
|
1568
|
+
if (!startStr || !endStr)
|
|
1569
|
+
return;
|
|
1570
|
+
const slot = { start: new Date(startStr), end: new Date(endStr) };
|
|
1571
|
+
const isFocused = !!focused &&
|
|
1572
|
+
slot.start.getTime() === focused.start.getTime() &&
|
|
1573
|
+
focusedResourceId === resourceId;
|
|
1574
|
+
slotEl.setAttribute('tabindex', isFocused ? '0' : '-1');
|
|
1575
|
+
const inSelection = isSlotInSelection(slot, this.state, resourceId);
|
|
1576
|
+
slotEl.setAttribute('aria-selected', inSelection ? 'true' : 'false');
|
|
1577
|
+
slotEl.classList.toggle('selected', inSelection);
|
|
1578
|
+
if (isFocused)
|
|
1579
|
+
foundFocused = true;
|
|
1580
|
+
});
|
|
1581
|
+
if (!foundFocused && firstEl)
|
|
1582
|
+
firstEl.setAttribute('tabindex', '0');
|
|
1188
1583
|
}
|
|
1189
1584
|
createResourceRow(flat, days) {
|
|
1190
1585
|
const { options } = this.state;
|
|
1191
1586
|
const row = this.createElement('div', 'scheduler-timeline-row');
|
|
1587
|
+
row.setAttribute('role', 'row');
|
|
1192
1588
|
if (isResourceGroup(flat.item)) {
|
|
1193
1589
|
row.classList.add('group');
|
|
1194
1590
|
}
|
|
1195
|
-
// Resource cell
|
|
1591
|
+
// Resource cell — role="rowheader" labels the row for SR users.
|
|
1196
1592
|
const resourceCell = this.createElement('div', 'scheduler-resource-cell');
|
|
1593
|
+
resourceCell.setAttribute('role', 'rowheader');
|
|
1197
1594
|
resourceCell.style.paddingLeft = `${8 + flat.depth * 16}px`;
|
|
1198
1595
|
if (isResourceGroup(flat.item)) {
|
|
1199
|
-
|
|
1200
|
-
|
|
1596
|
+
// Native <button> for the expand/collapse — gets keyboard activation
|
|
1597
|
+
// (Enter/Space) for free, plus aria-expanded reflects collapsed state.
|
|
1598
|
+
const toggle = this.createElement('button', 'expand-toggle');
|
|
1599
|
+
toggle.type = 'button';
|
|
1600
|
+
const isCollapsed = this.state.collapsedGroups.has(flat.item.id);
|
|
1601
|
+
toggle.textContent = isCollapsed ? '▶' : '▼';
|
|
1602
|
+
toggle.setAttribute('aria-expanded', String(!isCollapsed));
|
|
1603
|
+
toggle.setAttribute('aria-label', `${isCollapsed ? 'Expand' : 'Collapse'} ${flat.item.title}`);
|
|
1201
1604
|
this.setData(toggle, { groupId: flat.item.id });
|
|
1202
1605
|
resourceCell.appendChild(toggle);
|
|
1203
1606
|
}
|
|
@@ -1211,6 +1614,10 @@ class TimelineView extends BaseView {
|
|
|
1211
1614
|
const slots = dateService.getTimeSlots(day, options.slotDuration, options.slotMinTime, options.slotMaxTime);
|
|
1212
1615
|
for (const slot of slots) {
|
|
1213
1616
|
const slotEl = this.createElement('div', 'scheduler-timeline-slot');
|
|
1617
|
+
slotEl.setAttribute('role', 'gridcell');
|
|
1618
|
+
slotEl.setAttribute('tabindex', '-1');
|
|
1619
|
+
slotEl.setAttribute('aria-selected', 'false');
|
|
1620
|
+
slotEl.id = `scheduler-cell-t-${flat.item.id}-${slot.start.getTime()}`;
|
|
1214
1621
|
slotEl.style.width = `${this.slotWidth}px`;
|
|
1215
1622
|
this.setData(slotEl, {
|
|
1216
1623
|
resourceId: flat.item.id,
|
|
@@ -1269,15 +1676,24 @@ class TimelineView extends BaseView {
|
|
|
1269
1676
|
for (const { part, trackIndex, totalTracks, colspan } of timelinedParts) {
|
|
1270
1677
|
if (!part.event)
|
|
1271
1678
|
continue;
|
|
1272
|
-
const eventEl = this.createEventElement(part.event, trackIndex, totalTracks, colspan, weekStart, totalWidth, options.slotDuration ?? 1800);
|
|
1679
|
+
const eventEl = this.createEventElement(part.event, trackIndex, totalTracks, colspan, weekStart, totalWidth, options.slotDuration ?? 1800, resource.title);
|
|
1273
1680
|
eventsContainer.appendChild(eventEl);
|
|
1274
1681
|
}
|
|
1275
1682
|
}
|
|
1276
1683
|
}
|
|
1277
|
-
createEventElement(event, trackIndex, totalTracks, colspan, viewStart, totalWidth, slotDuration) {
|
|
1684
|
+
createEventElement(event, trackIndex, totalTracks, colspan, viewStart, totalWidth, slotDuration, resourceTitle = null) {
|
|
1278
1685
|
const eventEl = this.createElement('div', 'scheduler-timeline-event');
|
|
1279
|
-
|
|
1280
|
-
|
|
1686
|
+
const isSelected = this.state.selectedEvent?.id === event.id;
|
|
1687
|
+
const inMoveMode = this.state.keyboardMoveEventId === event.id;
|
|
1688
|
+
eventEl.setAttribute('role', 'button');
|
|
1689
|
+
// Every event is Tab-reachable (PRD §6.1) — flipped from roving tabindex
|
|
1690
|
+
// so users can Tab through events in document order.
|
|
1691
|
+
eventEl.setAttribute('tabindex', '0');
|
|
1692
|
+
eventEl.setAttribute('aria-label', formatEventAriaLabel(event, resourceTitle, this.state.options.timeFormat));
|
|
1693
|
+
if (inMoveMode)
|
|
1694
|
+
eventEl.setAttribute('aria-pressed', 'true');
|
|
1695
|
+
if (isSelected) {
|
|
1696
|
+
eventEl.setAttribute('aria-current', 'true');
|
|
1281
1697
|
eventEl.classList.add('selected');
|
|
1282
1698
|
}
|
|
1283
1699
|
// Clamp event to view bounds
|
|
@@ -1318,6 +1734,8 @@ class TimelineView extends BaseView {
|
|
|
1318
1734
|
// Re-render events
|
|
1319
1735
|
const days = dateService.getWeekDays(state.date, state.options.firstDayOfWeek);
|
|
1320
1736
|
this.renderEvents(days);
|
|
1737
|
+
// Refresh cell focus + selection styling.
|
|
1738
|
+
this.updateCellFocusAndSelection();
|
|
1321
1739
|
}
|
|
1322
1740
|
optionsRequireRerender(oldOpts, newOpts) {
|
|
1323
1741
|
return oldOpts.slotDuration !== newOpts.slotDuration ||
|
|
@@ -1564,6 +1982,17 @@ const schedulerStyles = unsafeCSS(`:host {
|
|
|
1564
1982
|
background: var(--scheduler-greyed-slot-bg);
|
|
1565
1983
|
}
|
|
1566
1984
|
|
|
1985
|
+
.scheduler-time-slot.selected {
|
|
1986
|
+
background: var(--scheduler-preview-bg);
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
.scheduler-time-slot:focus-visible,
|
|
1990
|
+
.scheduler-time-slot:focus {
|
|
1991
|
+
outline: 2px solid #0d6efd;
|
|
1992
|
+
outline-offset: -2px;
|
|
1993
|
+
z-index: 1;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1567
1996
|
/* Events */
|
|
1568
1997
|
.scheduler-events-container {
|
|
1569
1998
|
position: absolute;
|
|
@@ -1872,6 +2301,17 @@ const schedulerStyles = unsafeCSS(`:host {
|
|
|
1872
2301
|
background: var(--scheduler-greyed-slot-bg);
|
|
1873
2302
|
}
|
|
1874
2303
|
|
|
2304
|
+
.scheduler-timeline-slot.selected {
|
|
2305
|
+
background: var(--scheduler-preview-bg);
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
.scheduler-timeline-slot:focus-visible,
|
|
2309
|
+
.scheduler-timeline-slot:focus {
|
|
2310
|
+
outline: 2px solid #0d6efd;
|
|
2311
|
+
outline-offset: -2px;
|
|
2312
|
+
z-index: 1;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
1875
2315
|
.scheduler-timeline-events {
|
|
1876
2316
|
position: absolute;
|
|
1877
2317
|
top: 0;
|
|
@@ -1995,6 +2435,14 @@ const schedulerStyles = unsafeCSS(`:host {
|
|
|
1995
2435
|
|
|
1996
2436
|
.scheduler-content::-webkit-scrollbar-thumb:hover {
|
|
1997
2437
|
background: #a1a1a1;
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2441
|
+
.scheduler-event,
|
|
2442
|
+
.scheduler-timeline-event {
|
|
2443
|
+
animation: none !important;
|
|
2444
|
+
transition: none !important;
|
|
2445
|
+
}
|
|
1998
2446
|
}`);
|
|
1999
2447
|
|
|
2000
2448
|
/**
|
|
@@ -3168,10 +3616,11 @@ class SchedulerEventEmitter {
|
|
|
3168
3616
|
}));
|
|
3169
3617
|
}
|
|
3170
3618
|
/**
|
|
3171
|
-
* Emit an event-
|
|
3619
|
+
* Emit an event-selected event. Fired both by mouse click and by keyboard
|
|
3620
|
+
* Tab landing on an event (mouse-parity, PRD scheduler-keyboard-grid-nav D3).
|
|
3172
3621
|
*/
|
|
3173
|
-
|
|
3174
|
-
this.emit({ type: 'event-
|
|
3622
|
+
emitEventSelected(event, originalEvent) {
|
|
3623
|
+
this.emit({ type: 'event-selected', event, originalEvent });
|
|
3175
3624
|
}
|
|
3176
3625
|
/**
|
|
3177
3626
|
* Emit an event-dblclick event.
|
|
@@ -3180,10 +3629,12 @@ class SchedulerEventEmitter {
|
|
|
3180
3629
|
this.emit({ type: 'event-dblclick', event, originalEvent });
|
|
3181
3630
|
}
|
|
3182
3631
|
/**
|
|
3183
|
-
* Emit an event-create
|
|
3632
|
+
* Emit an `event-create` *request*. Per PRD scheduler-controlled-selection,
|
|
3633
|
+
* the scheduler does not mutate its internal events list — the consumer
|
|
3634
|
+
* receives the range and decides whether to construct an event from it.
|
|
3184
3635
|
*/
|
|
3185
|
-
emitEventCreate(
|
|
3186
|
-
this.emit({ type: 'event-create',
|
|
3636
|
+
emitEventCreate(range, view, originalEvent, resourceId) {
|
|
3637
|
+
this.emit({ type: 'event-create', range, view, resourceId, originalEvent });
|
|
3187
3638
|
}
|
|
3188
3639
|
/**
|
|
3189
3640
|
* Emit an event-update event.
|
|
@@ -3210,10 +3661,12 @@ class SchedulerEventEmitter {
|
|
|
3210
3661
|
this.emit({ type: 'view-change', view, date });
|
|
3211
3662
|
}
|
|
3212
3663
|
/**
|
|
3213
|
-
* Emit a selection-change event.
|
|
3664
|
+
* Emit a selection-change event. Carries both the single-event focus and
|
|
3665
|
+
* the time-range selection — either may be null. Fires on every transition
|
|
3666
|
+
* so consumers can react to the selection clearing without polling.
|
|
3214
3667
|
*/
|
|
3215
|
-
emitSelectionChange(selectedEvent) {
|
|
3216
|
-
this.emit({ type: 'selection-change', selectedEvent });
|
|
3668
|
+
emitSelectionChange(selectedEvent, range, view, resourceId) {
|
|
3669
|
+
this.emit({ type: 'selection-change', selectedEvent, range, view, resourceId });
|
|
3217
3670
|
}
|
|
3218
3671
|
}
|
|
3219
3672
|
|
|
@@ -3251,11 +3704,23 @@ class MpScheduler extends LitElement {
|
|
|
3251
3704
|
this.previousView = null;
|
|
3252
3705
|
this.previousDate = null;
|
|
3253
3706
|
this.previousSelectedEventId = null;
|
|
3707
|
+
// Sentinel-keyed previous range so we can fire selection-change when the
|
|
3708
|
+
// time-range selection mutates (anchor/extent/resourceId). `__init__`
|
|
3709
|
+
// distinguishes "haven't observed yet" from "currently null" so the very
|
|
3710
|
+
// first emission isn't suppressed.
|
|
3711
|
+
this.previousRangeKey = '__init__';
|
|
3254
3712
|
// RAF scheduling for drag updates
|
|
3255
3713
|
this.pendingDragUpdate = null;
|
|
3256
3714
|
this.latestDragState = null;
|
|
3257
3715
|
// Now indicator update timer
|
|
3258
3716
|
this.nowIndicatorTimer = null;
|
|
3717
|
+
this.liveAnnouncer = new LiveAnnouncerController(this);
|
|
3718
|
+
/**
|
|
3719
|
+
* Active keyboard-driven event move. Captures the original time range and
|
|
3720
|
+
* (timeline) resource so Escape can revert; the working copy is mutated in
|
|
3721
|
+
* place by arrow keys and committed or rolled back on Enter / Escape.
|
|
3722
|
+
*/
|
|
3723
|
+
this.keyboardMove = null;
|
|
3259
3724
|
this.stateManager = new SchedulerStateManager();
|
|
3260
3725
|
this.eventEmitter = new SchedulerEventEmitter(this);
|
|
3261
3726
|
// Initialize drag manager (input handler is deferred to firstUpdated()
|
|
@@ -3264,6 +3729,7 @@ class MpScheduler extends LitElement {
|
|
|
3264
3729
|
this.dragManager.setSlotResolver((x, y) => this.getSlotAtPosition(x, y));
|
|
3265
3730
|
// Bind keyboard handler
|
|
3266
3731
|
this.boundHandleKeyDown = this.handleKeyDown.bind(this);
|
|
3732
|
+
this.boundHandleFocusIn = this.handleFocusIn.bind(this);
|
|
3267
3733
|
// Subscribe to state changes
|
|
3268
3734
|
this.stateManager.subscribe((state) => this.onStateChange(state));
|
|
3269
3735
|
}
|
|
@@ -3273,12 +3739,14 @@ class MpScheduler extends LitElement {
|
|
|
3273
3739
|
this.inputHandler.attach();
|
|
3274
3740
|
}
|
|
3275
3741
|
this.addEventListener('keydown', this.boundHandleKeyDown);
|
|
3742
|
+
// focusin listener is registered in firstUpdated() once the shadowRoot exists.
|
|
3276
3743
|
// Start now indicator update timer (every minute)
|
|
3277
3744
|
this.startNowIndicatorTimer();
|
|
3278
3745
|
}
|
|
3279
3746
|
disconnectedCallback() {
|
|
3280
3747
|
this.inputHandler?.detach();
|
|
3281
3748
|
this.removeEventListener('keydown', this.boundHandleKeyDown);
|
|
3749
|
+
this.shadowRoot?.removeEventListener('focusin', this.boundHandleFocusIn);
|
|
3282
3750
|
this.currentView?.destroy();
|
|
3283
3751
|
this.dragManager.destroy();
|
|
3284
3752
|
// Stop now indicator timer
|
|
@@ -3399,15 +3867,30 @@ class MpScheduler extends LitElement {
|
|
|
3399
3867
|
}
|
|
3400
3868
|
changeView(view) {
|
|
3401
3869
|
this.stateManager.setView(view);
|
|
3870
|
+
this.liveAnnouncer.announce(`View changed to ${view}.`);
|
|
3871
|
+
}
|
|
3872
|
+
/**
|
|
3873
|
+
* Clear the time-range selection and the focused-cell selection. Public
|
|
3874
|
+
* because — per PRD scheduler-controlled-selection — the WC no longer
|
|
3875
|
+
* auto-clears on commit; consumers call this from their `event-create`
|
|
3876
|
+
* handler if they want the post-create selection cleared.
|
|
3877
|
+
*/
|
|
3878
|
+
clearSelection() {
|
|
3879
|
+
this.stateManager.clearSelection();
|
|
3402
3880
|
}
|
|
3403
3881
|
addEvent(event) {
|
|
3404
3882
|
this.stateManager.addEvent(event);
|
|
3883
|
+
this.liveAnnouncer.announce(`Event ${event.title} added.`);
|
|
3405
3884
|
}
|
|
3406
3885
|
updateEvent(event) {
|
|
3407
3886
|
this.stateManager.updateEvent(event);
|
|
3887
|
+
this.liveAnnouncer.announce(`Event ${event.title} updated.`);
|
|
3408
3888
|
}
|
|
3409
3889
|
removeEvent(eventId) {
|
|
3890
|
+
const ev = this.getEventById(eventId);
|
|
3410
3891
|
this.stateManager.removeEvent(eventId);
|
|
3892
|
+
if (ev)
|
|
3893
|
+
this.liveAnnouncer.announce(`Event ${ev.title} removed.`);
|
|
3411
3894
|
}
|
|
3412
3895
|
getEventById(eventId) {
|
|
3413
3896
|
return this.events.find((e) => e.id === eventId) ?? null;
|
|
@@ -3424,6 +3907,7 @@ class MpScheduler extends LitElement {
|
|
|
3424
3907
|
<header class="scheduler-header"></header>
|
|
3425
3908
|
<div class="scheduler-content"></div>
|
|
3426
3909
|
</div>
|
|
3910
|
+
${this.liveAnnouncer.template()}
|
|
3427
3911
|
`;
|
|
3428
3912
|
}
|
|
3429
3913
|
firstUpdated() {
|
|
@@ -3445,33 +3929,48 @@ class MpScheduler extends LitElement {
|
|
|
3445
3929
|
getScrollContainer: () => this.contentContainer,
|
|
3446
3930
|
});
|
|
3447
3931
|
this.inputHandler.attach();
|
|
3932
|
+
// focusin on shadowRoot so e.target is the actual focused element
|
|
3933
|
+
// (avoids cross-shadow retargeting back to the host). Cast — focusin
|
|
3934
|
+
// isn't in the typed ShadowRootEventMap but the runtime supports it.
|
|
3935
|
+
this.shadowRoot.addEventListener('focusin', this.boundHandleFocusIn);
|
|
3448
3936
|
this.renderView();
|
|
3449
3937
|
}
|
|
3450
3938
|
populateHeader(header) {
|
|
3451
3939
|
// Navigation
|
|
3452
3940
|
const nav = document.createElement('nav');
|
|
3453
3941
|
nav.className = 'scheduler-nav';
|
|
3942
|
+
nav.setAttribute('aria-label', 'Scheduler navigation');
|
|
3454
3943
|
const prevBtn = document.createElement('button');
|
|
3944
|
+
prevBtn.type = 'button';
|
|
3455
3945
|
prevBtn.textContent = '‹';
|
|
3946
|
+
prevBtn.setAttribute('aria-label', 'Previous period');
|
|
3456
3947
|
prevBtn.title = 'Previous';
|
|
3457
3948
|
prevBtn.addEventListener('click', () => this.prev());
|
|
3458
3949
|
const nextBtn = document.createElement('button');
|
|
3950
|
+
nextBtn.type = 'button';
|
|
3459
3951
|
nextBtn.textContent = '›';
|
|
3952
|
+
nextBtn.setAttribute('aria-label', 'Next period');
|
|
3460
3953
|
nextBtn.title = 'Next';
|
|
3461
3954
|
nextBtn.addEventListener('click', () => this.next());
|
|
3462
3955
|
const todayBtn = document.createElement('button');
|
|
3956
|
+
todayBtn.type = 'button';
|
|
3463
3957
|
todayBtn.textContent = 'Today';
|
|
3958
|
+
todayBtn.setAttribute('aria-label', 'Jump to today');
|
|
3464
3959
|
todayBtn.addEventListener('click', () => this.today());
|
|
3465
3960
|
nav.appendChild(prevBtn);
|
|
3466
3961
|
nav.appendChild(nextBtn);
|
|
3467
3962
|
nav.appendChild(todayBtn);
|
|
3468
|
-
// Title
|
|
3963
|
+
// Title — assertive live region so navigation announces the new period.
|
|
3469
3964
|
const title = document.createElement('div');
|
|
3470
3965
|
title.className = 'scheduler-title';
|
|
3966
|
+
title.setAttribute('aria-live', 'polite');
|
|
3967
|
+
title.setAttribute('aria-atomic', 'true');
|
|
3471
3968
|
this.updateTitle(title);
|
|
3472
|
-
// View switcher
|
|
3969
|
+
// View switcher — toolbar of toggle-buttons; aria-pressed mirrors active state.
|
|
3473
3970
|
const viewSwitcher = document.createElement('div');
|
|
3474
3971
|
viewSwitcher.className = 'scheduler-view-switcher';
|
|
3972
|
+
viewSwitcher.setAttribute('role', 'group');
|
|
3973
|
+
viewSwitcher.setAttribute('aria-label', 'Switch view');
|
|
3475
3974
|
const views = [
|
|
3476
3975
|
{ key: 'year', label: 'Year' },
|
|
3477
3976
|
{ key: 'month', label: 'Month' },
|
|
@@ -3481,9 +3980,12 @@ class MpScheduler extends LitElement {
|
|
|
3481
3980
|
];
|
|
3482
3981
|
for (const { key, label } of views) {
|
|
3483
3982
|
const btn = document.createElement('button');
|
|
3983
|
+
btn.type = 'button';
|
|
3484
3984
|
btn.textContent = label;
|
|
3485
3985
|
btn.dataset['view'] = key;
|
|
3486
|
-
|
|
3986
|
+
const isActive = key === this.view;
|
|
3987
|
+
btn.setAttribute('aria-pressed', String(isActive));
|
|
3988
|
+
if (isActive) {
|
|
3487
3989
|
btn.classList.add('active');
|
|
3488
3990
|
}
|
|
3489
3991
|
btn.addEventListener('click', () => this.changeView(key));
|
|
@@ -3576,25 +4078,35 @@ class MpScheduler extends LitElement {
|
|
|
3576
4078
|
const dateChanged = this.previousDate !== null &&
|
|
3577
4079
|
this.previousDate.getTime() !== state.date.getTime();
|
|
3578
4080
|
const selectedEventId = state.selectedEvent?.id ?? null;
|
|
3579
|
-
const
|
|
3580
|
-
|
|
4081
|
+
const range = selectionRange(state);
|
|
4082
|
+
// Encode the range + resource into a single key so we fire selection-change
|
|
4083
|
+
// on any movement of anchor/extent/resourceId, including the transition
|
|
4084
|
+
// back to null (per PRD: consumers shouldn't have to poll).
|
|
4085
|
+
const rangeKey = range
|
|
4086
|
+
? `${range.start.getTime()}-${range.end.getTime()}-${state.selectionResourceId ?? ''}`
|
|
4087
|
+
: null;
|
|
4088
|
+
const selectionChanged = this.previousSelectedEventId !== selectedEventId ||
|
|
4089
|
+
this.previousRangeKey !== rangeKey;
|
|
3581
4090
|
if (viewChanged || dateChanged) {
|
|
3582
4091
|
this.eventEmitter.emitViewChange(state.view, state.date);
|
|
3583
4092
|
}
|
|
3584
4093
|
if (selectionChanged) {
|
|
3585
|
-
this.eventEmitter.emitSelectionChange(state.selectedEvent);
|
|
4094
|
+
this.eventEmitter.emitSelectionChange(state.selectedEvent, range, state.view, state.selectionResourceId ?? undefined);
|
|
3586
4095
|
}
|
|
3587
4096
|
this.previousView = state.view;
|
|
3588
4097
|
this.previousDate = new Date(state.date);
|
|
3589
4098
|
this.previousSelectedEventId = selectedEventId;
|
|
4099
|
+
this.previousRangeKey = rangeKey;
|
|
3590
4100
|
}
|
|
3591
4101
|
updateUI(state) {
|
|
3592
4102
|
this.updateTitle();
|
|
3593
|
-
// Update view switcher active state
|
|
4103
|
+
// Update view switcher active state — visual class + aria-pressed in lockstep.
|
|
3594
4104
|
const buttons = this.shadowRoot.querySelectorAll('.scheduler-view-switcher button');
|
|
3595
4105
|
buttons.forEach((btn) => {
|
|
3596
4106
|
const btnEl = btn;
|
|
3597
|
-
|
|
4107
|
+
const isActive = btnEl.dataset['view'] === state.view;
|
|
4108
|
+
btnEl.classList.toggle('active', isActive);
|
|
4109
|
+
btnEl.setAttribute('aria-pressed', String(isActive));
|
|
3598
4110
|
});
|
|
3599
4111
|
// Update or re-render view
|
|
3600
4112
|
if (this.currentView) {
|
|
@@ -3643,22 +4155,18 @@ class MpScheduler extends LitElement {
|
|
|
3643
4155
|
// It was a click, not a drag
|
|
3644
4156
|
if (result.event) {
|
|
3645
4157
|
this.stateManager.setSelectedEvent(result.event);
|
|
3646
|
-
this.eventEmitter.
|
|
4158
|
+
this.eventEmitter.emitEventSelected(result.event, originalEvent);
|
|
3647
4159
|
}
|
|
3648
4160
|
return;
|
|
3649
4161
|
}
|
|
3650
4162
|
// Handle actual drag completion
|
|
3651
4163
|
switch (result.type) {
|
|
3652
4164
|
case 'create': {
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
color: '#3788d8',
|
|
3659
|
-
};
|
|
3660
|
-
this.stateManager.addEvent(newEvent);
|
|
3661
|
-
this.eventEmitter.emitEventCreate(newEvent, originalEvent);
|
|
4165
|
+
// Per PRD scheduler-controlled-selection: the scheduler does not
|
|
4166
|
+
// construct or store the event itself — it emits the range as a
|
|
4167
|
+
// request, the consumer constructs the SchedulerEvent.
|
|
4168
|
+
const state = this.stateManager.getState();
|
|
4169
|
+
this.eventEmitter.emitEventCreate({ start: result.preview.start, end: result.preview.end }, state.view, originalEvent, result.preview.resourceId);
|
|
3662
4170
|
break;
|
|
3663
4171
|
}
|
|
3664
4172
|
case 'move':
|
|
@@ -3715,59 +4223,956 @@ class MpScheduler extends LitElement {
|
|
|
3715
4223
|
this.stateManager.setView('day');
|
|
3716
4224
|
}
|
|
3717
4225
|
}
|
|
4226
|
+
// Event click — also drives the keyboard-move tab stop. The drag flow
|
|
4227
|
+
// already calls setSelectedEvent on commit, but a plain click on an
|
|
4228
|
+
// event needs to select it too so the focus model can land on it.
|
|
4229
|
+
if (target.type === 'event' && target.event) {
|
|
4230
|
+
this.stateManager.setSelectedEvent(target.event);
|
|
4231
|
+
}
|
|
3718
4232
|
}
|
|
3719
4233
|
handleDoubleClick(pointer, target) {
|
|
3720
4234
|
if (target.type === 'event' && target.event) {
|
|
3721
4235
|
this.eventEmitter.emitEventDblClick(target.event, pointer.originalEvent);
|
|
3722
4236
|
}
|
|
3723
4237
|
}
|
|
4238
|
+
/**
|
|
4239
|
+
* When focus lands on an event block (Tab, programmatic, or click), select
|
|
4240
|
+
* the event and emit `event-selected` for mouse-parity (PRD §6.5 D3). The
|
|
4241
|
+
* subsequent setSelectedEvent call routes through detectAndEmitChanges
|
|
4242
|
+
* which fires `selection-change`.
|
|
4243
|
+
*
|
|
4244
|
+
* `focusin` bubbles across shadow boundaries but `e.target` is retargeted
|
|
4245
|
+
* to the host. Use composedPath()[0] for the actual focused element.
|
|
4246
|
+
*/
|
|
4247
|
+
handleFocusIn(e) {
|
|
4248
|
+
const path = (e.composedPath?.() ?? []);
|
|
4249
|
+
const target = (path[0] ?? e.target);
|
|
4250
|
+
if (!target || !target.dataset)
|
|
4251
|
+
return;
|
|
4252
|
+
const eventId = target.dataset['eventId'];
|
|
4253
|
+
if (!eventId)
|
|
4254
|
+
return;
|
|
4255
|
+
if (!target.classList.contains('scheduler-event') &&
|
|
4256
|
+
!target.classList.contains('scheduler-timeline-event')) {
|
|
4257
|
+
return;
|
|
4258
|
+
}
|
|
4259
|
+
const ev = this.getEventById(eventId);
|
|
4260
|
+
if (!ev)
|
|
4261
|
+
return;
|
|
4262
|
+
if (this.stateManager.getState().selectedEvent?.id === ev.id) {
|
|
4263
|
+
// Already selected — Tab landed on the same event again. Don't re-emit
|
|
4264
|
+
// event-selected to avoid noise from programmatic focus restoration
|
|
4265
|
+
// (e.g. after move-mode commit re-focuses the moved event).
|
|
4266
|
+
return;
|
|
4267
|
+
}
|
|
4268
|
+
this.stateManager.setSelectedEvent(ev);
|
|
4269
|
+
this.eventEmitter.emitEventSelected(ev, e);
|
|
4270
|
+
// setSelectedEvent triggers a re-render that destroys the event's DOM
|
|
4271
|
+
// node (renderEvents tears down + rebuilds). Restore focus so subsequent
|
|
4272
|
+
// keypresses (Enter to enter move-mode, Delete to delete) still see the
|
|
4273
|
+
// event as the active element.
|
|
4274
|
+
requestAnimationFrame(() => {
|
|
4275
|
+
const sel = `[data-event-id="${this.cssEscape(ev.id)}"]`;
|
|
4276
|
+
const newEl = this.shadowRoot?.querySelector(sel);
|
|
4277
|
+
newEl?.focus({ preventScroll: true });
|
|
4278
|
+
});
|
|
4279
|
+
}
|
|
3724
4280
|
handleKeyDown(e) {
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
4281
|
+
// Move-mode owns every key while active so arrows/Enter/Esc go to it.
|
|
4282
|
+
if (this.keyboardMove) {
|
|
4283
|
+
this.handleKeyboardMove(e);
|
|
4284
|
+
return;
|
|
4285
|
+
}
|
|
4286
|
+
// Cancel pointer drag with Escape regardless of focus.
|
|
4287
|
+
if (e.key === 'Escape' && this.dragManager.isDragging()) {
|
|
4288
|
+
this.dragManager.cancel();
|
|
4289
|
+
return;
|
|
4290
|
+
}
|
|
4291
|
+
// Alt+letter view shortcuts work from any focus (PRD D2). Bare letters
|
|
4292
|
+
// are no longer hot-keys — that frees them for future input surfaces.
|
|
4293
|
+
if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
|
4294
|
+
if (this.handleAltShortcut(e))
|
|
4295
|
+
return;
|
|
4296
|
+
}
|
|
4297
|
+
const kind = this.getFocusedKind();
|
|
4298
|
+
if (kind === 'cell') {
|
|
4299
|
+
this.handleCellKeyDown(e);
|
|
4300
|
+
}
|
|
4301
|
+
else if (kind === 'event') {
|
|
4302
|
+
this.handleEventKeyDown(e);
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
getFocusedKind() {
|
|
4306
|
+
const active = this.shadowRoot?.activeElement;
|
|
4307
|
+
if (!active)
|
|
4308
|
+
return 'other';
|
|
4309
|
+
if (active.classList.contains('scheduler-time-slot') ||
|
|
4310
|
+
active.classList.contains('scheduler-timeline-slot') ||
|
|
4311
|
+
active.classList.contains('scheduler-month-day') ||
|
|
4312
|
+
active.classList.contains('scheduler-year-month')) {
|
|
4313
|
+
return 'cell';
|
|
4314
|
+
}
|
|
4315
|
+
if (active.classList.contains('scheduler-event') ||
|
|
4316
|
+
active.classList.contains('scheduler-timeline-event')) {
|
|
4317
|
+
return 'event';
|
|
4318
|
+
}
|
|
4319
|
+
return 'other';
|
|
4320
|
+
}
|
|
4321
|
+
handleAltShortcut(e) {
|
|
4322
|
+
switch (e.key.toLowerCase()) {
|
|
3735
4323
|
case 't':
|
|
3736
|
-
case 'T':
|
|
3737
4324
|
this.today();
|
|
3738
4325
|
e.preventDefault();
|
|
3739
|
-
|
|
4326
|
+
return true;
|
|
3740
4327
|
case 'y':
|
|
3741
|
-
case 'Y':
|
|
3742
4328
|
this.changeView('year');
|
|
3743
4329
|
e.preventDefault();
|
|
3744
|
-
|
|
4330
|
+
return true;
|
|
3745
4331
|
case 'm':
|
|
3746
|
-
case 'M':
|
|
3747
4332
|
this.changeView('month');
|
|
3748
4333
|
e.preventDefault();
|
|
3749
|
-
|
|
4334
|
+
return true;
|
|
3750
4335
|
case 'w':
|
|
3751
|
-
case 'W':
|
|
3752
4336
|
this.changeView('week');
|
|
3753
4337
|
e.preventDefault();
|
|
3754
|
-
|
|
4338
|
+
return true;
|
|
3755
4339
|
case 'd':
|
|
3756
|
-
case 'D':
|
|
3757
4340
|
this.changeView('day');
|
|
3758
4341
|
e.preventDefault();
|
|
3759
|
-
|
|
4342
|
+
return true;
|
|
4343
|
+
}
|
|
4344
|
+
return false;
|
|
4345
|
+
}
|
|
4346
|
+
handleEventKeyDown(e) {
|
|
4347
|
+
const state = this.stateManager.getState();
|
|
4348
|
+
const ev = state.selectedEvent;
|
|
4349
|
+
if (!ev)
|
|
4350
|
+
return;
|
|
4351
|
+
switch (e.key) {
|
|
4352
|
+
case 'Enter':
|
|
4353
|
+
e.preventDefault();
|
|
4354
|
+
this.enterEventMoveMode(ev);
|
|
4355
|
+
return;
|
|
3760
4356
|
case 'Delete':
|
|
3761
4357
|
case 'Backspace':
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
4358
|
+
e.preventDefault();
|
|
4359
|
+
this.eventEmitter.emitEventDelete(ev);
|
|
4360
|
+
return;
|
|
4361
|
+
case 'Escape':
|
|
4362
|
+
e.preventDefault();
|
|
4363
|
+
this.focusFocusedCell();
|
|
4364
|
+
return;
|
|
4365
|
+
case 'ArrowLeft':
|
|
4366
|
+
e.preventDefault();
|
|
4367
|
+
this.focusAdjacentEvent(ev, -1);
|
|
4368
|
+
return;
|
|
4369
|
+
case 'ArrowRight':
|
|
4370
|
+
e.preventDefault();
|
|
4371
|
+
this.focusAdjacentEvent(ev, +1);
|
|
4372
|
+
return;
|
|
4373
|
+
}
|
|
4374
|
+
}
|
|
4375
|
+
/**
|
|
4376
|
+
* Inter-event arrow nav (PRD scheduler-controlled-selection §5.3): walk
|
|
4377
|
+
* the events in document order (start time, with id as tiebreaker) by ±1.
|
|
4378
|
+
* No wrap at the ends — matches the APG list/feed pattern.
|
|
4379
|
+
*/
|
|
4380
|
+
focusAdjacentEvent(current, direction) {
|
|
4381
|
+
const state = this.stateManager.getState();
|
|
4382
|
+
const ordered = [...state.events].sort((a, b) => {
|
|
4383
|
+
const dt = a.start.getTime() - b.start.getTime();
|
|
4384
|
+
return dt !== 0 ? dt : a.id.localeCompare(b.id);
|
|
4385
|
+
});
|
|
4386
|
+
const idx = ordered.findIndex((e) => e.id === current.id);
|
|
4387
|
+
if (idx < 0)
|
|
4388
|
+
return;
|
|
4389
|
+
const target = ordered[idx + direction];
|
|
4390
|
+
if (!target)
|
|
4391
|
+
return; // boundary — no wrap.
|
|
4392
|
+
const root = this.shadowRoot;
|
|
4393
|
+
if (!root)
|
|
4394
|
+
return;
|
|
4395
|
+
const el = root.querySelector(`[data-event-id="${this.cssEscape(target.id)}"]`);
|
|
4396
|
+
el?.focus({ preventScroll: false });
|
|
4397
|
+
}
|
|
4398
|
+
handleCellKeyDown(e) {
|
|
4399
|
+
const state = this.stateManager.getState();
|
|
4400
|
+
// Month + year views have their own focus model — `focusedDate` (a whole
|
|
4401
|
+
// day or month) rather than `focusedCell` (a time slot) — and route
|
|
4402
|
+
// through dedicated handlers per PRD scheduler-controlled-selection §5.
|
|
4403
|
+
if (state.view === 'month') {
|
|
4404
|
+
this.handleMonthCellKeyDown(e);
|
|
4405
|
+
return;
|
|
4406
|
+
}
|
|
4407
|
+
if (state.view === 'year') {
|
|
4408
|
+
this.handleYearCellKeyDown(e);
|
|
4409
|
+
return;
|
|
4410
|
+
}
|
|
4411
|
+
if (!state.focusedCell)
|
|
4412
|
+
this.initFocusedCellFromActive();
|
|
4413
|
+
const shift = e.shiftKey;
|
|
4414
|
+
const ctrl = e.ctrlKey || e.metaKey;
|
|
4415
|
+
// Arrow mapping is physical-direction-aware:
|
|
4416
|
+
// week/day: time is vertical → ArrowUp/Down nudge time, ArrowLeft/Right
|
|
4417
|
+
// walk days (week only).
|
|
4418
|
+
// timeline: time is horizontal → ArrowLeft/Right nudge time,
|
|
4419
|
+
// ArrowUp/Down walk resources (rows).
|
|
4420
|
+
const timelineLayout = state.view === 'timeline';
|
|
4421
|
+
switch (e.key) {
|
|
4422
|
+
case 'ArrowUp':
|
|
4423
|
+
e.preventDefault();
|
|
4424
|
+
timelineLayout ? this.moveCellByResource(-1, shift) : this.moveCellByTime(-1, shift);
|
|
4425
|
+
break;
|
|
4426
|
+
case 'ArrowDown':
|
|
4427
|
+
e.preventDefault();
|
|
4428
|
+
timelineLayout ? this.moveCellByResource(+1, shift) : this.moveCellByTime(+1, shift);
|
|
4429
|
+
break;
|
|
4430
|
+
case 'ArrowLeft':
|
|
4431
|
+
e.preventDefault();
|
|
4432
|
+
timelineLayout ? this.moveCellByTime(-1, shift) : this.moveCellByDay(-1, shift);
|
|
4433
|
+
break;
|
|
4434
|
+
case 'ArrowRight':
|
|
4435
|
+
e.preventDefault();
|
|
4436
|
+
timelineLayout ? this.moveCellByTime(+1, shift) : this.moveCellByDay(+1, shift);
|
|
4437
|
+
break;
|
|
4438
|
+
case 'Home':
|
|
4439
|
+
e.preventDefault();
|
|
4440
|
+
ctrl ? this.moveCellToViewExtreme('start', shift) : this.moveCellToColumnExtreme('start', shift);
|
|
4441
|
+
break;
|
|
4442
|
+
case 'End':
|
|
4443
|
+
e.preventDefault();
|
|
4444
|
+
ctrl ? this.moveCellToViewExtreme('end', shift) : this.moveCellToColumnExtreme('end', shift);
|
|
4445
|
+
break;
|
|
4446
|
+
case 'PageUp':
|
|
4447
|
+
e.preventDefault();
|
|
4448
|
+
this.moveCellByPeriod(-1);
|
|
4449
|
+
break;
|
|
4450
|
+
case 'PageDown':
|
|
4451
|
+
e.preventDefault();
|
|
4452
|
+
this.moveCellByPeriod(+1);
|
|
4453
|
+
break;
|
|
4454
|
+
case 'Enter':
|
|
4455
|
+
e.preventDefault();
|
|
4456
|
+
this.createEventFromCellOrSelection(e);
|
|
3765
4457
|
break;
|
|
3766
4458
|
case 'Escape':
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
4459
|
+
e.preventDefault();
|
|
4460
|
+
this.stateManager.clearSelection();
|
|
4461
|
+
break;
|
|
4462
|
+
}
|
|
4463
|
+
}
|
|
4464
|
+
/**
|
|
4465
|
+
* If a cell is the active element but state.focusedCell is empty (e.g.
|
|
4466
|
+
* Tab landed on the fallback first cell), seed the state from the
|
|
4467
|
+
* active element's data attributes.
|
|
4468
|
+
*/
|
|
4469
|
+
initFocusedCellFromActive() {
|
|
4470
|
+
const active = this.shadowRoot?.activeElement;
|
|
4471
|
+
if (!active)
|
|
4472
|
+
return;
|
|
4473
|
+
const startStr = active.dataset['start'];
|
|
4474
|
+
const endStr = active.dataset['end'];
|
|
4475
|
+
if (!startStr || !endStr)
|
|
4476
|
+
return;
|
|
4477
|
+
const cell = { start: new Date(startStr), end: new Date(endStr) };
|
|
4478
|
+
const resourceId = active.dataset['resourceId'] ?? null;
|
|
4479
|
+
this.stateManager.setFocusedCell(cell, resourceId, true);
|
|
4480
|
+
}
|
|
4481
|
+
/**
|
|
4482
|
+
* Month-view keyboard handler (PRD scheduler-controlled-selection §5.1).
|
|
4483
|
+
* Arrows walk days; ArrowUp/Down ± one week; cross-month moves auto-
|
|
4484
|
+
* advance the displayed date so the new month renders. Enter emits
|
|
4485
|
+
* `event-create` for the focused day's full range.
|
|
4486
|
+
*/
|
|
4487
|
+
handleMonthCellKeyDown(e) {
|
|
4488
|
+
// Always re-seed from the active element so click / programmatic-focus
|
|
4489
|
+
// moves win over any stale `focusedDate` that lingered from a prior
|
|
4490
|
+
// view. (Cheap; the alternative would be to clear `focusedDate` on
|
|
4491
|
+
// view-change, which loses the user's last position when they bounce
|
|
4492
|
+
// back.)
|
|
4493
|
+
this.syncFocusedDateFromActive();
|
|
4494
|
+
switch (e.key) {
|
|
4495
|
+
case 'ArrowLeft':
|
|
4496
|
+
e.preventDefault();
|
|
4497
|
+
this.moveFocusedDateByDays(-1);
|
|
4498
|
+
return;
|
|
4499
|
+
case 'ArrowRight':
|
|
4500
|
+
e.preventDefault();
|
|
4501
|
+
this.moveFocusedDateByDays(+1);
|
|
4502
|
+
return;
|
|
4503
|
+
case 'ArrowUp':
|
|
4504
|
+
e.preventDefault();
|
|
4505
|
+
this.moveFocusedDateByDays(-7);
|
|
4506
|
+
return;
|
|
4507
|
+
case 'ArrowDown':
|
|
4508
|
+
e.preventDefault();
|
|
4509
|
+
this.moveFocusedDateByDays(+7);
|
|
4510
|
+
return;
|
|
4511
|
+
case 'Enter':
|
|
4512
|
+
e.preventDefault();
|
|
4513
|
+
this.commitFocusedDateAsCreate(e, 'day');
|
|
4514
|
+
return;
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
/**
|
|
4518
|
+
* Year-view keyboard handler (PRD scheduler-controlled-selection §5.2).
|
|
4519
|
+
* The focus unit is a month — ArrowLeft/Right ± 1, ArrowUp/Down ± 3 to
|
|
4520
|
+
* mirror the visual 4×3 layout. Cross-year auto-advances. Enter emits
|
|
4521
|
+
* `event-create` for the focused month's full range.
|
|
4522
|
+
*/
|
|
4523
|
+
handleYearCellKeyDown(e) {
|
|
4524
|
+
this.syncFocusedDateFromActive();
|
|
4525
|
+
switch (e.key) {
|
|
4526
|
+
case 'ArrowLeft':
|
|
4527
|
+
e.preventDefault();
|
|
4528
|
+
this.moveFocusedDateByMonths(-1);
|
|
4529
|
+
return;
|
|
4530
|
+
case 'ArrowRight':
|
|
4531
|
+
e.preventDefault();
|
|
4532
|
+
this.moveFocusedDateByMonths(+1);
|
|
4533
|
+
return;
|
|
4534
|
+
case 'ArrowUp':
|
|
4535
|
+
e.preventDefault();
|
|
4536
|
+
this.moveFocusedDateByMonths(-3);
|
|
4537
|
+
return;
|
|
4538
|
+
case 'ArrowDown':
|
|
4539
|
+
e.preventDefault();
|
|
4540
|
+
this.moveFocusedDateByMonths(+3);
|
|
4541
|
+
return;
|
|
4542
|
+
case 'Enter':
|
|
4543
|
+
e.preventDefault();
|
|
4544
|
+
this.commitFocusedDateAsCreate(e, 'month');
|
|
4545
|
+
return;
|
|
4546
|
+
}
|
|
4547
|
+
}
|
|
4548
|
+
/**
|
|
4549
|
+
* Sync `focusedDate` from the currently-active month/year cell. Runs at
|
|
4550
|
+
* the top of every Phase B keydown so click-driven and Tab-driven focus
|
|
4551
|
+
* moves are reflected in state before arrow keys read it.
|
|
4552
|
+
*/
|
|
4553
|
+
syncFocusedDateFromActive() {
|
|
4554
|
+
const active = this.shadowRoot?.activeElement;
|
|
4555
|
+
if (!active)
|
|
4556
|
+
return;
|
|
4557
|
+
const dateStr = active.dataset['date'] ?? active.dataset['month'];
|
|
4558
|
+
if (!dateStr)
|
|
4559
|
+
return;
|
|
4560
|
+
this.stateManager.setFocusedDate(new Date(dateStr));
|
|
4561
|
+
}
|
|
4562
|
+
moveFocusedDateByDays(deltaDays) {
|
|
4563
|
+
const state = this.stateManager.getState();
|
|
4564
|
+
const current = state.focusedDate ?? state.date;
|
|
4565
|
+
const next = new Date(current);
|
|
4566
|
+
next.setDate(next.getDate() + deltaDays);
|
|
4567
|
+
this.commitFocusedDate(next);
|
|
4568
|
+
}
|
|
4569
|
+
moveFocusedDateByMonths(deltaMonths) {
|
|
4570
|
+
const state = this.stateManager.getState();
|
|
4571
|
+
const current = state.focusedDate ?? state.date;
|
|
4572
|
+
const next = new Date(current);
|
|
4573
|
+
next.setMonth(next.getMonth() + deltaMonths);
|
|
4574
|
+
this.commitFocusedDate(next);
|
|
4575
|
+
}
|
|
4576
|
+
/**
|
|
4577
|
+
* Apply a focused-date update. If the new date crosses the displayed
|
|
4578
|
+
* period (different month on month view; different year on year view),
|
|
4579
|
+
* also bump `state.date` so the view re-renders to the new period —
|
|
4580
|
+
* APG date-picker auto-advance behaviour. Then schedule a focus
|
|
4581
|
+
* restoration on the matching cell after the next render.
|
|
4582
|
+
*/
|
|
4583
|
+
commitFocusedDate(next) {
|
|
4584
|
+
const state = this.stateManager.getState();
|
|
4585
|
+
let advanceTo = null;
|
|
4586
|
+
if (state.view === 'month') {
|
|
4587
|
+
const sameMonth = next.getFullYear() === state.date.getFullYear() &&
|
|
4588
|
+
next.getMonth() === state.date.getMonth();
|
|
4589
|
+
if (!sameMonth)
|
|
4590
|
+
advanceTo = next;
|
|
4591
|
+
}
|
|
4592
|
+
else if (state.view === 'year') {
|
|
4593
|
+
if (next.getFullYear() !== state.date.getFullYear())
|
|
4594
|
+
advanceTo = next;
|
|
4595
|
+
}
|
|
4596
|
+
if (advanceTo) {
|
|
4597
|
+
// setDate triggers the view to re-render with the new month/year, then
|
|
4598
|
+
// we set the focused date so the renderer's tabindex update catches it.
|
|
4599
|
+
this.stateManager.setDate(new Date(advanceTo));
|
|
4600
|
+
}
|
|
4601
|
+
this.stateManager.setFocusedDate(next);
|
|
4602
|
+
// Within-period nav: the target cell is already in the DOM, focus it
|
|
4603
|
+
// synchronously so the next keydown sees the right `activeElement`.
|
|
4604
|
+
// Cross-period nav: the cell only exists after Lit re-renders, so the
|
|
4605
|
+
// rAF re-tries focus on the next frame. The two together cover both
|
|
4606
|
+
// cases without flicker.
|
|
4607
|
+
this.scrollAndFocusDateCell(next);
|
|
4608
|
+
if (advanceTo) {
|
|
4609
|
+
requestAnimationFrame(() => this.scrollAndFocusDateCell(next));
|
|
4610
|
+
}
|
|
4611
|
+
}
|
|
4612
|
+
/**
|
|
4613
|
+
* Find the month/year date-cell DOM element by id and focus it. Keys are
|
|
4614
|
+
* built from *local* date components to match `MonthView.dayKey()` /
|
|
4615
|
+
* `YearView.monthKey()` — see those helpers for why ISO is unsafe across
|
|
4616
|
+
* non-UTC timezones.
|
|
4617
|
+
*/
|
|
4618
|
+
scrollAndFocusDateCell(date) {
|
|
4619
|
+
const state = this.stateManager.getState();
|
|
4620
|
+
const root = this.shadowRoot;
|
|
4621
|
+
if (!root)
|
|
4622
|
+
return;
|
|
4623
|
+
const yyyymm = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
|
4624
|
+
const id = state.view === 'year'
|
|
4625
|
+
? `scheduler-cell-y-${yyyymm}`
|
|
4626
|
+
: `scheduler-cell-m-${yyyymm}-${String(date.getDate()).padStart(2, '0')}`;
|
|
4627
|
+
const el = root.getElementById(id);
|
|
4628
|
+
if (!el)
|
|
4629
|
+
return;
|
|
4630
|
+
el.focus({ preventScroll: true });
|
|
4631
|
+
if (typeof el.scrollIntoView === 'function') {
|
|
4632
|
+
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
4633
|
+
}
|
|
4634
|
+
}
|
|
4635
|
+
/**
|
|
4636
|
+
* Emit `event-create` covering the focused day (month view) or focused
|
|
4637
|
+
* month (year view). No internal mutation per PRD
|
|
4638
|
+
* scheduler-controlled-selection — consumer constructs the actual event.
|
|
4639
|
+
*/
|
|
4640
|
+
commitFocusedDateAsCreate(originalEvent, unit) {
|
|
4641
|
+
const state = this.stateManager.getState();
|
|
4642
|
+
const focused = state.focusedDate;
|
|
4643
|
+
if (!focused)
|
|
4644
|
+
return;
|
|
4645
|
+
const start = new Date(focused);
|
|
4646
|
+
start.setHours(0, 0, 0, 0);
|
|
4647
|
+
const end = new Date(start);
|
|
4648
|
+
if (unit === 'day') {
|
|
4649
|
+
end.setDate(end.getDate() + 1);
|
|
4650
|
+
}
|
|
4651
|
+
else {
|
|
4652
|
+
end.setMonth(end.getMonth() + 1);
|
|
4653
|
+
}
|
|
4654
|
+
this.eventEmitter.emitEventCreate({ start, end }, state.view, originalEvent);
|
|
4655
|
+
}
|
|
4656
|
+
moveCellByTime(direction, extend) {
|
|
4657
|
+
const state = this.stateManager.getState();
|
|
4658
|
+
const f = state.focusedCell;
|
|
4659
|
+
if (!f)
|
|
4660
|
+
return;
|
|
4661
|
+
const slotMs = (state.options.slotDuration ?? 1800) * 1000;
|
|
4662
|
+
const newStart = new Date(f.start.getTime() + direction * slotMs);
|
|
4663
|
+
const newEnd = new Date(newStart.getTime() + slotMs);
|
|
4664
|
+
if (!this.cellIsWithinView(newStart, state))
|
|
4665
|
+
return;
|
|
4666
|
+
this.commitFocusMove({ start: newStart, end: newEnd }, state.focusedResourceId, extend);
|
|
4667
|
+
}
|
|
4668
|
+
/** Week view ArrowLeft/Right: ±1 day, same time-of-day. No-op on day view. */
|
|
4669
|
+
moveCellByDay(direction, extend) {
|
|
4670
|
+
const state = this.stateManager.getState();
|
|
4671
|
+
const f = state.focusedCell;
|
|
4672
|
+
if (!f)
|
|
4673
|
+
return;
|
|
4674
|
+
if (state.view !== 'week')
|
|
4675
|
+
return;
|
|
4676
|
+
const slotMs = (state.options.slotDuration ?? 1800) * 1000;
|
|
4677
|
+
const newStart = new Date(f.start);
|
|
4678
|
+
newStart.setDate(newStart.getDate() + direction);
|
|
4679
|
+
const newEnd = new Date(newStart.getTime() + slotMs);
|
|
4680
|
+
if (!this.cellIsWithinView(newStart, state))
|
|
4681
|
+
return;
|
|
4682
|
+
this.commitFocusMove({ start: newStart, end: newEnd }, null, extend);
|
|
4683
|
+
}
|
|
4684
|
+
/** Timeline ArrowUp/Down: ±1 resource, same time-of-day. PRD D1: cross-resource
|
|
4685
|
+
* Shift+Arrow is intentionally ignored (resource is categorical). */
|
|
4686
|
+
moveCellByResource(direction, extend) {
|
|
4687
|
+
const state = this.stateManager.getState();
|
|
4688
|
+
const f = state.focusedCell;
|
|
4689
|
+
if (!f)
|
|
4690
|
+
return;
|
|
4691
|
+
if (state.view !== 'timeline')
|
|
4692
|
+
return;
|
|
4693
|
+
if (extend)
|
|
4694
|
+
return;
|
|
4695
|
+
const next = this.adjacentResource(state.focusedResourceId, direction, state);
|
|
4696
|
+
if (!next)
|
|
4697
|
+
return;
|
|
4698
|
+
this.commitFocusMove(f, next, false);
|
|
4699
|
+
}
|
|
4700
|
+
moveCellToColumnExtreme(end, extend) {
|
|
4701
|
+
const state = this.stateManager.getState();
|
|
4702
|
+
const f = state.focusedCell;
|
|
4703
|
+
if (!f)
|
|
4704
|
+
return;
|
|
4705
|
+
const day = new Date(f.start);
|
|
4706
|
+
day.setHours(0, 0, 0, 0);
|
|
4707
|
+
const slots = dateService.getTimeSlots(day, state.options.slotDuration, state.options.slotMinTime, state.options.slotMaxTime);
|
|
4708
|
+
const target = end === 'start' ? slots[0] : slots[slots.length - 1];
|
|
4709
|
+
if (target)
|
|
4710
|
+
this.commitFocusMove(target, state.focusedResourceId, extend);
|
|
4711
|
+
}
|
|
4712
|
+
moveCellToViewExtreme(end, extend) {
|
|
4713
|
+
const state = this.stateManager.getState();
|
|
4714
|
+
let target = null;
|
|
4715
|
+
let resourceId = state.focusedResourceId;
|
|
4716
|
+
switch (state.view) {
|
|
4717
|
+
case 'day': {
|
|
4718
|
+
const slots = dateService.getTimeSlots(state.date, state.options.slotDuration, state.options.slotMinTime, state.options.slotMaxTime);
|
|
4719
|
+
target = end === 'start' ? slots[0] : slots[slots.length - 1];
|
|
3770
4720
|
break;
|
|
4721
|
+
}
|
|
4722
|
+
case 'week': {
|
|
4723
|
+
const days = dateService.getWeekDays(state.date, state.options.firstDayOfWeek);
|
|
4724
|
+
const day = end === 'start' ? days[0] : days[6];
|
|
4725
|
+
const slots = dateService.getTimeSlots(day, state.options.slotDuration, state.options.slotMinTime, state.options.slotMaxTime);
|
|
4726
|
+
target = end === 'start' ? slots[0] : slots[slots.length - 1];
|
|
4727
|
+
break;
|
|
4728
|
+
}
|
|
4729
|
+
case 'timeline': {
|
|
4730
|
+
const flattened = resourceService.flatten(state.resources, state.collapsedGroups);
|
|
4731
|
+
const visible = flattened.filter((f) => f.visible && isResource(f.item));
|
|
4732
|
+
if (visible.length === 0)
|
|
4733
|
+
return;
|
|
4734
|
+
resourceId = end === 'start' ? visible[0].item.id : visible[visible.length - 1].item.id;
|
|
4735
|
+
const days = dateService.getWeekDays(state.date, state.options.firstDayOfWeek);
|
|
4736
|
+
const day = end === 'start' ? days[0] : days[6];
|
|
4737
|
+
const slots = dateService.getTimeSlots(day, state.options.slotDuration, state.options.slotMinTime, state.options.slotMaxTime);
|
|
4738
|
+
target = end === 'start' ? slots[0] : slots[slots.length - 1];
|
|
4739
|
+
break;
|
|
4740
|
+
}
|
|
4741
|
+
}
|
|
4742
|
+
if (target)
|
|
4743
|
+
this.commitFocusMove(target, resourceId, extend);
|
|
4744
|
+
}
|
|
4745
|
+
/**
|
|
4746
|
+
* PageUp/PageDown — advance one period (week or day) and re-focus the same
|
|
4747
|
+
* day-of-week + time-of-day in the new period. Selection is cleared since
|
|
4748
|
+
* crossing a period boundary breaks the linear-range invariant.
|
|
4749
|
+
*/
|
|
4750
|
+
moveCellByPeriod(direction) {
|
|
4751
|
+
const state = this.stateManager.getState();
|
|
4752
|
+
const f = state.focusedCell;
|
|
4753
|
+
const oldDate = state.date;
|
|
4754
|
+
if (direction > 0)
|
|
4755
|
+
this.next();
|
|
4756
|
+
else
|
|
4757
|
+
this.prev();
|
|
4758
|
+
if (!f)
|
|
4759
|
+
return;
|
|
4760
|
+
const newState = this.stateManager.getState();
|
|
4761
|
+
let newStart;
|
|
4762
|
+
if (newState.view === 'day') {
|
|
4763
|
+
newStart = new Date(newState.date);
|
|
4764
|
+
newStart.setHours(f.start.getHours(), f.start.getMinutes(), f.start.getSeconds(), 0);
|
|
4765
|
+
}
|
|
4766
|
+
else {
|
|
4767
|
+
// week / timeline — preserve day-of-week index.
|
|
4768
|
+
const oldDays = dateService.getWeekDays(oldDate, state.options.firstDayOfWeek);
|
|
4769
|
+
const newDays = dateService.getWeekDays(newState.date, newState.options.firstDayOfWeek);
|
|
4770
|
+
const oldIdx = oldDays.findIndex((d) => dateService.isSameDay(d, f.start));
|
|
4771
|
+
const targetDay = newDays[Math.max(0, oldIdx)] ?? newDays[0];
|
|
4772
|
+
newStart = new Date(targetDay);
|
|
4773
|
+
newStart.setHours(f.start.getHours(), f.start.getMinutes(), f.start.getSeconds(), 0);
|
|
4774
|
+
}
|
|
4775
|
+
const slotMs = (newState.options.slotDuration ?? 1800) * 1000;
|
|
4776
|
+
const cell = { start: newStart, end: new Date(newStart.getTime() + slotMs) };
|
|
4777
|
+
this.commitFocusMove(cell, state.focusedResourceId, false);
|
|
4778
|
+
}
|
|
4779
|
+
/**
|
|
4780
|
+
* Apply the focus move to state and DOM. `extend` grows the selection
|
|
4781
|
+
* range; otherwise selection is cleared. Live-region announces the new
|
|
4782
|
+
* focused cell or selection range.
|
|
4783
|
+
*/
|
|
4784
|
+
commitFocusMove(cell, resourceId, extend) {
|
|
4785
|
+
const state = this.stateManager.getState();
|
|
4786
|
+
const slotDuration = state.options.slotDuration ?? 1800;
|
|
4787
|
+
if (extend) {
|
|
4788
|
+
this.stateManager.extendSelection(cell, resourceId);
|
|
4789
|
+
this.stateManager.setFocusedCell(cell, resourceId, false);
|
|
4790
|
+
const newState = this.stateManager.getState();
|
|
4791
|
+
this.liveAnnouncer.announce(formatSelectionAnnouncement(newState, slotDuration, state.options.timeFormat));
|
|
4792
|
+
}
|
|
4793
|
+
else {
|
|
4794
|
+
this.stateManager.setFocusedCell(cell, resourceId, true);
|
|
4795
|
+
const resourceTitle = this.getResourceTitle(resourceId);
|
|
4796
|
+
this.liveAnnouncer.announce(formatCellAnnouncement(cell, state.options.timeFormat, resourceTitle));
|
|
4797
|
+
}
|
|
4798
|
+
this.scrollAndFocusCell(cell, resourceId);
|
|
4799
|
+
}
|
|
4800
|
+
cellIsWithinView(start, state) {
|
|
4801
|
+
switch (state.view) {
|
|
4802
|
+
case 'day': {
|
|
4803
|
+
const dayStart = this.parseTimeOnDay(state.date, state.options.slotMinTime);
|
|
4804
|
+
const dayEnd = this.parseTimeOnDay(state.date, state.options.slotMaxTime);
|
|
4805
|
+
return start.getTime() >= dayStart.getTime() && start.getTime() < dayEnd.getTime();
|
|
4806
|
+
}
|
|
4807
|
+
case 'week':
|
|
4808
|
+
case 'timeline': {
|
|
4809
|
+
const days = dateService.getWeekDays(state.date, state.options.firstDayOfWeek);
|
|
4810
|
+
const viewStart = this.parseTimeOnDay(days[0], state.options.slotMinTime);
|
|
4811
|
+
const viewEnd = this.parseTimeOnDay(days[6], state.options.slotMaxTime);
|
|
4812
|
+
return start.getTime() >= viewStart.getTime() && start.getTime() < viewEnd.getTime();
|
|
4813
|
+
}
|
|
4814
|
+
default:
|
|
4815
|
+
return false;
|
|
4816
|
+
}
|
|
4817
|
+
}
|
|
4818
|
+
parseTimeOnDay(day, timeStr) {
|
|
4819
|
+
const [h, m, s] = (timeStr ?? '00:00:00').split(':').map(Number);
|
|
4820
|
+
const d = new Date(day);
|
|
4821
|
+
d.setHours(0, 0, 0, 0);
|
|
4822
|
+
d.setSeconds((h ?? 0) * 3600 + (m ?? 0) * 60 + (s ?? 0));
|
|
4823
|
+
return d;
|
|
4824
|
+
}
|
|
4825
|
+
adjacentResource(currentId, direction, state) {
|
|
4826
|
+
const flattened = resourceService.flatten(state.resources, state.collapsedGroups);
|
|
4827
|
+
const visible = flattened.filter((f) => f.visible && isResource(f.item));
|
|
4828
|
+
if (visible.length === 0)
|
|
4829
|
+
return null;
|
|
4830
|
+
if (!currentId)
|
|
4831
|
+
return visible[0].item.id;
|
|
4832
|
+
const idx = visible.findIndex((f) => f.item.id === currentId);
|
|
4833
|
+
if (idx < 0)
|
|
4834
|
+
return visible[0].item.id;
|
|
4835
|
+
const next = idx + direction;
|
|
4836
|
+
if (next < 0 || next >= visible.length)
|
|
4837
|
+
return null;
|
|
4838
|
+
return visible[next].item.id;
|
|
4839
|
+
}
|
|
4840
|
+
getResourceTitle(id) {
|
|
4841
|
+
if (!id)
|
|
4842
|
+
return null;
|
|
4843
|
+
for (const r of resourceService.getAllResources(this.stateManager.getState().resources)) {
|
|
4844
|
+
if (r.id === id)
|
|
4845
|
+
return r.title;
|
|
4846
|
+
}
|
|
4847
|
+
return null;
|
|
4848
|
+
}
|
|
4849
|
+
/**
|
|
4850
|
+
* Find the cell DOM element for a (slot, resource) pair and call .focus()
|
|
4851
|
+
* on it. scrollIntoView with block:nearest provides parity with mouse
|
|
4852
|
+
* drag-near-edge auto-pan (PRD D6).
|
|
4853
|
+
*/
|
|
4854
|
+
scrollAndFocusCell(cell, resourceId) {
|
|
4855
|
+
const startIso = cell.start.toISOString();
|
|
4856
|
+
const root = this.shadowRoot;
|
|
4857
|
+
if (!root)
|
|
4858
|
+
return;
|
|
4859
|
+
const sel = resourceId
|
|
4860
|
+
? `.scheduler-timeline-slot[data-resource-id="${this.cssEscape(resourceId)}"][data-start="${startIso}"]`
|
|
4861
|
+
: `.scheduler-time-slot[data-start="${startIso}"]`;
|
|
4862
|
+
const el = root.querySelector(sel);
|
|
4863
|
+
if (!el)
|
|
4864
|
+
return;
|
|
4865
|
+
el.focus({ preventScroll: true });
|
|
4866
|
+
// jsdom doesn't implement scrollIntoView — guard so unit tests don't crash.
|
|
4867
|
+
if (typeof el.scrollIntoView === 'function') {
|
|
4868
|
+
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
4869
|
+
}
|
|
4870
|
+
}
|
|
4871
|
+
/** Re-focus whatever cell the keyboard model currently considers focused. */
|
|
4872
|
+
focusFocusedCell() {
|
|
4873
|
+
const state = this.stateManager.getState();
|
|
4874
|
+
if (state.focusedCell) {
|
|
4875
|
+
this.scrollAndFocusCell(state.focusedCell, state.focusedResourceId);
|
|
4876
|
+
}
|
|
4877
|
+
}
|
|
4878
|
+
cssEscape(value) {
|
|
4879
|
+
// Lightweight CSS.escape polyfill — sufficient for resource ids that are
|
|
4880
|
+
// ULID/UUIDs or simple strings. Falls back to the native API where it exists.
|
|
4881
|
+
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function')
|
|
4882
|
+
return CSS.escape(value);
|
|
4883
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`);
|
|
4884
|
+
}
|
|
4885
|
+
/**
|
|
4886
|
+
* Emit `event-create` covering the active selection range, or a single
|
|
4887
|
+
* cell when no selection is active. Per PRD scheduler-controlled-selection,
|
|
4888
|
+
* this is a *request* — no internal state mutation, no auto-clear, no
|
|
4889
|
+
* auto-focus. The consumer constructs the SchedulerEvent and decides
|
|
4890
|
+
* whether/when to clear the selection.
|
|
4891
|
+
*/
|
|
4892
|
+
createEventFromCellOrSelection(originalEvent) {
|
|
4893
|
+
const state = this.stateManager.getState();
|
|
4894
|
+
const range = selectionRange(state);
|
|
4895
|
+
let start;
|
|
4896
|
+
let end;
|
|
4897
|
+
let resourceId;
|
|
4898
|
+
if (range) {
|
|
4899
|
+
start = range.start;
|
|
4900
|
+
end = range.end;
|
|
4901
|
+
resourceId = state.selectionResourceId ?? undefined;
|
|
4902
|
+
}
|
|
4903
|
+
else if (state.focusedCell) {
|
|
4904
|
+
start = state.focusedCell.start;
|
|
4905
|
+
end = state.focusedCell.end;
|
|
4906
|
+
resourceId = state.focusedResourceId ?? undefined;
|
|
4907
|
+
}
|
|
4908
|
+
else {
|
|
4909
|
+
return;
|
|
4910
|
+
}
|
|
4911
|
+
this.eventEmitter.emitEventCreate({ start, end }, state.view, originalEvent, resourceId);
|
|
4912
|
+
this.liveAnnouncer.announce(`Selection committed: ${dateService.formatTime(start, state.options.timeFormat)}–${dateService.formatTime(end, state.options.timeFormat)}.`);
|
|
4913
|
+
}
|
|
4914
|
+
/**
|
|
4915
|
+
* Enter keyboard event-move mode. Captures the working copy and a snapshot
|
|
4916
|
+
* of the resource (timeline). Visual feedback is provided by routing the
|
|
4917
|
+
* working start/end through the existing previewEvent state — the same
|
|
4918
|
+
* channel used for mouse drag — so the event renders at the projected
|
|
4919
|
+
* destination as the user nudges.
|
|
4920
|
+
*/
|
|
4921
|
+
enterEventMoveMode(event) {
|
|
4922
|
+
const resourceId = event.resourceId ?? null;
|
|
4923
|
+
this.keyboardMove = {
|
|
4924
|
+
eventId: event.id,
|
|
4925
|
+
originalStart: new Date(event.start),
|
|
4926
|
+
originalEnd: new Date(event.end),
|
|
4927
|
+
workingStart: new Date(event.start),
|
|
4928
|
+
workingEnd: new Date(event.end),
|
|
4929
|
+
workingResourceId: resourceId,
|
|
4930
|
+
};
|
|
4931
|
+
this.stateManager.setState({
|
|
4932
|
+
keyboardMoveEventId: event.id,
|
|
4933
|
+
previewEvent: {
|
|
4934
|
+
start: new Date(event.start),
|
|
4935
|
+
end: new Date(event.end),
|
|
4936
|
+
...(resourceId ? { resourceId } : {}),
|
|
4937
|
+
},
|
|
4938
|
+
});
|
|
4939
|
+
const minutes = this.minutesPerSlot();
|
|
4940
|
+
this.liveAnnouncer.announce(`Move mode for ${event.title}. Arrow keys nudge by ${minutes} minutes; Shift with arrow keys resizes the end edge; Alt with Shift resizes the start edge; Enter commits, Escape cancels.`);
|
|
4941
|
+
// setState above tore down and rebuilt the focused event element. Re-focus
|
|
4942
|
+
// the new node so subsequent arrow keystrokes still reach our keydown
|
|
4943
|
+
// listener (otherwise focus falls back to <body> and our listener is
|
|
4944
|
+
// bypassed).
|
|
4945
|
+
requestAnimationFrame(() => {
|
|
4946
|
+
const sel = `[data-event-id="${this.cssEscape(event.id)}"]`;
|
|
4947
|
+
const el = this.shadowRoot?.querySelector(sel);
|
|
4948
|
+
el?.focus({ preventScroll: true });
|
|
4949
|
+
});
|
|
4950
|
+
}
|
|
4951
|
+
/**
|
|
4952
|
+
* Move-mode keymap. Layered on the existing M-mode foundation:
|
|
4953
|
+
* - bare Arrow keys nudge the event
|
|
4954
|
+
* - Shift+Arrow resizes the end edge
|
|
4955
|
+
* - Alt+Shift+Arrow resizes the start edge
|
|
4956
|
+
* - on week view, Shift+ArrowLeft/Right pushes the end edge across the
|
|
4957
|
+
* day boundary (PRD D5) — symmetric with Shift+ArrowDown for time.
|
|
4958
|
+
* - Enter commits, Escape reverts.
|
|
4959
|
+
*/
|
|
4960
|
+
handleKeyboardMove(e) {
|
|
4961
|
+
if (!this.keyboardMove)
|
|
4962
|
+
return;
|
|
4963
|
+
if (e.key === 'Escape') {
|
|
4964
|
+
e.preventDefault();
|
|
4965
|
+
this.cancelEventMoveMode();
|
|
4966
|
+
return;
|
|
4967
|
+
}
|
|
4968
|
+
if (e.key === 'Enter') {
|
|
4969
|
+
e.preventDefault();
|
|
4970
|
+
this.commitEventMoveMode();
|
|
4971
|
+
return;
|
|
4972
|
+
}
|
|
4973
|
+
const view = this.stateManager.getState().view;
|
|
4974
|
+
const timelineLayout = view === 'timeline';
|
|
4975
|
+
const slotMs = this.minutesPerSlot() * 60 * 1000;
|
|
4976
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
4977
|
+
const shift = e.shiftKey;
|
|
4978
|
+
const alt = e.altKey;
|
|
4979
|
+
switch (e.key) {
|
|
4980
|
+
case 'ArrowUp':
|
|
4981
|
+
e.preventDefault();
|
|
4982
|
+
if (timelineLayout) {
|
|
4983
|
+
if (!shift && !alt)
|
|
4984
|
+
this.nudgeKeyboardMoveResource(-1);
|
|
4985
|
+
}
|
|
4986
|
+
else if (shift && alt) {
|
|
4987
|
+
this.resizeKeyboardMoveEdge('start', -slotMs);
|
|
4988
|
+
}
|
|
4989
|
+
else if (shift) {
|
|
4990
|
+
this.resizeKeyboardMoveEdge('end', -slotMs);
|
|
4991
|
+
}
|
|
4992
|
+
else {
|
|
4993
|
+
this.nudgeKeyboardMove(-slotMs);
|
|
4994
|
+
}
|
|
4995
|
+
return;
|
|
4996
|
+
case 'ArrowDown':
|
|
4997
|
+
e.preventDefault();
|
|
4998
|
+
if (timelineLayout) {
|
|
4999
|
+
if (!shift && !alt)
|
|
5000
|
+
this.nudgeKeyboardMoveResource(+1);
|
|
5001
|
+
}
|
|
5002
|
+
else if (shift && alt) {
|
|
5003
|
+
this.resizeKeyboardMoveEdge('start', +slotMs);
|
|
5004
|
+
}
|
|
5005
|
+
else if (shift) {
|
|
5006
|
+
this.resizeKeyboardMoveEdge('end', +slotMs);
|
|
5007
|
+
}
|
|
5008
|
+
else {
|
|
5009
|
+
this.nudgeKeyboardMove(+slotMs);
|
|
5010
|
+
}
|
|
5011
|
+
return;
|
|
5012
|
+
case 'ArrowLeft':
|
|
5013
|
+
e.preventDefault();
|
|
5014
|
+
if (timelineLayout) {
|
|
5015
|
+
if (shift && alt)
|
|
5016
|
+
this.resizeKeyboardMoveEdge('start', -slotMs);
|
|
5017
|
+
else if (shift)
|
|
5018
|
+
this.resizeKeyboardMoveEdge('end', -slotMs);
|
|
5019
|
+
else
|
|
5020
|
+
this.nudgeKeyboardMove(-slotMs);
|
|
5021
|
+
}
|
|
5022
|
+
else if (view === 'week') {
|
|
5023
|
+
// Week view (D5): Shift+Arrow on the column axis resizes the end edge
|
|
5024
|
+
// across the day boundary by 24h. Alt+Shift moves the start edge.
|
|
5025
|
+
if (shift && alt)
|
|
5026
|
+
this.resizeKeyboardMoveEdge('start', -dayMs);
|
|
5027
|
+
else if (shift)
|
|
5028
|
+
this.resizeKeyboardMoveEdge('end', -dayMs);
|
|
5029
|
+
else
|
|
5030
|
+
this.nudgeKeyboardMove(-dayMs);
|
|
5031
|
+
}
|
|
5032
|
+
return;
|
|
5033
|
+
case 'ArrowRight':
|
|
5034
|
+
e.preventDefault();
|
|
5035
|
+
if (timelineLayout) {
|
|
5036
|
+
if (shift && alt)
|
|
5037
|
+
this.resizeKeyboardMoveEdge('start', +slotMs);
|
|
5038
|
+
else if (shift)
|
|
5039
|
+
this.resizeKeyboardMoveEdge('end', +slotMs);
|
|
5040
|
+
else
|
|
5041
|
+
this.nudgeKeyboardMove(+slotMs);
|
|
5042
|
+
}
|
|
5043
|
+
else if (view === 'week') {
|
|
5044
|
+
if (shift && alt)
|
|
5045
|
+
this.resizeKeyboardMoveEdge('start', +dayMs);
|
|
5046
|
+
else if (shift)
|
|
5047
|
+
this.resizeKeyboardMoveEdge('end', +dayMs);
|
|
5048
|
+
else
|
|
5049
|
+
this.nudgeKeyboardMove(+dayMs);
|
|
5050
|
+
}
|
|
5051
|
+
return;
|
|
5052
|
+
}
|
|
5053
|
+
}
|
|
5054
|
+
minutesPerSlot() {
|
|
5055
|
+
const seconds = this.stateManager.getState().options.slotDuration ?? 1800;
|
|
5056
|
+
return Math.max(1, Math.round(seconds / 60));
|
|
5057
|
+
}
|
|
5058
|
+
/** Shift the working event by `deltaMs` along the time axis (preserves duration). */
|
|
5059
|
+
nudgeKeyboardMove(deltaMs) {
|
|
5060
|
+
if (!this.keyboardMove)
|
|
5061
|
+
return;
|
|
5062
|
+
const newStart = new Date(this.keyboardMove.workingStart.getTime() + deltaMs);
|
|
5063
|
+
const newEnd = new Date(this.keyboardMove.workingEnd.getTime() + deltaMs);
|
|
5064
|
+
this.keyboardMove.workingStart = newStart;
|
|
5065
|
+
this.keyboardMove.workingEnd = newEnd;
|
|
5066
|
+
this.applyKeyboardMovePreview();
|
|
5067
|
+
this.liveAnnouncer.announce(formatMoveAnnouncement(newStart, newEnd, this.stateManager.getState().options.timeFormat));
|
|
5068
|
+
}
|
|
5069
|
+
/** Walk to the next/previous resource (timeline only). Updates the preview's resourceId. */
|
|
5070
|
+
nudgeKeyboardMoveResource(direction) {
|
|
5071
|
+
if (!this.keyboardMove)
|
|
5072
|
+
return;
|
|
5073
|
+
const next = this.adjacentResource(this.keyboardMove.workingResourceId, direction, this.stateManager.getState());
|
|
5074
|
+
if (!next)
|
|
5075
|
+
return;
|
|
5076
|
+
this.keyboardMove.workingResourceId = next;
|
|
5077
|
+
this.applyKeyboardMovePreview();
|
|
5078
|
+
const title = this.getResourceTitle(next) ?? next;
|
|
5079
|
+
this.liveAnnouncer.announce(`Moved to resource ${title}.`);
|
|
5080
|
+
}
|
|
5081
|
+
/**
|
|
5082
|
+
* Resize one edge of the working event. Clamps to a minimum duration of
|
|
5083
|
+
* one slot to keep the event valid, and refuses to invert (start ≤ end).
|
|
5084
|
+
*/
|
|
5085
|
+
resizeKeyboardMoveEdge(edge, deltaMs) {
|
|
5086
|
+
if (!this.keyboardMove)
|
|
5087
|
+
return;
|
|
5088
|
+
const minDurationMs = this.minutesPerSlot() * 60 * 1000;
|
|
5089
|
+
let newStart = this.keyboardMove.workingStart;
|
|
5090
|
+
let newEnd = this.keyboardMove.workingEnd;
|
|
5091
|
+
if (edge === 'end') {
|
|
5092
|
+
newEnd = new Date(newEnd.getTime() + deltaMs);
|
|
5093
|
+
if (newEnd.getTime() - newStart.getTime() < minDurationMs)
|
|
5094
|
+
return;
|
|
5095
|
+
}
|
|
5096
|
+
else {
|
|
5097
|
+
newStart = new Date(newStart.getTime() + deltaMs);
|
|
5098
|
+
if (newEnd.getTime() - newStart.getTime() < minDurationMs)
|
|
5099
|
+
return;
|
|
5100
|
+
}
|
|
5101
|
+
this.keyboardMove.workingStart = newStart;
|
|
5102
|
+
this.keyboardMove.workingEnd = newEnd;
|
|
5103
|
+
this.applyKeyboardMovePreview();
|
|
5104
|
+
this.liveAnnouncer.announce(formatResizeAnnouncement(newStart, newEnd, edge, this.stateManager.getState().options.timeFormat));
|
|
5105
|
+
}
|
|
5106
|
+
/** Mirror keyboardMove.working* into state.previewEvent so views render the destination,
|
|
5107
|
+
* then scroll the destination into view so the sighted-keyboard user can see it (PRD D6). */
|
|
5108
|
+
applyKeyboardMovePreview() {
|
|
5109
|
+
if (!this.keyboardMove)
|
|
5110
|
+
return;
|
|
5111
|
+
const { eventId, workingStart, workingEnd, workingResourceId } = this.keyboardMove;
|
|
5112
|
+
this.stateManager.setState({
|
|
5113
|
+
previewEvent: {
|
|
5114
|
+
start: workingStart,
|
|
5115
|
+
end: workingEnd,
|
|
5116
|
+
...(workingResourceId ? { resourceId: workingResourceId } : {}),
|
|
5117
|
+
},
|
|
5118
|
+
});
|
|
5119
|
+
// Each move-mode update tears down + rebuilds event elements (renderEvents
|
|
5120
|
+
// is unconditional in week/day/timeline). Re-focus the event so subsequent
|
|
5121
|
+
// arrow keystrokes still reach our keydown listener instead of falling
|
|
5122
|
+
// through to <body>. Also scroll the preview cell into view.
|
|
5123
|
+
requestAnimationFrame(() => {
|
|
5124
|
+
const root = this.shadowRoot;
|
|
5125
|
+
if (!root)
|
|
5126
|
+
return;
|
|
5127
|
+
const eventEl = root.querySelector(`[data-event-id="${this.cssEscape(eventId)}"]`);
|
|
5128
|
+
eventEl?.focus({ preventScroll: true });
|
|
5129
|
+
const startIso = workingStart.toISOString();
|
|
5130
|
+
const sel = workingResourceId
|
|
5131
|
+
? `.scheduler-timeline-slot[data-resource-id="${this.cssEscape(workingResourceId)}"][data-start="${startIso}"]`
|
|
5132
|
+
: `.scheduler-time-slot[data-start="${startIso}"]`;
|
|
5133
|
+
const cellEl = root.querySelector(sel);
|
|
5134
|
+
if (cellEl && typeof cellEl.scrollIntoView === 'function') {
|
|
5135
|
+
cellEl.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
5136
|
+
}
|
|
5137
|
+
});
|
|
5138
|
+
}
|
|
5139
|
+
commitEventMoveMode() {
|
|
5140
|
+
if (!this.keyboardMove)
|
|
5141
|
+
return;
|
|
5142
|
+
const original = this.getEventById(this.keyboardMove.eventId);
|
|
5143
|
+
if (original) {
|
|
5144
|
+
const updated = {
|
|
5145
|
+
...original,
|
|
5146
|
+
start: this.keyboardMove.workingStart,
|
|
5147
|
+
end: this.keyboardMove.workingEnd,
|
|
5148
|
+
...(this.keyboardMove.workingResourceId
|
|
5149
|
+
? { resourceId: this.keyboardMove.workingResourceId }
|
|
5150
|
+
: {}),
|
|
5151
|
+
};
|
|
5152
|
+
this.stateManager.updateEvent(updated);
|
|
5153
|
+
this.eventEmitter.emitEventUpdate(updated, original, new CustomEvent('keyboard-move'));
|
|
5154
|
+
this.liveAnnouncer.announce('Move committed.');
|
|
5155
|
+
}
|
|
5156
|
+
this.keyboardMove = null;
|
|
5157
|
+
this.stateManager.setState({ keyboardMoveEventId: null, previewEvent: null });
|
|
5158
|
+
// Re-focus the moved event after re-render.
|
|
5159
|
+
requestAnimationFrame(() => {
|
|
5160
|
+
const sel = `[data-event-id="${this.cssEscape(original?.id ?? '')}"]`;
|
|
5161
|
+
const el = this.shadowRoot?.querySelector(sel);
|
|
5162
|
+
el?.focus({ preventScroll: false });
|
|
5163
|
+
});
|
|
5164
|
+
}
|
|
5165
|
+
cancelEventMoveMode() {
|
|
5166
|
+
const id = this.keyboardMove?.eventId ?? null;
|
|
5167
|
+
this.keyboardMove = null;
|
|
5168
|
+
this.stateManager.setState({ keyboardMoveEventId: null, previewEvent: null });
|
|
5169
|
+
this.liveAnnouncer.announce('Move cancelled.');
|
|
5170
|
+
if (id) {
|
|
5171
|
+
requestAnimationFrame(() => {
|
|
5172
|
+
const sel = `[data-event-id="${this.cssEscape(id)}"]`;
|
|
5173
|
+
const el = this.shadowRoot?.querySelector(sel);
|
|
5174
|
+
el?.focus({ preventScroll: false });
|
|
5175
|
+
});
|
|
3771
5176
|
}
|
|
3772
5177
|
}
|
|
3773
5178
|
// ============================================
|