@forcecalendar/interface 1.0.17 → 1.0.19

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.
@@ -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;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * MonthViewRenderer - Renders month calendar grid
3
+ *
4
+ * Pure JavaScript renderer for month view, compatible with Salesforce Locker Service.
5
+ */
6
+
7
+ import { BaseViewRenderer } from './BaseViewRenderer.js';
8
+
9
+ export class MonthViewRenderer extends BaseViewRenderer {
10
+ constructor(container, stateManager) {
11
+ super(container, stateManager);
12
+ this.maxEventsToShow = 3;
13
+ }
14
+
15
+ render() {
16
+ if (!this.container || !this.stateManager) return;
17
+
18
+ const viewData = this.stateManager.getViewData();
19
+ if (!viewData || !viewData.weeks) {
20
+ this.container.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">No data available for month view.</div>';
21
+ return;
22
+ }
23
+
24
+ this.cleanup();
25
+ const config = this.stateManager.getState().config;
26
+ const html = this._renderMonthView(viewData, config);
27
+ this.container.innerHTML = html;
28
+ this._attachEventHandlers();
29
+ }
30
+
31
+ _renderMonthView(viewData, config) {
32
+ const weekStartsOn = config.weekStartsOn || 0;
33
+ const dayNames = this._getDayNames(weekStartsOn);
34
+
35
+ let html = `
36
+ <div class="fc-month-view" style="display: flex; flex-direction: column; height: 100%; min-height: 400px; background: #fff; border: 1px solid #e5e7eb;">
37
+ <div class="fc-month-header" style="display: grid; grid-template-columns: repeat(7, 1fr); border-bottom: 1px solid #e5e7eb; background: #f9fafb;">
38
+ ${dayNames.map(d => `<div class="fc-month-header-cell" style="padding: 12px 8px; text-align: center; font-size: 11px; font-weight: 600; color: #6b7280; text-transform: uppercase;">${d}</div>`).join('')}
39
+ </div>
40
+ <div class="fc-month-body" style="display: flex; flex-direction: column; flex: 1;">
41
+ `;
42
+
43
+ viewData.weeks.forEach(week => {
44
+ html += this._renderWeek(week);
45
+ });
46
+
47
+ html += '</div></div>';
48
+ return html;
49
+ }
50
+
51
+ _getDayNames(weekStartsOn) {
52
+ const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
53
+ const dayNames = [];
54
+ for (let i = 0; i < 7; i++) {
55
+ const dayIndex = (weekStartsOn + i) % 7;
56
+ dayNames.push(days[dayIndex]);
57
+ }
58
+ return dayNames;
59
+ }
60
+
61
+ _renderWeek(week) {
62
+ let html = '<div class="fc-month-week" style="display: grid; grid-template-columns: repeat(7, 1fr); flex: 1; min-height: 80px;">';
63
+
64
+ week.days.forEach(day => {
65
+ html += this._renderDay(day);
66
+ });
67
+
68
+ html += '</div>';
69
+ return html;
70
+ }
71
+
72
+ _renderDay(day) {
73
+ const isOtherMonth = !day.isCurrentMonth;
74
+ const isToday = day.isToday;
75
+
76
+ const dayBg = isOtherMonth ? '#f3f4f6' : '#fff';
77
+ const dayNumColor = isOtherMonth ? '#9ca3af' : '#111827';
78
+ const todayStyle = isToday
79
+ ? 'background: #2563eb; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;'
80
+ : '';
81
+
82
+ const events = day.events || [];
83
+ const visibleEvents = events.slice(0, this.maxEventsToShow);
84
+ const moreCount = events.length - this.maxEventsToShow;
85
+
86
+ return `
87
+ <div class="fc-month-day" data-date="${day.date}"
88
+ style="background: ${dayBg}; border-right: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb; padding: 4px; min-height: 80px; cursor: pointer; display: flex; flex-direction: column;">
89
+ <div class="fc-day-number" style="font-size: 13px; font-weight: 500; color: ${dayNumColor}; padding: 2px 4px; margin-bottom: 4px; ${todayStyle}">
90
+ ${day.dayOfMonth}
91
+ </div>
92
+ <div class="fc-day-events" style="display: flex; flex-direction: column; gap: 2px; flex: 1; overflow: hidden;">
93
+ ${visibleEvents.map(evt => this._renderEvent(evt)).join('')}
94
+ ${moreCount > 0 ? `<div class="fc-more-events" style="font-size: 10px; color: #6b7280; padding: 2px 4px; font-weight: 500;">+${moreCount} more</div>` : ''}
95
+ </div>
96
+ </div>
97
+ `;
98
+ }
99
+
100
+ _renderEvent(event) {
101
+ const color = event.backgroundColor || '#2563eb';
102
+ return `
103
+ <div class="fc-event" data-event-id="${this.escapeHTML(event.id)}"
104
+ style="background-color: ${color}; font-size: 11px; padding: 2px 6px; border-radius: 3px; color: white; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer;">
105
+ ${this.escapeHTML(event.title)}
106
+ </div>
107
+ `;
108
+ }
109
+
110
+ _attachEventHandlers() {
111
+ // Day click handlers
112
+ this.container.querySelectorAll('.fc-month-day').forEach(dayEl => {
113
+ this.addListener(dayEl, 'click', (e) => {
114
+ if (e.target.closest('.fc-event')) return;
115
+ const date = new Date(dayEl.dataset.date);
116
+ this.stateManager.selectDate(date);
117
+ });
118
+ });
119
+
120
+ // Common event handlers (event clicks)
121
+ this.attachCommonEventHandlers();
122
+ }
123
+ }
124
+
125
+ export default MonthViewRenderer;