@forcecalendar/interface 1.0.60 → 1.1.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.0.60",
3
+ "version": "1.1.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",
@@ -60,6 +60,6 @@
60
60
  "jest": "^30.2.0",
61
61
  "jest-environment-jsdom": "^30.2.0",
62
62
  "prettier": "^3.8.1",
63
- "vite": "^5.0.0"
63
+ "vite": "^8.1.4"
64
64
  }
65
65
  }
@@ -37,6 +37,38 @@ export class ForceCalendar extends BaseComponent {
37
37
  this._busUnsubscribers = [];
38
38
  }
39
39
 
40
+ /**
41
+ * Route observed-attribute changes into the StateManager.
42
+ * Without this, attribute updates after mount (view, locale, timezone,
43
+ * week-starts-on, date) only re-rendered from stale state and had no
44
+ * visible effect. 'height' is presentational and picked up by render().
45
+ */
46
+ propChanged(name, oldValue, newValue) {
47
+ if (!this.stateManager || oldValue === newValue) return;
48
+
49
+ switch (name) {
50
+ case 'view':
51
+ if (newValue) this.stateManager.setView(newValue);
52
+ break;
53
+ case 'date': {
54
+ const date = newValue ? new Date(newValue) : null;
55
+ if (date && !isNaN(date.getTime())) this.stateManager.setDate(date);
56
+ break;
57
+ }
58
+ case 'locale':
59
+ if (newValue) this.stateManager.updateConfig({ locale: newValue });
60
+ break;
61
+ case 'timezone':
62
+ if (newValue) this.stateManager.updateConfig({ timeZone: newValue });
63
+ break;
64
+ case 'week-starts-on': {
65
+ const day = parseInt(newValue, 10);
66
+ if (!Number.isNaN(day)) this.stateManager.updateConfig({ weekStartsOn: day });
67
+ break;
68
+ }
69
+ }
70
+ }
71
+
40
72
  initialize() {
41
73
  // Initialize state manager with config from attributes
42
74
  const config = {
@@ -364,6 +396,20 @@ export class ForceCalendar extends BaseComponent {
364
396
  padding: 0;
365
397
  }
366
398
 
399
+ .fc-month-day:focus-visible,
400
+ .fc-event:focus-visible {
401
+ outline: 2px solid var(--fc-primary-color);
402
+ outline-offset: -2px;
403
+ }
404
+
405
+ .fc-nav-arrow:focus-visible,
406
+ .fc-btn:focus-visible,
407
+ .fc-btn-today:focus-visible,
408
+ .fc-view-button:focus-visible {
409
+ outline: 2px solid var(--fc-primary-color);
410
+ outline-offset: 2px;
411
+ }
412
+
367
413
  .fc-nav-arrow:hover {
368
414
  background: var(--fc-background-hover);
369
415
  color: var(--fc-text-color);
@@ -673,11 +719,11 @@ export class ForceCalendar extends BaseComponent {
673
719
  </div>
674
720
 
675
721
  <div class="fc-header-center">
676
- <button class="fc-nav-arrow" data-action="previous" title="Previous">
722
+ <button class="fc-nav-arrow" data-action="previous" title="Previous" aria-label="Previous ${view}">
677
723
  ${this.getIcon('chevron-left')}
678
724
  </button>
679
- <h2 class="fc-title">${DOMUtils.escapeHTML(title)}</h2>
680
- <button class="fc-nav-arrow" data-action="next" title="Next">
725
+ <h2 class="fc-title" aria-live="polite">${DOMUtils.escapeHTML(title)}</h2>
726
+ <button class="fc-nav-arrow" data-action="next" title="Next" aria-label="Next ${view}">
681
727
  ${this.getIcon('chevron-right')}
682
728
  </button>
683
729
  </div>
@@ -686,12 +732,15 @@ export class ForceCalendar extends BaseComponent {
686
732
  <button class="fc-btn fc-btn-primary" id="create-event-btn" style="height: 28px; padding: 0 12px; font-size: 12px;">
687
733
  + New Event
688
734
  </button>
689
- <div class="fc-view-buttons" role="group">
735
+ <div class="fc-view-buttons" role="group" aria-label="Calendar view">
690
736
  <button class="fc-view-button ${view === 'month' ? 'active' : ''}"
737
+ aria-pressed="${view === 'month' ? 'true' : 'false'}"
691
738
  data-view="month">Month</button>
692
739
  <button class="fc-view-button ${view === 'week' ? 'active' : ''}"
740
+ aria-pressed="${view === 'week' ? 'true' : 'false'}"
693
741
  data-view="week">Week</button>
694
742
  <button class="fc-view-button ${view === 'day' ? 'active' : ''}"
743
+ aria-pressed="${view === 'day' ? 'true' : 'false'}"
695
744
  data-view="day">Day</button>
696
745
  </div>
697
746
  </div>
@@ -183,8 +183,13 @@ export class BaseComponent extends HTMLElement {
183
183
  */
184
184
  _restoreScrollPositions(positions) {
185
185
  if (!this._contentWrapper || positions.size === 0) return;
186
+ // CSS.escape may be absent in sandboxed or test environments
187
+ const escapeId =
188
+ typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
189
+ ? CSS.escape
190
+ : id => String(id).replace(/["\\]/g, '\\$&');
186
191
  positions.forEach((pos, id) => {
187
- const el = this._contentWrapper.querySelector(`[id="${CSS.escape(id)}"]`);
192
+ const el = this._contentWrapper.querySelector(`[id="${escapeId(id)}"]`);
188
193
  if (el) {
189
194
  el.scrollTop = pos.top;
190
195
  el.scrollLeft = pos.left;
@@ -304,12 +304,46 @@ export class BaseViewRenderer {
304
304
  if (!eventEl || !this.container.contains(eventEl)) return;
305
305
 
306
306
  e.stopPropagation();
307
- const eventId = eventEl.dataset.eventId;
308
- const event = this.stateManager.getEvents().find(ev => ev.id === eventId);
309
- if (event) {
310
- this.stateManager.selectEvent(event);
311
- }
307
+ this._selectEventFromElement(eventEl);
312
308
  });
309
+
310
+ // Keyboard activation for events (Enter / Space)
311
+ this.addListener(this.container, 'keydown', e => {
312
+ if (e.key !== 'Enter' && e.key !== ' ') return;
313
+ const eventEl = e.target.closest('.fc-event');
314
+ if (!eventEl || !this.container.contains(eventEl)) return;
315
+
316
+ e.preventDefault();
317
+ e.stopPropagation();
318
+ this._selectEventFromElement(eventEl);
319
+ });
320
+
321
+ this._enhanceEventAccessibility();
322
+ }
323
+
324
+ _selectEventFromElement(eventEl) {
325
+ const eventId = eventEl.dataset.eventId;
326
+ const event = this.stateManager.getEvents().find(ev => ev.id === eventId);
327
+ if (event) {
328
+ this.stateManager.selectEvent(event);
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Make rendered events keyboard-reachable and screen-reader labeled.
334
+ * Runs after every render, across all views.
335
+ */
336
+ _enhanceEventAccessibility() {
337
+ for (const el of this.container.querySelectorAll('.fc-event')) {
338
+ el.setAttribute('role', 'button');
339
+ el.setAttribute('tabindex', '0');
340
+ if (!el.hasAttribute('aria-label')) {
341
+ const label = (el.getAttribute('title') || el.textContent || '').trim();
342
+ if (label) {
343
+ el.setAttribute('aria-label', `Event: ${label}`);
344
+ }
345
+ }
346
+ }
313
347
  }
314
348
  }
315
349
 
@@ -28,16 +28,37 @@ export class MonthViewRenderer extends BaseViewRenderer {
28
28
  const html = this._renderMonthView(viewData, config);
29
29
  this.container.innerHTML = html;
30
30
  this._attachEventHandlers();
31
+
32
+ // Roving tabindex: exactly one cell is tabbable; restore focus after
33
+ // a keyboard-initiated re-render (month navigation, selection)
34
+ const active = this._applyRovingTabindex(this._pendingFocusMs ?? null);
35
+ if (this._pendingFocusMs != null) {
36
+ this._pendingFocusMs = null;
37
+ if (active) {
38
+ active.focus();
39
+ }
40
+ }
31
41
  }
32
42
 
33
43
  _renderMonthView(viewData, config) {
34
44
  const weekStartsOn = config.weekStartsOn || 0;
35
45
  const dayNames = this._getDayNames(weekStartsOn);
46
+ const locale = config.locale || 'en-US';
47
+ this._dayLabelFormatter = new Intl.DateTimeFormat(locale, {
48
+ weekday: 'long',
49
+ year: 'numeric',
50
+ month: 'long',
51
+ day: 'numeric'
52
+ });
53
+ const currentDate = this.stateManager.getState().currentDate;
54
+ const gridLabel = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(
55
+ currentDate || new Date()
56
+ );
36
57
 
37
58
  let html = `
38
- <div class="fc-month-view" style="display: flex; flex-direction: column; height: 100%; min-height: 400px; background: var(--fc-background); border: 1px solid var(--fc-border-color);">
39
- <div class="fc-month-header" style="display: grid; grid-template-columns: repeat(7, 1fr); border-bottom: 1px solid var(--fc-border-color); background: var(--fc-background-alt);">
40
- ${dayNames.map(d => `<div class="fc-month-header-cell" style="padding: 12px 8px; text-align: center; font-size: 11px; font-weight: 600; color: var(--fc-text-light); text-transform: uppercase;">${this.escapeHTML(d)}</div>`).join('')}
59
+ <div class="fc-month-view" role="grid" aria-label="${this.escapeHTML(gridLabel)}" style="display: flex; flex-direction: column; height: 100%; min-height: 400px; background: var(--fc-background); border: 1px solid var(--fc-border-color);">
60
+ <div class="fc-month-header" role="row" style="display: grid; grid-template-columns: repeat(7, 1fr); border-bottom: 1px solid var(--fc-border-color); background: var(--fc-background-alt);">
61
+ ${dayNames.map(d => `<div class="fc-month-header-cell" role="columnheader" style="padding: 12px 8px; text-align: center; font-size: 11px; font-weight: 600; color: var(--fc-text-light); text-transform: uppercase;">${this.escapeHTML(d)}</div>`).join('')}
41
62
  </div>
42
63
  <div class="fc-month-body" style="display: flex; flex-direction: column; flex: 1;">
43
64
  `;
@@ -62,7 +83,7 @@ export class MonthViewRenderer extends BaseViewRenderer {
62
83
 
63
84
  _renderWeek(week) {
64
85
  let html =
65
- '<div class="fc-month-week" style="display: grid; grid-template-columns: repeat(7, 1fr); flex: 1; min-height: 80px;">';
86
+ '<div class="fc-month-week" role="row" style="display: grid; grid-template-columns: repeat(7, 1fr); flex: 1; min-height: 80px;">';
66
87
 
67
88
  week.days.forEach(day => {
68
89
  html += this._renderDay(day);
@@ -86,8 +107,20 @@ export class MonthViewRenderer extends BaseViewRenderer {
86
107
  const visibleEvents = events.slice(0, this.maxEventsToShow);
87
108
  const moreCount = events.length - this.maxEventsToShow;
88
109
 
110
+ let cellLabel = this._dayLabelFormatter
111
+ ? this._dayLabelFormatter.format(new Date(day.date))
112
+ : day.date;
113
+ if (events.length > 0) {
114
+ cellLabel += `, ${events.length} ${events.length === 1 ? 'event' : 'events'}`;
115
+ }
116
+
89
117
  return `
90
118
  <div class="fc-month-day" data-date="${this.escapeHTML(day.date)}"
119
+ role="gridcell" tabindex="-1"
120
+ aria-selected="${day.isSelected ? 'true' : 'false'}"
121
+ ${isToday ? 'aria-current="date"' : ''}
122
+ ${isOtherMonth ? 'data-other-month="true"' : ''}
123
+ aria-label="${this.escapeHTML(cellLabel)}"
91
124
  style="background: ${dayBg}; border-right: 1px solid var(--fc-border-color); border-bottom: 1px solid var(--fc-border-color); padding: 4px; min-height: 80px; cursor: pointer; display: flex; flex-direction: column;">
92
125
  <div class="fc-day-number" style="font-size: 13px; font-weight: 500; color: ${dayNumColor}; padding: 2px 4px; margin-bottom: 4px; ${todayStyle}">
93
126
  ${this.escapeHTML(String(day.dayOfMonth))}
@@ -121,9 +154,112 @@ export class MonthViewRenderer extends BaseViewRenderer {
121
154
  this.stateManager.selectDate(date);
122
155
  });
123
156
 
157
+ // WAI-ARIA grid keyboard navigation
158
+ this.addListener(this.container, 'keydown', e => {
159
+ if (e.target.closest('.fc-event')) return; // events handle their own keys
160
+ const cell = e.target.closest('.fc-month-day');
161
+ if (!cell || !this.container.contains(cell)) return;
162
+
163
+ const cells = Array.from(this.container.querySelectorAll('.fc-month-day'));
164
+ const idx = cells.indexOf(cell);
165
+ let targetIdx;
166
+
167
+ switch (e.key) {
168
+ case 'ArrowRight':
169
+ targetIdx = idx + 1;
170
+ break;
171
+ case 'ArrowLeft':
172
+ targetIdx = idx - 1;
173
+ break;
174
+ case 'ArrowDown':
175
+ targetIdx = idx + 7;
176
+ break;
177
+ case 'ArrowUp':
178
+ targetIdx = idx - 7;
179
+ break;
180
+ case 'Home':
181
+ targetIdx = idx - (idx % 7);
182
+ break;
183
+ case 'End':
184
+ targetIdx = idx - (idx % 7) + 6;
185
+ break;
186
+ case 'PageUp':
187
+ case 'PageDown': {
188
+ e.preventDefault();
189
+ const d = new Date(cell.dataset.date);
190
+ d.setMonth(d.getMonth() + (e.key === 'PageDown' ? 1 : -1));
191
+ this._pendingFocusMs = d.getTime();
192
+ if (e.key === 'PageDown') {
193
+ this.stateManager.next();
194
+ } else {
195
+ this.stateManager.previous();
196
+ }
197
+ return;
198
+ }
199
+ case 'Enter':
200
+ case ' ': {
201
+ e.preventDefault();
202
+ const date = new Date(cell.dataset.date);
203
+ this._pendingFocusMs = date.getTime();
204
+ this.stateManager.selectDate(date);
205
+ return;
206
+ }
207
+ default:
208
+ return;
209
+ }
210
+
211
+ e.preventDefault();
212
+ if (targetIdx >= 0 && targetIdx < cells.length) {
213
+ this._applyRovingTabindex(new Date(cells[targetIdx].dataset.date).getTime());
214
+ cells[targetIdx].focus();
215
+ } else {
216
+ // Focus target falls outside the rendered grid: navigate and
217
+ // restore focus on the equivalent date after re-render
218
+ const d = new Date(cell.dataset.date);
219
+ d.setDate(d.getDate() + (targetIdx - idx));
220
+ this._pendingFocusMs = d.getTime();
221
+ if (targetIdx < 0) {
222
+ this.stateManager.previous();
223
+ } else {
224
+ this.stateManager.next();
225
+ }
226
+ }
227
+ });
228
+
124
229
  // Common event handlers (event clicks)
125
230
  this.attachCommonEventHandlers();
126
231
  }
232
+
233
+ /**
234
+ * Ensure exactly one grid cell participates in the tab order.
235
+ * Priority: requested focus date, selected date, today, first day of
236
+ * the current month.
237
+ * @param {number|null} focusMs - Preferred focus date as a timestamp
238
+ * @returns {HTMLElement|null} The tabbable cell
239
+ */
240
+ _applyRovingTabindex(focusMs = null) {
241
+ const cells = Array.from(this.container.querySelectorAll('.fc-month-day'));
242
+ if (cells.length === 0) return null;
243
+
244
+ let active = null;
245
+ if (focusMs != null) {
246
+ active = cells.find(c => new Date(c.dataset.date).getTime() === focusMs);
247
+ }
248
+ if (!active) {
249
+ active = cells.find(c => c.getAttribute('aria-selected') === 'true');
250
+ }
251
+ if (!active) {
252
+ active = cells.find(c => c.hasAttribute('aria-current'));
253
+ }
254
+ if (!active) {
255
+ active = cells.find(c => !c.dataset.otherMonth) || cells[0];
256
+ }
257
+
258
+ for (const c of cells) {
259
+ c.setAttribute('tabindex', c === active ? '0' : '-1');
260
+ }
261
+ return active;
262
+ }
127
263
  }
128
264
 
129
265
  export default MonthViewRenderer;