@forcecalendar/interface 0.9.0 → 1.0.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": "0.9.0",
3
+ "version": "1.0.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",
@@ -300,7 +300,10 @@ export class EventForm extends BaseComponent {
300
300
  if (e.target === this) this.close();
301
301
  });
302
302
 
303
- // Close on Escape key
303
+ // Close on Escape key - remove old listener before adding new one
304
+ if (this._handleKeyDown) {
305
+ window.removeEventListener('keydown', this._handleKeyDown);
306
+ }
304
307
  this._handleKeyDown = (e) => {
305
308
  if (e.key === 'Escape' && this.hasAttribute('open')) {
306
309
  this.close();
@@ -436,13 +436,13 @@ export class ForceCalendar extends BaseComponent {
436
436
  viewElement.stateManager = this.stateManager;
437
437
  }
438
438
 
439
- // Add event listeners for buttons
439
+ // Add event listeners for buttons using tracked addListener
440
440
  this.$$('[data-action]').forEach(button => {
441
- button.addEventListener('click', this.handleNavigation.bind(this));
441
+ this.addListener(button, 'click', this.handleNavigation);
442
442
  });
443
443
 
444
444
  this.$$('[data-view]').forEach(button => {
445
- button.addEventListener('click', this.handleViewChange.bind(this));
445
+ this.addListener(button, 'click', this.handleViewChange);
446
446
  });
447
447
 
448
448
  // Event Modal Handling
@@ -450,13 +450,13 @@ export class ForceCalendar extends BaseComponent {
450
450
  const createBtn = this.$('#create-event-btn');
451
451
 
452
452
  if (createBtn && modal) {
453
- createBtn.addEventListener('click', () => {
453
+ this.addListener(createBtn, 'click', () => {
454
454
  modal.open(new Date());
455
455
  });
456
456
  }
457
457
 
458
458
  // Listen for day clicks from the view
459
- this.shadowRoot.addEventListener('day-click', (e) => {
459
+ this.addListener(this.shadowRoot, 'day-click', (e) => {
460
460
  if (modal) {
461
461
  modal.open(e.detail.date);
462
462
  }
@@ -464,13 +464,13 @@ export class ForceCalendar extends BaseComponent {
464
464
 
465
465
  // Handle event saving
466
466
  if (modal) {
467
- modal.addEventListener('save', (e) => {
467
+ this.addListener(modal, 'save', (e) => {
468
468
  const eventData = e.detail;
469
469
  // Robust Safari support check for randomUUID
470
- const id = (window.crypto && typeof window.crypto.randomUUID === 'function')
471
- ? window.crypto.randomUUID()
470
+ const id = (window.crypto && typeof window.crypto.randomUUID === 'function')
471
+ ? window.crypto.randomUUID()
472
472
  : Math.random().toString(36).substring(2, 15);
473
-
473
+
474
474
  this.stateManager.addEvent({
475
475
  id,
476
476
  ...eventData
@@ -318,18 +318,18 @@ export class DayView extends BaseComponent {
318
318
  renderTimedEvent(event) {
319
319
  const start = new Date(event.start);
320
320
  const end = new Date(event.end);
321
-
321
+
322
322
  const startMinutes = start.getHours() * 60 + start.getMinutes();
323
323
  const durationMinutes = (end - start) / (1000 * 60);
324
-
324
+
325
325
  const top = startMinutes;
326
326
  const height = Math.max(durationMinutes, 30);
327
-
328
- const color = event.backgroundColor || 'var(--fc-primary-color)';
329
- const textColor = StyleUtils.getContrastColor(color);
327
+
328
+ const color = StyleUtils.sanitizeColor(event.backgroundColor);
329
+ const textColor = StyleUtils.sanitizeColor(StyleUtils.getContrastColor(color), 'white');
330
330
 
331
331
  return `
332
- <div class="event-container"
332
+ <div class="event-container"
333
333
  style="top: ${top}px; height: ${height}px; background-color: ${color}; color: ${textColor};"
334
334
  data-event-id="${event.id}">
335
335
  <span class="event-title">${DOMUtils.escapeHTML(event.title)}</span>
@@ -339,11 +339,11 @@ export class DayView extends BaseComponent {
339
339
  }
340
340
 
341
341
  renderAllDayEvent(event) {
342
- const color = event.backgroundColor || 'var(--fc-primary-color)';
343
- const textColor = StyleUtils.getContrastColor(color);
344
-
342
+ const color = StyleUtils.sanitizeColor(event.backgroundColor);
343
+ const textColor = StyleUtils.sanitizeColor(StyleUtils.getContrastColor(color), 'white');
344
+
345
345
  return `
346
- <div class="event-item"
346
+ <div class="event-item"
347
347
  style="background-color: ${color}; color: ${textColor}; font-size: 12px; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-weight: 500; margin-bottom: 2px;"
348
348
  data-event-id="${event.id}">
349
349
  ${DOMUtils.escapeHTML(event.title)}
@@ -7,6 +7,7 @@
7
7
  import { BaseComponent } from '../../core/BaseComponent.js';
8
8
  import { DOMUtils } from '../../utils/DOMUtils.js';
9
9
  import { DateUtils } from '../../utils/DateUtils.js';
10
+ import { StyleUtils } from '../../utils/StyleUtils.js';
10
11
 
11
12
  export class MonthView extends BaseComponent {
12
13
  constructor() {
@@ -107,11 +108,30 @@ export class MonthView extends BaseComponent {
107
108
  }
108
109
 
109
110
  getContrastingTextColor(bgColor) {
110
- if (!bgColor) return 'white';
111
- const color = (bgColor.charAt(0) === '#') ? bgColor.substring(1, 7) : bgColor;
112
- const r = parseInt(color.substring(0, 2), 16);
113
- const g = parseInt(color.substring(2, 4), 16);
114
- const b = parseInt(color.substring(4, 6), 16);
111
+ if (!bgColor || typeof bgColor !== 'string') return 'white';
112
+
113
+ // Extract hex color, removing # if present
114
+ const color = (bgColor.charAt(0) === '#') ? bgColor.substring(1) : bgColor;
115
+
116
+ // Validate hex format (3 or 6 characters)
117
+ if (!/^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/.test(color)) {
118
+ return 'white'; // Fallback for invalid format
119
+ }
120
+
121
+ // Expand 3-char hex to 6-char
122
+ const fullColor = color.length === 3
123
+ ? color[0] + color[0] + color[1] + color[1] + color[2] + color[2]
124
+ : color;
125
+
126
+ const r = parseInt(fullColor.substring(0, 2), 16);
127
+ const g = parseInt(fullColor.substring(2, 4), 16);
128
+ const b = parseInt(fullColor.substring(4, 6), 16);
129
+
130
+ // Check for NaN (shouldn't happen with validation, but just in case)
131
+ if (isNaN(r) || isNaN(g) || isNaN(b)) {
132
+ return 'white';
133
+ }
134
+
115
135
  const uicolors = [r / 255, g / 255, b / 255];
116
136
  const c = uicolors.map((col) => {
117
137
  if (col <= 0.03928) {
@@ -455,7 +475,9 @@ export class MonthView extends BaseComponent {
455
475
 
456
476
  let style = '';
457
477
  if (backgroundColor) {
458
- style += `background-color: ${backgroundColor}; color: ${textColor};`;
478
+ const safeColor = StyleUtils.sanitizeColor(backgroundColor);
479
+ const safeTextColor = StyleUtils.sanitizeColor(textColor, 'white');
480
+ style += `background-color: ${safeColor}; color: ${safeTextColor};`;
459
481
  }
460
482
 
461
483
  let timeStr = '';
@@ -326,18 +326,18 @@ export class WeekView extends BaseComponent {
326
326
  renderTimedEvent(event) {
327
327
  const start = new Date(event.start);
328
328
  const end = new Date(event.end);
329
-
329
+
330
330
  const startMinutes = start.getHours() * 60 + start.getMinutes();
331
331
  const durationMinutes = (end - start) / (1000 * 60);
332
-
333
- const top = startMinutes;
332
+
333
+ const top = startMinutes;
334
334
  const height = Math.max(durationMinutes, 20);
335
-
336
- const color = event.backgroundColor || 'var(--fc-primary-color)';
337
- const textColor = StyleUtils.getContrastColor(color);
335
+
336
+ const color = StyleUtils.sanitizeColor(event.backgroundColor);
337
+ const textColor = StyleUtils.sanitizeColor(StyleUtils.getContrastColor(color), 'white');
338
338
 
339
339
  return `
340
- <div class="event-container"
340
+ <div class="event-container"
341
341
  style="top: ${top}px; height: ${height}px; background-color: ${color}; color: ${textColor};"
342
342
  data-event-id="${event.id}">
343
343
  <span class="event-title">${DOMUtils.escapeHTML(event.title)}</span>
@@ -347,11 +347,11 @@ export class WeekView extends BaseComponent {
347
347
  }
348
348
 
349
349
  renderAllDayEvent(event) {
350
- const color = event.backgroundColor || 'var(--fc-primary-color)';
351
- const textColor = StyleUtils.getContrastColor(color);
352
-
350
+ const color = StyleUtils.sanitizeColor(event.backgroundColor);
351
+ const textColor = StyleUtils.sanitizeColor(StyleUtils.getContrastColor(color), 'white');
352
+
353
353
  return `
354
- <div class="event-item"
354
+ <div class="event-item"
355
355
  style="background-color: ${color}; color: ${textColor}; font-size: 10px; padding: 2px 4px; border-radius: 2px; cursor: pointer; margin-bottom: 2px;"
356
356
  data-event-id="${event.id}">
357
357
  ${DOMUtils.escapeHTML(event.title)}
@@ -126,6 +126,9 @@ export class BaseComponent extends HTMLElement {
126
126
 
127
127
  // Template rendering
128
128
  render() {
129
+ // Clean up existing listeners before replacing DOM
130
+ this.cleanup();
131
+
129
132
  const styles = `
130
133
  <style>
131
134
  ${this.getBaseStyles()}
@@ -102,13 +102,14 @@ class EventBus {
102
102
  }
103
103
  }
104
104
 
105
- // Handle wildcard subscriptions
106
- for (const subscription of this.wildcardHandlers) {
105
+ // Handle wildcard subscriptions (copy Set to avoid mutation during iteration)
106
+ const toRemove = [];
107
+ for (const subscription of [...this.wildcardHandlers]) {
107
108
  if (this.matchesPattern(eventName, subscription.pattern)) {
108
109
  const { handler, once } = subscription;
109
110
 
110
111
  if (once) {
111
- this.wildcardHandlers.delete(subscription);
112
+ toRemove.push(subscription);
112
113
  }
113
114
 
114
115
  try {
@@ -121,6 +122,8 @@ class EventBus {
121
122
  }
122
123
  }
123
124
  }
125
+ // Remove one-time handlers after iteration
126
+ toRemove.forEach(sub => this.wildcardHandlers.delete(sub));
124
127
 
125
128
  return Promise.all(promises);
126
129
  }
@@ -145,33 +145,53 @@ class StateManager {
145
145
  // Event management
146
146
  addEvent(event) {
147
147
  const addedEvent = this.calendar.addEvent(event);
148
- this.state.events.push(addedEvent);
149
- this.setState({ events: [...this.state.events] });
148
+ if (!addedEvent) {
149
+ console.error('Failed to add event to calendar');
150
+ eventBus.emit('event:error', { action: 'add', event, error: 'Failed to add event' });
151
+ return null;
152
+ }
153
+ // Create new array to avoid mutation before setState
154
+ const newEvents = [...this.state.events, addedEvent];
155
+ this.setState({ events: newEvents });
150
156
  eventBus.emit('event:added', { event: addedEvent });
151
157
  return addedEvent;
152
158
  }
153
159
 
154
160
  updateEvent(eventId, updates) {
155
161
  const event = this.calendar.updateEvent(eventId, updates);
156
- if (event) {
157
- const index = this.state.events.findIndex(e => e.id === eventId);
158
- if (index > -1) {
159
- this.state.events[index] = event;
160
- this.setState({ events: [...this.state.events] });
161
- eventBus.emit('event:updated', { event });
162
- }
162
+ if (!event) {
163
+ console.error(`Failed to update event: ${eventId}`);
164
+ eventBus.emit('event:error', { action: 'update', eventId, updates, error: 'Event not found in calendar' });
165
+ return null;
163
166
  }
167
+
168
+ const index = this.state.events.findIndex(e => e.id === eventId);
169
+ if (index === -1) {
170
+ console.error(`Event ${eventId} not found in state`);
171
+ eventBus.emit('event:error', { action: 'update', eventId, error: 'Event not found in state' });
172
+ return null;
173
+ }
174
+
175
+ // Create new array to avoid mutation before setState
176
+ const newEvents = [...this.state.events];
177
+ newEvents[index] = event;
178
+ this.setState({ events: newEvents });
179
+ eventBus.emit('event:updated', { event });
164
180
  return event;
165
181
  }
166
182
 
167
183
  deleteEvent(eventId) {
168
184
  const deleted = this.calendar.removeEvent(eventId);
169
- if (deleted) {
170
- this.state.events = this.state.events.filter(e => e.id !== eventId);
171
- this.setState({ events: [...this.state.events] });
172
- eventBus.emit('event:deleted', { eventId });
185
+ if (!deleted) {
186
+ console.error(`Failed to delete event: ${eventId}`);
187
+ eventBus.emit('event:error', { action: 'delete', eventId, error: 'Event not found' });
188
+ return false;
173
189
  }
174
- return deleted;
190
+ // Create new array to avoid mutation before setState
191
+ const newEvents = this.state.events.filter(e => e.id !== eventId);
192
+ this.setState({ events: newEvents });
193
+ eventBus.emit('event:deleted', { eventId });
194
+ return true;
175
195
  }
176
196
 
177
197
  getEvents() {
@@ -255,6 +255,15 @@ export class DOMUtils {
255
255
  const focusableElements = container.querySelectorAll(
256
256
  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
257
257
  );
258
+
259
+ // Handle case where there are no focusable elements
260
+ if (focusableElements.length === 0) {
261
+ // Make container focusable as fallback
262
+ container.setAttribute('tabindex', '-1');
263
+ container.focus();
264
+ return () => container.removeAttribute('tabindex');
265
+ }
266
+
258
267
  const firstFocusable = focusableElements[0];
259
268
  const lastFocusable = focusableElements[focusableElements.length - 1];
260
269
 
@@ -263,12 +272,12 @@ export class DOMUtils {
263
272
 
264
273
  if (e.shiftKey) {
265
274
  if (document.activeElement === firstFocusable) {
266
- lastFocusable.focus();
275
+ lastFocusable?.focus();
267
276
  e.preventDefault();
268
277
  }
269
278
  } else {
270
279
  if (document.activeElement === lastFocusable) {
271
- firstFocusable.focus();
280
+ firstFocusable?.focus();
272
281
  e.preventDefault();
273
282
  }
274
283
  }
@@ -312,6 +312,50 @@ export class StyleUtils {
312
312
  return yiq >= 128 ? '#000000' : '#FFFFFF';
313
313
  }
314
314
 
315
+ /**
316
+ * Sanitize color value to prevent CSS injection
317
+ * Returns the color if valid, or a fallback color if invalid
318
+ */
319
+ static sanitizeColor(color, fallback = 'var(--fc-primary-color)') {
320
+ if (!color || typeof color !== 'string') {
321
+ return fallback;
322
+ }
323
+
324
+ // Trim and check for dangerous characters that could break out of CSS
325
+ const trimmed = color.trim();
326
+ if (/[;{}()<>\"\'\\]/.test(trimmed)) {
327
+ return fallback;
328
+ }
329
+
330
+ // Allow hex colors (#RGB, #RRGGBB, #RRGGBBAA)
331
+ if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(trimmed)) {
332
+ return trimmed;
333
+ }
334
+
335
+ // Allow CSS variables
336
+ if (/^var\(--[a-zA-Z0-9-]+\)$/.test(trimmed)) {
337
+ return trimmed;
338
+ }
339
+
340
+ // Allow rgb/rgba with numbers only
341
+ if (/^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(,\s*(0|1|0?\.\d+))?\s*\)$/.test(trimmed)) {
342
+ return trimmed;
343
+ }
344
+
345
+ // Allow safe CSS color keywords
346
+ const safeKeywords = [
347
+ 'transparent', 'currentColor', 'inherit',
348
+ 'black', 'white', 'red', 'green', 'blue', 'yellow', 'orange', 'purple',
349
+ 'pink', 'brown', 'gray', 'grey', 'cyan', 'magenta', 'lime', 'navy',
350
+ 'teal', 'aqua', 'maroon', 'olive', 'silver', 'fuchsia'
351
+ ];
352
+ if (safeKeywords.includes(trimmed.toLowerCase())) {
353
+ return trimmed;
354
+ }
355
+
356
+ return fallback;
357
+ }
358
+
315
359
  /**
316
360
  * Convert hex to rgba
317
361
  */