@forcecalendar/interface 1.0.16 → 1.0.18

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.
@@ -58,6 +58,17 @@ class EventBus {
58
58
  * Unsubscribe from an event
59
59
  */
60
60
  off(eventName, handler) {
61
+ // Handle wildcard pattern removal
62
+ if (eventName.includes('*')) {
63
+ for (const sub of this.wildcardHandlers) {
64
+ if (sub.pattern === eventName && sub.handler === handler) {
65
+ this.wildcardHandlers.delete(sub);
66
+ return;
67
+ }
68
+ }
69
+ return;
70
+ }
71
+
61
72
  if (!this.events.has(eventName)) return;
62
73
 
63
74
  const handlers = this.events.get(eventName);
@@ -71,6 +82,43 @@ class EventBus {
71
82
  }
72
83
  }
73
84
 
85
+ /**
86
+ * Remove all wildcard handlers matching a pattern
87
+ * @param {string} pattern - Pattern to match (e.g., 'event:*')
88
+ */
89
+ offWildcard(pattern) {
90
+ for (const sub of [...this.wildcardHandlers]) {
91
+ if (sub.pattern === pattern) {
92
+ this.wildcardHandlers.delete(sub);
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Remove all handlers (regular and wildcard) for a specific handler function
99
+ * Useful for cleanup when a component is destroyed
100
+ * @param {Function} handler - Handler function to remove
101
+ */
102
+ offAll(handler) {
103
+ // Remove from regular events
104
+ for (const [eventName, handlers] of this.events) {
105
+ const index = handlers.findIndex(sub => sub.handler === handler);
106
+ if (index > -1) {
107
+ handlers.splice(index, 1);
108
+ }
109
+ if (handlers.length === 0) {
110
+ this.events.delete(eventName);
111
+ }
112
+ }
113
+
114
+ // Remove from wildcard handlers
115
+ for (const sub of [...this.wildcardHandlers]) {
116
+ if (sub.handler === handler) {
117
+ this.wildcardHandlers.delete(sub);
118
+ }
119
+ }
120
+ }
121
+
74
122
  /**
75
123
  * Emit an event
76
124
  * @param {string} eventName - Event name
@@ -157,6 +205,24 @@ class EventBus {
157
205
  getHandlerCount(eventName) {
158
206
  return this.events.has(eventName) ? this.events.get(eventName).length : 0;
159
207
  }
208
+
209
+ /**
210
+ * Get wildcard handler count
211
+ */
212
+ getWildcardHandlerCount() {
213
+ return this.wildcardHandlers.size;
214
+ }
215
+
216
+ /**
217
+ * Get total handler count (for debugging/monitoring)
218
+ */
219
+ getTotalHandlerCount() {
220
+ let count = this.wildcardHandlers.size;
221
+ for (const handlers of this.events.values()) {
222
+ count += handlers.length;
223
+ }
224
+ return count;
225
+ }
160
226
  }
161
227
 
162
228
  // Create singleton instance
@@ -85,13 +85,50 @@ class StateManager {
85
85
  return this.state;
86
86
  }
87
87
 
88
- subscribe(callback) {
88
+ subscribe(callback, subscriberId = null) {
89
89
  this.subscribers.add(callback);
90
- return () => this.unsubscribe(callback);
90
+
91
+ // Track subscriber ID for debugging/cleanup
92
+ if (subscriberId) {
93
+ if (!this._subscriberIds) {
94
+ this._subscriberIds = new Map();
95
+ }
96
+ this._subscriberIds.set(subscriberId, callback);
97
+ }
98
+
99
+ return () => this.unsubscribe(callback, subscriberId);
91
100
  }
92
101
 
93
- unsubscribe(callback) {
102
+ unsubscribe(callback, subscriberId = null) {
94
103
  this.subscribers.delete(callback);
104
+
105
+ // Clean up ID tracking
106
+ if (subscriberId && this._subscriberIds) {
107
+ this._subscriberIds.delete(subscriberId);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Unsubscribe by subscriber ID
113
+ * @param {string} subscriberId - ID used when subscribing
114
+ */
115
+ unsubscribeById(subscriberId) {
116
+ if (!this._subscriberIds) return false;
117
+
118
+ const callback = this._subscriberIds.get(subscriberId);
119
+ if (callback) {
120
+ this.subscribers.delete(callback);
121
+ this._subscriberIds.delete(subscriberId);
122
+ return true;
123
+ }
124
+ return false;
125
+ }
126
+
127
+ /**
128
+ * Get subscriber count (for debugging/monitoring)
129
+ */
130
+ getSubscriberCount() {
131
+ return this.subscribers.size;
95
132
  }
96
133
 
97
134
  notifySubscribers(oldState, newState) {
@@ -362,6 +399,10 @@ class StateManager {
362
399
  // Destroy
363
400
  destroy() {
364
401
  this.subscribers.clear();
402
+ if (this._subscriberIds) {
403
+ this._subscriberIds.clear();
404
+ this._subscriberIds = null;
405
+ }
365
406
  this.state = null;
366
407
  this.calendar = null;
367
408
  }
package/src/index.js CHANGED
@@ -15,11 +15,17 @@ export { DateUtils } from './utils/DateUtils.js';
15
15
  export { DOMUtils } from './utils/DOMUtils.js';
16
16
  export { StyleUtils } from './utils/StyleUtils.js';
17
17
 
18
+ // View Renderers (pure JS classes, Locker Service compatible)
19
+ export { BaseViewRenderer } from './renderers/BaseViewRenderer.js';
20
+ export { MonthViewRenderer } from './renderers/MonthViewRenderer.js';
21
+ export { WeekViewRenderer } from './renderers/WeekViewRenderer.js';
22
+ export { DayViewRenderer } from './renderers/DayViewRenderer.js';
23
+
18
24
  // Components
19
25
  import './components/ForceCalendar.js';
20
26
  export { ForceCalendar } from './components/ForceCalendar.js';
21
27
 
22
- // Views
28
+ // Views (Web Components - for non-Salesforce usage)
23
29
  export { MonthView } from './components/views/MonthView.js';
24
30
  export { WeekView } from './components/views/WeekView.js';
25
31
  export { DayView } from './components/views/DayView.js';
@@ -0,0 +1,219 @@
1
+ /**
2
+ * BaseViewRenderer - Foundation for all view renderers
3
+ *
4
+ * Pure JavaScript class (no Web Components) for Salesforce Locker Service compatibility.
5
+ * Provides common functionality for rendering calendar views.
6
+ */
7
+
8
+ import { DOMUtils } from '../utils/DOMUtils.js';
9
+ import { DateUtils } from '../utils/DateUtils.js';
10
+ import { StyleUtils } from '../utils/StyleUtils.js';
11
+
12
+ export class BaseViewRenderer {
13
+ /**
14
+ * @param {HTMLElement} container - The DOM element to render into
15
+ * @param {StateManager} stateManager - The state manager instance
16
+ */
17
+ constructor(container, stateManager) {
18
+ this.container = container;
19
+ this.stateManager = stateManager;
20
+ this._listeners = [];
21
+ this._scrolled = false;
22
+ }
23
+
24
+ /**
25
+ * Render the view into the container
26
+ * Must be implemented by subclasses
27
+ */
28
+ render() {
29
+ throw new Error('render() must be implemented by subclass');
30
+ }
31
+
32
+ /**
33
+ * Clean up event listeners
34
+ */
35
+ cleanup() {
36
+ this._listeners.forEach(({ element, event, handler }) => {
37
+ element.removeEventListener(event, handler);
38
+ });
39
+ this._listeners = [];
40
+ }
41
+
42
+ /**
43
+ * Add an event listener with automatic cleanup tracking
44
+ * @param {HTMLElement} element
45
+ * @param {string} event
46
+ * @param {Function} handler
47
+ */
48
+ addListener(element, event, handler) {
49
+ const boundHandler = handler.bind(this);
50
+ element.addEventListener(event, boundHandler);
51
+ this._listeners.push({ element, event, handler: boundHandler });
52
+ }
53
+
54
+ /**
55
+ * Escape HTML to prevent XSS
56
+ * @param {string} str
57
+ * @returns {string}
58
+ */
59
+ escapeHTML(str) {
60
+ if (str == null) return '';
61
+ return DOMUtils.escapeHTML(String(str));
62
+ }
63
+
64
+ /**
65
+ * Check if a date is today
66
+ * @param {Date} date
67
+ * @returns {boolean}
68
+ */
69
+ isToday(date) {
70
+ const today = new Date();
71
+ return date.getDate() === today.getDate() &&
72
+ date.getMonth() === today.getMonth() &&
73
+ date.getFullYear() === today.getFullYear();
74
+ }
75
+
76
+ /**
77
+ * Check if two dates are the same day
78
+ * @param {Date} date1
79
+ * @param {Date} date2
80
+ * @returns {boolean}
81
+ */
82
+ isSameDay(date1, date2) {
83
+ return date1.getDate() === date2.getDate() &&
84
+ date1.getMonth() === date2.getMonth() &&
85
+ date1.getFullYear() === date2.getFullYear();
86
+ }
87
+
88
+ /**
89
+ * Format hour for display (e.g., "9 AM", "2 PM")
90
+ * @param {number} hour - Hour in 24-hour format (0-23)
91
+ * @returns {string}
92
+ */
93
+ formatHour(hour) {
94
+ const period = hour >= 12 ? 'PM' : 'AM';
95
+ const displayHour = hour % 12 || 12;
96
+ return `${displayHour} ${period}`;
97
+ }
98
+
99
+ /**
100
+ * Format time for display (e.g., "9 AM", "2:30 PM")
101
+ * @param {Date} date
102
+ * @returns {string}
103
+ */
104
+ formatTime(date) {
105
+ const hours = date.getHours();
106
+ const minutes = date.getMinutes();
107
+ const period = hours >= 12 ? 'PM' : 'AM';
108
+ const displayHour = hours % 12 || 12;
109
+ return minutes === 0
110
+ ? `${displayHour} ${period}`
111
+ : `${displayHour}:${minutes.toString().padStart(2, '0')} ${period}`;
112
+ }
113
+
114
+ /**
115
+ * Get contrasting text color for a background color
116
+ * Uses WCAG luminance formula
117
+ * @param {string} bgColor - Hex color string
118
+ * @returns {string} 'black' or 'white'
119
+ */
120
+ getContrastingTextColor(bgColor) {
121
+ if (!bgColor || typeof bgColor !== 'string') return 'white';
122
+
123
+ const color = (bgColor.charAt(0) === '#') ? bgColor.substring(1) : bgColor;
124
+
125
+ if (!/^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/.test(color)) {
126
+ return 'white';
127
+ }
128
+
129
+ const fullColor = color.length === 3
130
+ ? color[0] + color[0] + color[1] + color[1] + color[2] + color[2]
131
+ : color;
132
+
133
+ const r = parseInt(fullColor.substring(0, 2), 16);
134
+ const g = parseInt(fullColor.substring(2, 4), 16);
135
+ const b = parseInt(fullColor.substring(4, 6), 16);
136
+
137
+ if (isNaN(r) || isNaN(g) || isNaN(b)) {
138
+ return 'white';
139
+ }
140
+
141
+ const uicolors = [r / 255, g / 255, b / 255];
142
+ const c = uicolors.map((col) => {
143
+ if (col <= 0.03928) {
144
+ return col / 12.92;
145
+ }
146
+ return Math.pow((col + 0.055) / 1.055, 2.4);
147
+ });
148
+ const L = (0.2126 * c[0]) + (0.7152 * c[1]) + (0.0722 * c[2]);
149
+ return (L > 0.179) ? 'black' : 'white';
150
+ }
151
+
152
+ /**
153
+ * Render the "now" indicator line for time-based views
154
+ * @returns {string} HTML string
155
+ */
156
+ renderNowIndicator() {
157
+ const now = new Date();
158
+ const minutes = now.getHours() * 60 + now.getMinutes();
159
+ return `<div class="fc-now-indicator" style="position: absolute; left: 0; right: 0; top: ${minutes}px; height: 2px; background: #dc2626; z-index: 15; pointer-events: none;"></div>`;
160
+ }
161
+
162
+ /**
163
+ * Render a timed event block
164
+ * @param {Object} event - Event object
165
+ * @param {Object} options - Rendering options
166
+ * @returns {string} HTML string
167
+ */
168
+ renderTimedEvent(event, options = {}) {
169
+ const { compact = true } = options;
170
+ const start = new Date(event.start);
171
+ const end = new Date(event.end);
172
+ const startMinutes = start.getHours() * 60 + start.getMinutes();
173
+ const durationMinutes = Math.max((end - start) / (1000 * 60), compact ? 20 : 30);
174
+ const color = event.backgroundColor || '#2563eb';
175
+
176
+ const padding = compact ? '4px 8px' : '8px 12px';
177
+ const fontSize = compact ? '11px' : '13px';
178
+ const margin = compact ? '2px' : '12px';
179
+ const rightMargin = compact ? '2px' : '24px';
180
+ const borderRadius = compact ? '4px' : '6px';
181
+
182
+ return `
183
+ <div class="fc-event fc-timed-event" data-event-id="${this.escapeHTML(event.id)}"
184
+ style="position: absolute; top: ${startMinutes}px; height: ${durationMinutes}px;
185
+ left: ${margin}; right: ${rightMargin};
186
+ background-color: ${color}; border-radius: ${borderRadius};
187
+ padding: ${padding}; font-size: ${fontSize};
188
+ font-weight: 500; color: white; overflow: hidden;
189
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
190
+ cursor: pointer; z-index: 5;">
191
+ <div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
192
+ ${this.escapeHTML(event.title)}
193
+ </div>
194
+ <div style="font-size: ${compact ? '10px' : '11px'}; opacity: 0.9;">
195
+ ${this.formatTime(start)}${compact ? '' : ' - ' + this.formatTime(end)}
196
+ </div>
197
+ </div>
198
+ `;
199
+ }
200
+
201
+ /**
202
+ * Attach common event handlers for day/event clicks
203
+ */
204
+ attachCommonEventHandlers() {
205
+ // Event click handlers
206
+ this.container.querySelectorAll('.fc-event').forEach(eventEl => {
207
+ this.addListener(eventEl, 'click', (e) => {
208
+ e.stopPropagation();
209
+ const eventId = eventEl.dataset.eventId;
210
+ const event = this.stateManager.getEvents().find(ev => ev.id === eventId);
211
+ if (event) {
212
+ this.stateManager.selectEvent(event);
213
+ }
214
+ });
215
+ });
216
+ }
217
+ }
218
+
219
+ export default BaseViewRenderer;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * DayViewRenderer - Renders single day calendar view
3
+ *
4
+ * Pure JavaScript renderer for day view, compatible with Salesforce Locker Service.
5
+ */
6
+
7
+ import { BaseViewRenderer } from './BaseViewRenderer.js';
8
+
9
+ export class DayViewRenderer extends BaseViewRenderer {
10
+ constructor(container, stateManager) {
11
+ super(container, stateManager);
12
+ this.hourHeight = 60; // pixels per hour
13
+ this.totalHeight = 24 * this.hourHeight; // 1440px for 24 hours
14
+ }
15
+
16
+ render() {
17
+ if (!this.container || !this.stateManager) return;
18
+
19
+ const viewData = this.stateManager.getViewData();
20
+ if (!viewData) {
21
+ this.container.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">No data available for day view.</div>';
22
+ return;
23
+ }
24
+
25
+ this.cleanup();
26
+ const config = this.stateManager.getState().config;
27
+ const html = this._renderDayView(viewData, config);
28
+ this.container.innerHTML = html;
29
+ this._attachEventHandlers();
30
+ this._scrollToCurrentTime();
31
+ }
32
+
33
+ _renderDayView(viewData, config) {
34
+ const currentDate = this.stateManager?.getState()?.currentDate || new Date();
35
+ const dayData = this._extractDayData(viewData, currentDate);
36
+
37
+ if (!dayData) {
38
+ return '<div style="padding: 20px; text-align: center; color: #666;">No data available for day view.</div>';
39
+ }
40
+
41
+ const { dayDate, dayName, isToday, allDayEvents, timedEvents } = dayData;
42
+ const hours = Array.from({ length: 24 }, (_, i) => i);
43
+
44
+ return `
45
+ <div class="fc-day-view" style="display: flex; flex-direction: column; height: 100%; background: #fff; overflow: hidden;">
46
+ ${this._renderHeader(dayDate, dayName, isToday)}
47
+ ${this._renderAllDayRow(allDayEvents, dayDate)}
48
+ ${this._renderTimeGrid(timedEvents, isToday, dayDate, hours)}
49
+ </div>
50
+ `;
51
+ }
52
+
53
+ _extractDayData(viewData, currentDate) {
54
+ let dayDate, dayName, isToday, allDayEvents, timedEvents;
55
+ const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
56
+
57
+ if (viewData.type === 'day' && viewData.date) {
58
+ // Core day view structure
59
+ dayDate = new Date(viewData.date);
60
+ dayName = viewData.dayName || dayNames[dayDate.getDay()];
61
+ isToday = viewData.isToday !== undefined ? viewData.isToday : this.isToday(dayDate);
62
+ allDayEvents = viewData.allDayEvents || [];
63
+
64
+ // Extract timed events from hours array
65
+ if (viewData.hours && Array.isArray(viewData.hours)) {
66
+ const eventMap = new Map();
67
+ viewData.hours.forEach(hour => {
68
+ (hour.events || []).forEach(evt => {
69
+ if (!eventMap.has(evt.id)) {
70
+ eventMap.set(evt.id, evt);
71
+ }
72
+ });
73
+ });
74
+ timedEvents = Array.from(eventMap.values());
75
+ } else {
76
+ timedEvents = [];
77
+ }
78
+ } else if (viewData.days && viewData.days.length > 0) {
79
+ // Enriched structure with days array
80
+ const dayDataItem = viewData.days.find(d => this.isSameDay(new Date(d.date), currentDate)) || viewData.days[0];
81
+ dayDate = new Date(dayDataItem.date);
82
+ dayName = dayNames[dayDate.getDay()];
83
+ isToday = this.isToday(dayDate);
84
+ const events = dayDataItem.events || [];
85
+ allDayEvents = events.filter(e => e.allDay);
86
+ timedEvents = events.filter(e => !e.allDay);
87
+ } else {
88
+ return null;
89
+ }
90
+
91
+ return { dayDate, dayName, isToday, allDayEvents, timedEvents };
92
+ }
93
+
94
+ _renderHeader(dayDate, dayName, isToday) {
95
+ return `
96
+ <div class="fc-day-header" style="display: grid; grid-template-columns: 60px 1fr; border-bottom: 1px solid #e5e7eb; background: #f9fafb; flex-shrink: 0;">
97
+ <div style="border-right: 1px solid #e5e7eb;"></div>
98
+ <div style="padding: 16px 24px;">
99
+ <div style="font-size: 12px; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.1em;">
100
+ ${dayName}
101
+ </div>
102
+ <div style="font-size: 24px; font-weight: 600; margin-top: 4px; ${isToday ? 'color: #dc2626;' : 'color: #111827;'}">
103
+ ${dayDate.getDate()}
104
+ </div>
105
+ </div>
106
+ </div>
107
+ `;
108
+ }
109
+
110
+ _renderAllDayRow(allDayEvents, dayDate) {
111
+ return `
112
+ <div class="fc-all-day-row" style="display: grid; grid-template-columns: 60px 1fr; border-bottom: 1px solid #e5e7eb; background: #fafafa; min-height: 36px; flex-shrink: 0;">
113
+ <div style="font-size: 9px; color: #6b7280; display: flex; align-items: center; justify-content: center; border-right: 1px solid #e5e7eb; text-transform: uppercase; font-weight: 700;">
114
+ All day
115
+ </div>
116
+ <div class="fc-all-day-cell" data-date="${dayDate.toISOString()}" style="padding: 6px 12px; display: flex; flex-wrap: wrap; gap: 4px;">
117
+ ${allDayEvents.map(evt => `
118
+ <div class="fc-event fc-all-day-event" data-event-id="${this.escapeHTML(evt.id)}"
119
+ style="background-color: ${evt.backgroundColor || '#2563eb'}; font-size: 12px; padding: 4px 8px; border-radius: 4px; color: white; cursor: pointer; font-weight: 500;">
120
+ ${this.escapeHTML(evt.title)}
121
+ </div>
122
+ `).join('')}
123
+ </div>
124
+ </div>
125
+ `;
126
+ }
127
+
128
+ _renderTimeGrid(timedEvents, isToday, dayDate, hours) {
129
+ return `
130
+ <div id="day-scroll-container" class="fc-time-grid-container" style="flex: 1; overflow-y: auto; overflow-x: hidden; position: relative;">
131
+ <div class="fc-time-grid" style="display: grid; grid-template-columns: 60px 1fr; position: relative; height: ${this.totalHeight}px;">
132
+ ${this._renderTimeGutter(hours)}
133
+ ${this._renderDayColumn(timedEvents, isToday, dayDate, hours)}
134
+ </div>
135
+ </div>
136
+ `;
137
+ }
138
+
139
+ _renderTimeGutter(hours) {
140
+ return `
141
+ <div class="fc-time-gutter" style="border-right: 1px solid #e5e7eb; background: #fafafa;">
142
+ ${hours.map(h => `
143
+ <div style="height: ${this.hourHeight}px; font-size: 11px; color: #6b7280; text-align: right; padding-right: 12px; font-weight: 500;">
144
+ ${h === 0 ? '' : this.formatHour(h)}
145
+ </div>
146
+ `).join('')}
147
+ </div>
148
+ `;
149
+ }
150
+
151
+ _renderDayColumn(timedEvents, isToday, dayDate, hours) {
152
+ return `
153
+ <div class="fc-day-column" data-date="${dayDate.toISOString()}" style="position: relative; cursor: pointer;">
154
+ <!-- Hour grid lines -->
155
+ ${hours.map(() => `<div style="height: ${this.hourHeight}px; border-bottom: 1px solid #f3f4f6;"></div>`).join('')}
156
+
157
+ <!-- Now indicator for today -->
158
+ ${isToday ? this.renderNowIndicator() : ''}
159
+
160
+ <!-- Timed events -->
161
+ ${timedEvents.map(evt => this.renderTimedEvent(evt, { compact: false })).join('')}
162
+ </div>
163
+ `;
164
+ }
165
+
166
+ _attachEventHandlers() {
167
+ // Day column click handler
168
+ this.container.querySelectorAll('.fc-day-column').forEach(dayEl => {
169
+ this.addListener(dayEl, 'click', (e) => {
170
+ if (e.target.closest('.fc-event')) return;
171
+
172
+ const date = new Date(dayEl.dataset.date);
173
+ const rect = dayEl.getBoundingClientRect();
174
+ const scrollContainer = this.container.querySelector('#day-scroll-container');
175
+ const y = e.clientY - rect.top + (scrollContainer ? scrollContainer.scrollTop : 0);
176
+
177
+ // Calculate time from click position
178
+ date.setHours(Math.floor(y / this.hourHeight), Math.floor((y % this.hourHeight) / (this.hourHeight / 60)), 0, 0);
179
+ this.stateManager.selectDate(date);
180
+ });
181
+ });
182
+
183
+ // Common event handlers (event clicks)
184
+ this.attachCommonEventHandlers();
185
+ }
186
+
187
+ _scrollToCurrentTime() {
188
+ if (this._scrolled) return;
189
+
190
+ const scrollContainer = this.container.querySelector('#day-scroll-container');
191
+ if (scrollContainer) {
192
+ // Scroll to 8 AM, minus some offset for visibility
193
+ scrollContainer.scrollTop = 8 * this.hourHeight - 50;
194
+ this._scrolled = true;
195
+ }
196
+ }
197
+ }
198
+
199
+ export default DayViewRenderer;