@mintplayer/ng-bootstrap 21.29.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.
Files changed (156) hide show
  1. package/fesm2022/mintplayer-ng-bootstrap-a11y.mjs +455 -0
  2. package/fesm2022/mintplayer-ng-bootstrap-a11y.mjs.map +1 -0
  3. package/fesm2022/mintplayer-ng-bootstrap-accordion.mjs +8 -5
  4. package/fesm2022/mintplayer-ng-bootstrap-accordion.mjs.map +1 -1
  5. package/fesm2022/mintplayer-ng-bootstrap-breadcrumb.mjs +10 -4
  6. package/fesm2022/mintplayer-ng-bootstrap-breadcrumb.mjs.map +1 -1
  7. package/fesm2022/mintplayer-ng-bootstrap-button-group.mjs +7 -4
  8. package/fesm2022/mintplayer-ng-bootstrap-button-group.mjs.map +1 -1
  9. package/fesm2022/mintplayer-ng-bootstrap-calendar.mjs +131 -3
  10. package/fesm2022/mintplayer-ng-bootstrap-calendar.mjs.map +1 -1
  11. package/fesm2022/mintplayer-ng-bootstrap-carousel.mjs +80 -48
  12. package/fesm2022/mintplayer-ng-bootstrap-carousel.mjs.map +1 -1
  13. package/fesm2022/mintplayer-ng-bootstrap-code-snippet.mjs +4 -1
  14. package/fesm2022/mintplayer-ng-bootstrap-code-snippet.mjs.map +1 -1
  15. package/fesm2022/mintplayer-ng-bootstrap-color-picker.mjs +218 -14
  16. package/fesm2022/mintplayer-ng-bootstrap-color-picker.mjs.map +1 -1
  17. package/fesm2022/mintplayer-ng-bootstrap-datatable.mjs +4 -3
  18. package/fesm2022/mintplayer-ng-bootstrap-datatable.mjs.map +1 -1
  19. package/fesm2022/mintplayer-ng-bootstrap-datepicker.mjs +2 -2
  20. package/fesm2022/mintplayer-ng-bootstrap-datepicker.mjs.map +1 -1
  21. package/fesm2022/mintplayer-ng-bootstrap-dock.mjs +294 -3
  22. package/fesm2022/mintplayer-ng-bootstrap-dock.mjs.map +1 -1
  23. package/fesm2022/mintplayer-ng-bootstrap-dropdown-menu.mjs +163 -18
  24. package/fesm2022/mintplayer-ng-bootstrap-dropdown-menu.mjs.map +1 -1
  25. package/fesm2022/mintplayer-ng-bootstrap-dropdown.mjs +179 -7
  26. package/fesm2022/mintplayer-ng-bootstrap-dropdown.mjs.map +1 -1
  27. package/fesm2022/mintplayer-ng-bootstrap-file-upload.mjs +14 -4
  28. package/fesm2022/mintplayer-ng-bootstrap-file-upload.mjs.map +1 -1
  29. package/fesm2022/mintplayer-ng-bootstrap-has-overlay.mjs +14 -0
  30. package/fesm2022/mintplayer-ng-bootstrap-has-overlay.mjs.map +1 -1
  31. package/fesm2022/mintplayer-ng-bootstrap-list-group.mjs +2 -1
  32. package/fesm2022/mintplayer-ng-bootstrap-list-group.mjs.map +1 -1
  33. package/fesm2022/mintplayer-ng-bootstrap-marquee.mjs +7 -4
  34. package/fesm2022/mintplayer-ng-bootstrap-marquee.mjs.map +1 -1
  35. package/fesm2022/mintplayer-ng-bootstrap-modal.mjs +70 -6
  36. package/fesm2022/mintplayer-ng-bootstrap-modal.mjs.map +1 -1
  37. package/fesm2022/mintplayer-ng-bootstrap-multi-range.mjs +693 -0
  38. package/fesm2022/mintplayer-ng-bootstrap-multi-range.mjs.map +1 -0
  39. package/fesm2022/mintplayer-ng-bootstrap-multiselect.mjs +5 -4
  40. package/fesm2022/mintplayer-ng-bootstrap-multiselect.mjs.map +1 -1
  41. package/fesm2022/mintplayer-ng-bootstrap-navbar-toggler.mjs +6 -6
  42. package/fesm2022/mintplayer-ng-bootstrap-navbar-toggler.mjs.map +1 -1
  43. package/fesm2022/mintplayer-ng-bootstrap-navbar.mjs +45 -13
  44. package/fesm2022/mintplayer-ng-bootstrap-navbar.mjs.map +1 -1
  45. package/fesm2022/mintplayer-ng-bootstrap-offcanvas.mjs +51 -5
  46. package/fesm2022/mintplayer-ng-bootstrap-offcanvas.mjs.map +1 -1
  47. package/fesm2022/mintplayer-ng-bootstrap-pagination.mjs +5 -3
  48. package/fesm2022/mintplayer-ng-bootstrap-pagination.mjs.map +1 -1
  49. package/fesm2022/mintplayer-ng-bootstrap-placeholder.mjs +18 -4
  50. package/fesm2022/mintplayer-ng-bootstrap-placeholder.mjs.map +1 -1
  51. package/fesm2022/mintplayer-ng-bootstrap-playlist-toggler.mjs +6 -6
  52. package/fesm2022/mintplayer-ng-bootstrap-playlist-toggler.mjs.map +1 -1
  53. package/fesm2022/mintplayer-ng-bootstrap-popover.mjs +61 -6
  54. package/fesm2022/mintplayer-ng-bootstrap-popover.mjs.map +1 -1
  55. package/fesm2022/mintplayer-ng-bootstrap-priority-nav.mjs +19 -4
  56. package/fesm2022/mintplayer-ng-bootstrap-priority-nav.mjs.map +1 -1
  57. package/fesm2022/mintplayer-ng-bootstrap-progress-bar.mjs +8 -5
  58. package/fesm2022/mintplayer-ng-bootstrap-progress-bar.mjs.map +1 -1
  59. package/fesm2022/mintplayer-ng-bootstrap-range.mjs +4 -3
  60. package/fesm2022/mintplayer-ng-bootstrap-range.mjs.map +1 -1
  61. package/fesm2022/mintplayer-ng-bootstrap-rating.mjs +34 -4
  62. package/fesm2022/mintplayer-ng-bootstrap-rating.mjs.map +1 -1
  63. package/fesm2022/mintplayer-ng-bootstrap-reduced-motion.mjs +59 -0
  64. package/fesm2022/mintplayer-ng-bootstrap-reduced-motion.mjs.map +1 -0
  65. package/fesm2022/mintplayer-ng-bootstrap-resizable.mjs +91 -2
  66. package/fesm2022/mintplayer-ng-bootstrap-resizable.mjs.map +1 -1
  67. package/fesm2022/mintplayer-ng-bootstrap-scheduler.mjs +16 -5
  68. package/fesm2022/mintplayer-ng-bootstrap-scheduler.mjs.map +1 -1
  69. package/fesm2022/mintplayer-ng-bootstrap-scrollspy.mjs +2 -2
  70. package/fesm2022/mintplayer-ng-bootstrap-scrollspy.mjs.map +1 -1
  71. package/fesm2022/mintplayer-ng-bootstrap-searchbox.mjs +28 -5
  72. package/fesm2022/mintplayer-ng-bootstrap-searchbox.mjs.map +1 -1
  73. package/fesm2022/mintplayer-ng-bootstrap-select.mjs +4 -3
  74. package/fesm2022/mintplayer-ng-bootstrap-select.mjs.map +1 -1
  75. package/fesm2022/mintplayer-ng-bootstrap-select2.mjs +18 -4
  76. package/fesm2022/mintplayer-ng-bootstrap-select2.mjs.map +1 -1
  77. package/fesm2022/mintplayer-ng-bootstrap-signature-pad.mjs +4 -3
  78. package/fesm2022/mintplayer-ng-bootstrap-signature-pad.mjs.map +1 -1
  79. package/fesm2022/mintplayer-ng-bootstrap-tab-control.mjs +2 -2
  80. package/fesm2022/mintplayer-ng-bootstrap-tab-control.mjs.map +1 -1
  81. package/fesm2022/mintplayer-ng-bootstrap-table.mjs +10 -3
  82. package/fesm2022/mintplayer-ng-bootstrap-table.mjs.map +1 -1
  83. package/fesm2022/mintplayer-ng-bootstrap-tile-manager.mjs +143 -29
  84. package/fesm2022/mintplayer-ng-bootstrap-tile-manager.mjs.map +1 -1
  85. package/fesm2022/mintplayer-ng-bootstrap-timepicker.mjs +2 -2
  86. package/fesm2022/mintplayer-ng-bootstrap-timepicker.mjs.map +1 -1
  87. package/fesm2022/mintplayer-ng-bootstrap-toast.mjs +7 -4
  88. package/fesm2022/mintplayer-ng-bootstrap-toast.mjs.map +1 -1
  89. package/fesm2022/mintplayer-ng-bootstrap-toggle-button.mjs +42 -21
  90. package/fesm2022/mintplayer-ng-bootstrap-toggle-button.mjs.map +1 -1
  91. package/fesm2022/mintplayer-ng-bootstrap-tooltip.mjs +33 -4
  92. package/fesm2022/mintplayer-ng-bootstrap-tooltip.mjs.map +1 -1
  93. package/fesm2022/mintplayer-ng-bootstrap-treeview.mjs +17 -7
  94. package/fesm2022/mintplayer-ng-bootstrap-treeview.mjs.map +1 -1
  95. package/fesm2022/mintplayer-ng-bootstrap-typeahead.mjs +50 -8
  96. package/fesm2022/mintplayer-ng-bootstrap-typeahead.mjs.map +1 -1
  97. package/fesm2022/mintplayer-ng-bootstrap-virtual-datatable.mjs +34 -12
  98. package/fesm2022/mintplayer-ng-bootstrap-virtual-datatable.mjs.map +1 -1
  99. package/fesm2022/mintplayer-ng-bootstrap-web-components-a11y.mjs +74 -0
  100. package/fesm2022/mintplayer-ng-bootstrap-web-components-a11y.mjs.map +1 -0
  101. package/fesm2022/mintplayer-ng-bootstrap-web-components-scheduler.mjs +1476 -71
  102. package/fesm2022/mintplayer-ng-bootstrap-web-components-scheduler.mjs.map +1 -1
  103. package/fesm2022/mintplayer-ng-bootstrap-web-components-splitter.mjs +194 -2
  104. package/fesm2022/mintplayer-ng-bootstrap-web-components-splitter.mjs.map +1 -1
  105. package/fesm2022/mintplayer-ng-bootstrap-web-components-tab-control.mjs +4 -0
  106. package/fesm2022/mintplayer-ng-bootstrap-web-components-tab-control.mjs.map +1 -1
  107. package/package.json +18 -2
  108. package/types/mintplayer-ng-bootstrap-a11y.d.ts +196 -0
  109. package/types/mintplayer-ng-bootstrap-accordion.d.ts +4 -2
  110. package/types/mintplayer-ng-bootstrap-breadcrumb.d.ts +2 -1
  111. package/types/mintplayer-ng-bootstrap-button-group.d.ts +2 -1
  112. package/types/mintplayer-ng-bootstrap-calendar.d.ts +32 -0
  113. package/types/mintplayer-ng-bootstrap-carousel.d.ts +56 -3
  114. package/types/mintplayer-ng-bootstrap-code-snippet.d.ts +1 -0
  115. package/types/mintplayer-ng-bootstrap-color-picker.d.ts +75 -4
  116. package/types/mintplayer-ng-bootstrap-datatable.d.ts +1 -1
  117. package/types/mintplayer-ng-bootstrap-dock.d.ts +51 -0
  118. package/types/mintplayer-ng-bootstrap-dropdown-menu.d.ts +54 -9
  119. package/types/mintplayer-ng-bootstrap-dropdown.d.ts +57 -2
  120. package/types/mintplayer-ng-bootstrap-file-upload.d.ts +4 -1
  121. package/types/mintplayer-ng-bootstrap-has-overlay.d.ts +14 -0
  122. package/types/mintplayer-ng-bootstrap-marquee.d.ts +2 -1
  123. package/types/mintplayer-ng-bootstrap-modal.d.ts +25 -1
  124. package/types/mintplayer-ng-bootstrap-multi-range.d.ts +170 -0
  125. package/types/mintplayer-ng-bootstrap-multiselect.d.ts +2 -1
  126. package/types/mintplayer-ng-bootstrap-navbar-toggler.d.ts +4 -2
  127. package/types/mintplayer-ng-bootstrap-navbar.d.ts +25 -1
  128. package/types/mintplayer-ng-bootstrap-offcanvas.d.ts +23 -1
  129. package/types/mintplayer-ng-bootstrap-pagination.d.ts +3 -1
  130. package/types/mintplayer-ng-bootstrap-placeholder.d.ts +5 -1
  131. package/types/mintplayer-ng-bootstrap-playlist-toggler.d.ts +4 -2
  132. package/types/mintplayer-ng-bootstrap-popover.d.ts +21 -1
  133. package/types/mintplayer-ng-bootstrap-priority-nav.d.ts +4 -1
  134. package/types/mintplayer-ng-bootstrap-progress-bar.d.ts +4 -2
  135. package/types/mintplayer-ng-bootstrap-range.d.ts +2 -1
  136. package/types/mintplayer-ng-bootstrap-rating.d.ts +3 -0
  137. package/types/mintplayer-ng-bootstrap-reduced-motion.d.ts +36 -0
  138. package/types/mintplayer-ng-bootstrap-resizable.d.ts +4 -0
  139. package/types/mintplayer-ng-bootstrap-scheduler.d.ts +42 -9
  140. package/types/mintplayer-ng-bootstrap-scrollspy.d.ts +1 -1
  141. package/types/mintplayer-ng-bootstrap-searchbox.d.ts +8 -1
  142. package/types/mintplayer-ng-bootstrap-select.d.ts +2 -1
  143. package/types/mintplayer-ng-bootstrap-select2.d.ts +3 -0
  144. package/types/mintplayer-ng-bootstrap-signature-pad.d.ts +2 -1
  145. package/types/mintplayer-ng-bootstrap-table.d.ts +8 -1
  146. package/types/mintplayer-ng-bootstrap-tile-manager.d.ts +21 -2
  147. package/types/mintplayer-ng-bootstrap-toast.d.ts +6 -1
  148. package/types/mintplayer-ng-bootstrap-toggle-button.d.ts +11 -0
  149. package/types/mintplayer-ng-bootstrap-tooltip.d.ts +5 -0
  150. package/types/mintplayer-ng-bootstrap-treeview.d.ts +12 -1
  151. package/types/mintplayer-ng-bootstrap-typeahead.d.ts +11 -3
  152. package/types/mintplayer-ng-bootstrap-virtual-datatable.d.ts +14 -1
  153. package/types/mintplayer-ng-bootstrap-web-components-a11y.d.ts +34 -0
  154. package/types/mintplayer-ng-bootstrap-web-components-scheduler-core.d.ts +35 -11
  155. package/types/mintplayer-ng-bootstrap-web-components-scheduler.d.ts +246 -0
  156. 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
- // Year view is mostly static, re-render fully
378
- this.render();
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 = current.toISOString().split('T')[0];
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
- // Mark as selected if this is the selected event
678
- if (this.state.selectedEvent?.id === event.id) {
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
- // Mark as selected if this is the selected event
959
- if (this.state.selectedEvent?.id === event.id) {
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
- // Create timeline structure
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
- // Header
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
- // Resource column header
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
- const toggle = this.createElement('span', 'expand-toggle');
1200
- toggle.textContent = this.state.collapsedGroups.has(flat.item.id) ? '▶' : '▼';
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
- // Mark as selected if this is the selected event
1280
- if (this.state.selectedEvent?.id === event.id) {
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-click 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
- emitEventClick(event, originalEvent) {
3174
- this.emit({ type: 'event-click', event, originalEvent });
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 event.
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(event, originalEvent) {
3186
- this.emit({ type: 'event-create', event, originalEvent });
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
- if (key === this.view) {
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 selectionChanged = this.previousSelectedEventId !== null &&
3580
- this.previousSelectedEventId !== selectedEventId;
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
- btnEl.classList.toggle('active', btnEl.dataset['view'] === state.view);
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.emitEventClick(result.event, originalEvent);
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
- const newEvent = {
3654
- id: generateEventId(),
3655
- title: 'New Event',
3656
- start: result.preview.start,
3657
- end: result.preview.end,
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
- const state = this.stateManager.getState();
3726
- switch (e.key) {
3727
- case 'ArrowLeft':
3728
- this.prev();
3729
- e.preventDefault();
3730
- break;
3731
- case 'ArrowRight':
3732
- this.next();
3733
- e.preventDefault();
3734
- break;
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
- break;
4326
+ return true;
3740
4327
  case 'y':
3741
- case 'Y':
3742
4328
  this.changeView('year');
3743
4329
  e.preventDefault();
3744
- break;
4330
+ return true;
3745
4331
  case 'm':
3746
- case 'M':
3747
4332
  this.changeView('month');
3748
4333
  e.preventDefault();
3749
- break;
4334
+ return true;
3750
4335
  case 'w':
3751
- case 'W':
3752
4336
  this.changeView('week');
3753
4337
  e.preventDefault();
3754
- break;
4338
+ return true;
3755
4339
  case 'd':
3756
- case 'D':
3757
4340
  this.changeView('day');
3758
4341
  e.preventDefault();
3759
- break;
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
- if (state.selectedEvent) {
3763
- this.eventEmitter.emitEventDelete(state.selectedEvent);
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
- if (this.dragManager.isDragging()) {
3768
- this.dragManager.cancel();
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
  // ============================================