@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/dist/force-calendar-interface.esm.js +1590 -1533
- package/dist/force-calendar-interface.esm.js.map +1 -1
- package/dist/force-calendar-interface.umd.js +42 -42
- package/dist/force-calendar-interface.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/components/EventForm.js +4 -1
- package/src/components/ForceCalendar.js +9 -9
- package/src/components/views/DayView.js +10 -10
- package/src/components/views/MonthView.js +28 -6
- package/src/components/views/WeekView.js +11 -11
- package/src/core/BaseComponent.js +3 -0
- package/src/core/EventBus.js +6 -3
- package/src/core/StateManager.js +34 -14
- package/src/utils/DOMUtils.js +11 -2
- package/src/utils/StyleUtils.js +44 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
441
|
+
this.addListener(button, 'click', this.handleNavigation);
|
|
442
442
|
});
|
|
443
443
|
|
|
444
444
|
this.$$('[data-view]').forEach(button => {
|
|
445
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)}
|
package/src/core/EventBus.js
CHANGED
|
@@ -102,13 +102,14 @@ class EventBus {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
// Handle wildcard subscriptions
|
|
106
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/StateManager.js
CHANGED
|
@@ -145,33 +145,53 @@ class StateManager {
|
|
|
145
145
|
// Event management
|
|
146
146
|
addEvent(event) {
|
|
147
147
|
const addedEvent = this.calendar.addEvent(event);
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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() {
|
package/src/utils/DOMUtils.js
CHANGED
|
@@ -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
|
|
275
|
+
lastFocusable?.focus();
|
|
267
276
|
e.preventDefault();
|
|
268
277
|
}
|
|
269
278
|
} else {
|
|
270
279
|
if (document.activeElement === lastFocusable) {
|
|
271
|
-
firstFocusable
|
|
280
|
+
firstFocusable?.focus();
|
|
272
281
|
e.preventDefault();
|
|
273
282
|
}
|
|
274
283
|
}
|
package/src/utils/StyleUtils.js
CHANGED
|
@@ -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
|
*/
|