@forcecalendar/interface 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forcecalendar/interface",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "Official interface layer for forceCalendar Core - Enterprise calendar components",
6
6
  "main": "dist/force-calendar-interface.umd.js",
@@ -397,6 +397,7 @@ export class ForceCalendar extends BaseComponent {
397
397
  }
398
398
 
399
399
  .fc-month-day:focus-visible,
400
+ .fc-hour-slot:focus-visible,
400
401
  .fc-event:focus-visible {
401
402
  outline: 2px solid var(--fc-primary-color);
402
403
  outline-offset: -2px;
@@ -329,6 +329,146 @@ export class BaseViewRenderer {
329
329
  }
330
330
  }
331
331
 
332
+ /**
333
+ * WAI-ARIA semantics and keyboard navigation for time grids (week/day
334
+ * views). The DOM is column-major (a column per day, an hour line per
335
+ * slot), so each day column is exposed as a row of 24 hour gridcells.
336
+ * Arrow keys navigate visually: Up/Down moves hours, Left/Right moves
337
+ * days, Home/End jumps to the day's bounds, PageUp/PageDown navigates
338
+ * periods, Enter/Space selects the slot's date and time.
339
+ * @param {string} columnSelector - Selector for the day columns
340
+ * @param {string} gridLabel - Accessible label for the grid
341
+ */
342
+ _enhanceTimeGridAccessibility(columnSelector, gridLabel) {
343
+ const grid = this.container.querySelector('.fc-time-grid');
344
+ if (!grid) return;
345
+ grid.setAttribute('role', 'grid');
346
+ grid.setAttribute('aria-label', gridLabel);
347
+ const gutter = this.container.querySelector('.fc-time-gutter');
348
+ if (gutter) gutter.setAttribute('aria-hidden', 'true');
349
+
350
+ const locale = this.stateManager.getState().config.locale || 'en-US';
351
+ const dayFormatter = new Intl.DateTimeFormat(locale, {
352
+ weekday: 'long',
353
+ month: 'long',
354
+ day: 'numeric'
355
+ });
356
+
357
+ const columns = Array.from(this.container.querySelectorAll(columnSelector));
358
+ for (const col of columns) {
359
+ col.setAttribute('role', 'row');
360
+ const colDate = new Date(col.dataset.date);
361
+ const colLabel = dayFormatter.format(colDate);
362
+ col.setAttribute('aria-label', colLabel);
363
+ for (const slot of col.querySelectorAll('.fc-hour-slot')) {
364
+ slot.setAttribute('role', 'gridcell');
365
+ slot.setAttribute('tabindex', '-1');
366
+ slot.setAttribute(
367
+ 'aria-label',
368
+ `${colLabel}, ${this.formatHour(Number(slot.dataset.hour))}`
369
+ );
370
+ }
371
+ }
372
+
373
+ this._applySlotRovingTabindex(columns);
374
+
375
+ if (this._pendingSlotFocus) {
376
+ const { colIndex, hour } = this._pendingSlotFocus;
377
+ this._pendingSlotFocus = null;
378
+ const target = this._slotAt(columns, colIndex, hour);
379
+ if (target) {
380
+ this._applySlotRovingTabindex(columns, target);
381
+ target.focus();
382
+ }
383
+ }
384
+
385
+ this.addListener(this.container, 'keydown', e => {
386
+ const slot = e.target.closest('.fc-hour-slot');
387
+ if (!slot || !this.container.contains(slot)) return;
388
+ const col = slot.closest(columnSelector);
389
+ const colIndex = columns.indexOf(col);
390
+ const hour = Number(slot.dataset.hour);
391
+ let target = null;
392
+
393
+ switch (e.key) {
394
+ case 'ArrowDown':
395
+ target = this._slotAt(columns, colIndex, hour + 1);
396
+ break;
397
+ case 'ArrowUp':
398
+ target = this._slotAt(columns, colIndex, hour - 1);
399
+ break;
400
+ case 'ArrowRight':
401
+ target = this._slotAt(columns, colIndex + 1, hour);
402
+ break;
403
+ case 'ArrowLeft':
404
+ target = this._slotAt(columns, colIndex - 1, hour);
405
+ break;
406
+ case 'Home':
407
+ target = this._slotAt(columns, colIndex, 0);
408
+ break;
409
+ case 'End':
410
+ target = this._slotAt(columns, colIndex, 23);
411
+ break;
412
+ case 'PageUp':
413
+ case 'PageDown':
414
+ e.preventDefault();
415
+ this._pendingSlotFocus = { colIndex, hour };
416
+ if (e.key === 'PageDown') {
417
+ this.stateManager.next();
418
+ } else {
419
+ this.stateManager.previous();
420
+ }
421
+ return;
422
+ case 'Enter':
423
+ case ' ': {
424
+ e.preventDefault();
425
+ const date = new Date(col.dataset.date);
426
+ date.setHours(hour, 0, 0, 0);
427
+ this._pendingSlotFocus = { colIndex, hour };
428
+ this.stateManager.selectDate(date);
429
+ return;
430
+ }
431
+ default:
432
+ return;
433
+ }
434
+
435
+ e.preventDefault();
436
+ if (target) {
437
+ this._applySlotRovingTabindex(columns, target);
438
+ target.scrollIntoView?.({ block: 'nearest' });
439
+ target.focus();
440
+ }
441
+ });
442
+ }
443
+
444
+ /**
445
+ * Get the hour slot at a column/hour position
446
+ * @private
447
+ */
448
+ _slotAt(columns, colIndex, hour) {
449
+ if (colIndex < 0 || colIndex >= columns.length || hour < 0 || hour > 23) return null;
450
+ return columns[colIndex].querySelector(`.fc-hour-slot[data-hour="${hour}"]`);
451
+ }
452
+
453
+ /**
454
+ * Keep exactly one hour slot tabbable. Defaults to 9:00 AM in the
455
+ * first column (today's column when present).
456
+ * @private
457
+ */
458
+ _applySlotRovingTabindex(columns, active = null) {
459
+ const slots = this.container.querySelectorAll('.fc-hour-slot');
460
+ if (slots.length === 0) return;
461
+ if (!active) {
462
+ const todayCol =
463
+ columns.find(c => this.isToday(new Date(c.dataset.date))) || columns[0];
464
+ active = todayCol && todayCol.querySelector('.fc-hour-slot[data-hour="9"]');
465
+ active = active || slots[0];
466
+ }
467
+ for (const s of slots) {
468
+ s.setAttribute('tabindex', s === active ? '0' : '-1');
469
+ }
470
+ }
471
+
332
472
  /**
333
473
  * Make rendered events keyboard-reachable and screen-reader labeled.
334
474
  * Runs after every render, across all views.
@@ -165,7 +165,7 @@ export class DayViewRenderer extends BaseViewRenderer {
165
165
  return `
166
166
  <div class="fc-day-column" data-date="${dayDate.toISOString()}" style="position: relative; cursor: pointer;">
167
167
  <!-- Hour grid lines -->
168
- ${hours.map(() => `<div style="height: ${this.hourHeight}px; border-bottom: 1px solid var(--fc-background-hover);"></div>`).join('')}
168
+ ${hours.map(h => `<div class="fc-hour-slot" data-hour="${h}" style="height: ${this.hourHeight}px; border-bottom: 1px solid var(--fc-background-hover);"></div>`).join('')}
169
169
 
170
170
  <!-- Now indicator for today -->
171
171
  ${isToday ? this.renderNowIndicator() : ''}
@@ -211,6 +211,11 @@ export class DayViewRenderer extends BaseViewRenderer {
211
211
 
212
212
  // Common event handlers (event clicks)
213
213
  this.attachCommonEventHandlers();
214
+
215
+ const locale = this.stateManager.getState().config.locale || 'en-US';
216
+ const current = this.stateManager.getState().currentDate || new Date();
217
+ const label = new Intl.DateTimeFormat(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }).format(current);
218
+ this._enhanceTimeGridAccessibility('.fc-day-column', label);
214
219
  }
215
220
 
216
221
  _scrollToCurrentTime() {
@@ -144,7 +144,7 @@ export class WeekViewRenderer extends BaseViewRenderer {
144
144
  return `
145
145
  <div class="fc-week-day-column" data-date="${day.date.toISOString()}" style="border-right: 1px solid var(--fc-border-color); position: relative; cursor: pointer;">
146
146
  <!-- Hour grid lines -->
147
- ${hours.map(() => `<div style="height: ${this.hourHeight}px; border-bottom: 1px solid var(--fc-background-hover);"></div>`).join('')}
147
+ ${hours.map(h => `<div class="fc-hour-slot" data-hour="${h}" style="height: ${this.hourHeight}px; border-bottom: 1px solid var(--fc-background-hover);"></div>`).join('')}
148
148
 
149
149
  <!-- Now indicator for today -->
150
150
  ${day.isToday ? this.renderNowIndicator() : ''}
@@ -190,6 +190,12 @@ export class WeekViewRenderer extends BaseViewRenderer {
190
190
 
191
191
  // Common event handlers (event clicks)
192
192
  this.attachCommonEventHandlers();
193
+
194
+ const days = this.stateManager.getViewData()?.days || [];
195
+ const locale = this.stateManager.getState().config.locale || 'en-US';
196
+ const first = days[0] ? new Date(days[0].date) : new Date();
197
+ const label = `Week of ${new Intl.DateTimeFormat(locale, { month: 'long', day: 'numeric', year: 'numeric' }).format(first)}`;
198
+ this._enhanceTimeGridAccessibility('.fc-week-day-column', label);
193
199
  }
194
200
 
195
201
  _scrollToCurrentTime() {
@@ -10,6 +10,13 @@ export declare class BaseViewRenderer {
10
10
  _listeners: any[];
11
11
  _scrolled: boolean;
12
12
  _nowIndicatorTimer: number | null;
13
+ _pendingSlotFocus: {
14
+ colIndex: number;
15
+ hour: number;
16
+ } | {
17
+ colIndex: number;
18
+ hour: number;
19
+ } | null | undefined;
13
20
  /**
14
21
  * @param {HTMLElement} container - The DOM element to render into
15
22
  * @param {StateManager} stateManager - The state manager instance
@@ -113,6 +120,28 @@ export declare class BaseViewRenderer {
113
120
  */
114
121
  attachCommonEventHandlers(): void;
115
122
  _selectEventFromElement(eventEl: any): void;
123
+ /**
124
+ * WAI-ARIA semantics and keyboard navigation for time grids (week/day
125
+ * views). The DOM is column-major (a column per day, an hour line per
126
+ * slot), so each day column is exposed as a row of 24 hour gridcells.
127
+ * Arrow keys navigate visually: Up/Down moves hours, Left/Right moves
128
+ * days, Home/End jumps to the day's bounds, PageUp/PageDown navigates
129
+ * periods, Enter/Space selects the slot's date and time.
130
+ * @param {string} columnSelector - Selector for the day columns
131
+ * @param {string} gridLabel - Accessible label for the grid
132
+ */
133
+ _enhanceTimeGridAccessibility(columnSelector: string, gridLabel: string): void;
134
+ /**
135
+ * Get the hour slot at a column/hour position
136
+ * @private
137
+ */
138
+ private _slotAt;
139
+ /**
140
+ * Keep exactly one hour slot tabbable. Defaults to 9:00 AM in the
141
+ * first column (today's column when present).
142
+ * @private
143
+ */
144
+ private _applySlotRovingTabindex;
116
145
  /**
117
146
  * Make rendered events keyboard-reachable and screen-reader labeled.
118
147
  * Runs after every render, across all views.