@forcecalendar/interface 1.0.27 → 1.0.28
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/README.md +9 -0
- package/dist/force-calendar-interface.esm.js +102 -55
- package/dist/force-calendar-interface.esm.js.map +1 -1
- package/dist/force-calendar-interface.umd.js.map +1 -1
- package/package.json +3 -1
- package/src/components/EventForm.js +180 -176
- package/src/components/ForceCalendar.js +414 -392
- package/src/core/BaseComponent.js +146 -144
- package/src/core/EventBus.js +197 -197
- package/src/core/StateManager.js +405 -399
- package/src/index.js +3 -3
- package/src/renderers/BaseViewRenderer.js +195 -192
- package/src/renderers/DayViewRenderer.js +133 -118
- package/src/renderers/MonthViewRenderer.js +74 -72
- package/src/renderers/WeekViewRenderer.js +118 -96
- package/src/utils/DOMUtils.js +277 -277
- package/src/utils/DateUtils.js +164 -164
- package/src/utils/StyleUtils.js +286 -249
package/src/index.js
CHANGED
|
@@ -27,6 +27,6 @@ export { ForceCalendar } from './components/ForceCalendar.js';
|
|
|
27
27
|
|
|
28
28
|
// Auto-register main component if in browser environment
|
|
29
29
|
if (typeof window !== 'undefined' && typeof customElements !== 'undefined') {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
30
|
+
// The ForceCalendar component self-registers
|
|
31
|
+
console.log('Force Calendar Interface loading...');
|
|
32
|
+
}
|
|
@@ -10,176 +10,179 @@ import { DateUtils } from '../utils/DateUtils.js';
|
|
|
10
10
|
import { StyleUtils } from '../utils/StyleUtils.js';
|
|
11
11
|
|
|
12
12
|
export class BaseViewRenderer {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 (
|
|
72
|
+
date.getDate() === today.getDate() &&
|
|
73
|
+
date.getMonth() === today.getMonth() &&
|
|
74
|
+
date.getFullYear() === today.getFullYear()
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if two dates are the same day
|
|
80
|
+
* @param {Date} date1
|
|
81
|
+
* @param {Date} date2
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
isSameDay(date1, date2) {
|
|
85
|
+
return (
|
|
86
|
+
date1.getDate() === date2.getDate() &&
|
|
87
|
+
date1.getMonth() === date2.getMonth() &&
|
|
88
|
+
date1.getFullYear() === date2.getFullYear()
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Format hour for display (e.g., "9 AM", "2 PM")
|
|
94
|
+
* @param {number} hour - Hour in 24-hour format (0-23)
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
formatHour(hour) {
|
|
98
|
+
const period = hour >= 12 ? 'PM' : 'AM';
|
|
99
|
+
const displayHour = hour % 12 || 12;
|
|
100
|
+
return `${displayHour} ${period}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Format time for display (e.g., "9 AM", "2:30 PM")
|
|
105
|
+
* @param {Date} date
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
formatTime(date) {
|
|
109
|
+
const hours = date.getHours();
|
|
110
|
+
const minutes = date.getMinutes();
|
|
111
|
+
const period = hours >= 12 ? 'PM' : 'AM';
|
|
112
|
+
const displayHour = hours % 12 || 12;
|
|
113
|
+
return minutes === 0
|
|
114
|
+
? `${displayHour} ${period}`
|
|
115
|
+
: `${displayHour}:${minutes.toString().padStart(2, '0')} ${period}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get contrasting text color for a background color
|
|
120
|
+
* Uses WCAG luminance formula
|
|
121
|
+
* @param {string} bgColor - Hex color string
|
|
122
|
+
* @returns {string} 'black' or 'white'
|
|
123
|
+
*/
|
|
124
|
+
getContrastingTextColor(bgColor) {
|
|
125
|
+
if (!bgColor || typeof bgColor !== 'string') return 'white';
|
|
126
|
+
|
|
127
|
+
const color = bgColor.charAt(0) === '#' ? bgColor.substring(1) : bgColor;
|
|
128
|
+
|
|
129
|
+
if (!/^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/.test(color)) {
|
|
130
|
+
return 'white';
|
|
22
131
|
}
|
|
23
132
|
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
}
|
|
133
|
+
const fullColor =
|
|
134
|
+
color.length === 3 ? color[0] + color[0] + color[1] + color[1] + color[2] + color[2] : color;
|
|
41
135
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
}
|
|
136
|
+
const r = parseInt(fullColor.substring(0, 2), 16);
|
|
137
|
+
const g = parseInt(fullColor.substring(2, 4), 16);
|
|
138
|
+
const b = parseInt(fullColor.substring(4, 6), 16);
|
|
63
139
|
|
|
64
|
-
|
|
65
|
-
|
|
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();
|
|
140
|
+
if (isNaN(r) || isNaN(g) || isNaN(b)) {
|
|
141
|
+
return 'white';
|
|
74
142
|
}
|
|
75
143
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 = this.getEventColor(event);
|
|
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 `
|
|
144
|
+
const uicolors = [r / 255, g / 255, b / 255];
|
|
145
|
+
const c = uicolors.map(col => {
|
|
146
|
+
if (col <= 0.03928) {
|
|
147
|
+
return col / 12.92;
|
|
148
|
+
}
|
|
149
|
+
return Math.pow((col + 0.055) / 1.055, 2.4);
|
|
150
|
+
});
|
|
151
|
+
const L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
|
152
|
+
return L > 0.179 ? 'black' : 'white';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Render the "now" indicator line for time-based views
|
|
157
|
+
* @returns {string} HTML string
|
|
158
|
+
*/
|
|
159
|
+
renderNowIndicator() {
|
|
160
|
+
const now = new Date();
|
|
161
|
+
const minutes = now.getHours() * 60 + now.getMinutes();
|
|
162
|
+
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>`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Render a timed event block
|
|
167
|
+
* @param {Object} event - Event object
|
|
168
|
+
* @param {Object} options - Rendering options
|
|
169
|
+
* @returns {string} HTML string
|
|
170
|
+
*/
|
|
171
|
+
renderTimedEvent(event, options = {}) {
|
|
172
|
+
const { compact = true } = options;
|
|
173
|
+
const start = new Date(event.start);
|
|
174
|
+
const end = new Date(event.end);
|
|
175
|
+
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
|
176
|
+
const durationMinutes = Math.max((end - start) / (1000 * 60), compact ? 20 : 30);
|
|
177
|
+
const color = this.getEventColor(event);
|
|
178
|
+
|
|
179
|
+
const padding = compact ? '4px 8px' : '8px 12px';
|
|
180
|
+
const fontSize = compact ? '11px' : '13px';
|
|
181
|
+
const margin = compact ? '2px' : '12px';
|
|
182
|
+
const rightMargin = compact ? '2px' : '24px';
|
|
183
|
+
const borderRadius = compact ? '4px' : '6px';
|
|
184
|
+
|
|
185
|
+
return `
|
|
183
186
|
<div class="fc-event fc-timed-event" data-event-id="${this.escapeHTML(event.id)}"
|
|
184
187
|
style="position: absolute; top: ${startMinutes}px; height: ${durationMinutes}px;
|
|
185
188
|
left: ${margin}; right: ${rightMargin};
|
|
@@ -196,34 +199,34 @@ export class BaseViewRenderer {
|
|
|
196
199
|
</div>
|
|
197
200
|
</div>
|
|
198
201
|
`;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get a safe, sanitized event color value.
|
|
206
|
+
* @param {Object} event
|
|
207
|
+
* @returns {string}
|
|
208
|
+
*/
|
|
209
|
+
getEventColor(event) {
|
|
210
|
+
return StyleUtils.sanitizeColor(event?.backgroundColor, '#2563eb');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Attach common event handlers for day/event clicks
|
|
215
|
+
*/
|
|
216
|
+
attachCommonEventHandlers() {
|
|
217
|
+
// Delegate event clicks at container level to avoid rebinding per event node.
|
|
218
|
+
this.addListener(this.container, 'click', e => {
|
|
219
|
+
const eventEl = e.target.closest('.fc-event');
|
|
220
|
+
if (!eventEl || !this.container.contains(eventEl)) return;
|
|
221
|
+
|
|
222
|
+
e.stopPropagation();
|
|
223
|
+
const eventId = eventEl.dataset.eventId;
|
|
224
|
+
const event = this.stateManager.getEvents().find(ev => ev.id === eventId);
|
|
225
|
+
if (event) {
|
|
226
|
+
this.stateManager.selectEvent(event);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
227
230
|
}
|
|
228
231
|
|
|
229
232
|
export default BaseViewRenderer;
|