@forcecalendar/interface 1.0.59 → 1.0.61

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": "1.0.59",
3
+ "version": "1.0.61",
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",
@@ -54,7 +54,7 @@
54
54
  "devDependencies": {
55
55
  "@babel/core": "^7.28.5",
56
56
  "@babel/preset-env": "^7.28.5",
57
- "@forcecalendar/core": "^2.1.54",
57
+ "@forcecalendar/core": "^2.1.68",
58
58
  "babel-jest": "^30.2.0",
59
59
  "eslint": "^8.57.1",
60
60
  "jest": "^30.2.0",
@@ -253,12 +253,12 @@ export class EventForm extends BaseComponent {
253
253
  ${this.config.colors
254
254
  .map(
255
255
  c => `
256
- <button type="button"
257
- class="color-btn ${c.color === this._formData.color ? 'selected' : ''}"
258
- style="background-color: ${c.color}"
259
- data-color="${c.color}"
260
- title="${c.label}"
261
- aria-label="${c.label}"
256
+ <button type="button"
257
+ class="color-btn ${c.color === this._formData.color ? 'selected' : ''}"
258
+ style="background-color: ${StyleUtils.sanitizeColor(c.color)}"
259
+ data-color="${DOMUtils.escapeHTML(c.color)}"
260
+ title="${DOMUtils.escapeHTML(c.label)}"
261
+ aria-label="${DOMUtils.escapeHTML(c.label)}"
262
262
  aria-checked="${c.color === this._formData.color ? 'true' : 'false'}"
263
263
  role="radio"></button>
264
264
  `
@@ -37,6 +37,38 @@ export class ForceCalendar extends BaseComponent {
37
37
  this._busUnsubscribers = [];
38
38
  }
39
39
 
40
+ /**
41
+ * Route observed-attribute changes into the StateManager.
42
+ * Without this, attribute updates after mount (view, locale, timezone,
43
+ * week-starts-on, date) only re-rendered from stale state and had no
44
+ * visible effect. 'height' is presentational and picked up by render().
45
+ */
46
+ propChanged(name, oldValue, newValue) {
47
+ if (!this.stateManager || oldValue === newValue) return;
48
+
49
+ switch (name) {
50
+ case 'view':
51
+ if (newValue) this.stateManager.setView(newValue);
52
+ break;
53
+ case 'date': {
54
+ const date = newValue ? new Date(newValue) : null;
55
+ if (date && !isNaN(date.getTime())) this.stateManager.setDate(date);
56
+ break;
57
+ }
58
+ case 'locale':
59
+ if (newValue) this.stateManager.updateConfig({ locale: newValue });
60
+ break;
61
+ case 'timezone':
62
+ if (newValue) this.stateManager.updateConfig({ timeZone: newValue });
63
+ break;
64
+ case 'week-starts-on': {
65
+ const day = parseInt(newValue, 10);
66
+ if (!Number.isNaN(day)) this.stateManager.updateConfig({ weekStartsOn: day });
67
+ break;
68
+ }
69
+ }
70
+ }
71
+
40
72
  initialize() {
41
73
  // Initialize state manager with config from attributes
42
74
  const config = {
@@ -183,8 +183,13 @@ export class BaseComponent extends HTMLElement {
183
183
  */
184
184
  _restoreScrollPositions(positions) {
185
185
  if (!this._contentWrapper || positions.size === 0) return;
186
+ // CSS.escape may be absent in sandboxed or test environments
187
+ const escapeId =
188
+ typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
189
+ ? CSS.escape
190
+ : id => String(id).replace(/["\\]/g, '\\$&');
186
191
  positions.forEach((pos, id) => {
187
- const el = this._contentWrapper.querySelector(`[id="${CSS.escape(id)}"]`);
192
+ const el = this._contentWrapper.querySelector(`[id="${escapeId(id)}"]`);
188
193
  if (el) {
189
194
  el.scrollTop = pos.top;
190
195
  el.scrollLeft = pos.left;
@@ -129,14 +129,24 @@ export class DOMUtils {
129
129
 
130
130
  /**
131
131
  * Wait for animation/transition to complete
132
+ * Resolves after `timeout` ms even if the event never fires (animation
133
+ * cancelled, element removed from DOM, etc.) so awaiting callers can't hang.
134
+ * @param {Element} element - Element to listen on
135
+ * @param {string} [eventType='animationend'] - Event to wait for
136
+ * @param {number} [timeout=3000] - Max wait in ms; 0 disables the timeout
132
137
  */
133
- static waitForAnimation(element, eventType = 'animationend') {
138
+ static waitForAnimation(element, eventType = 'animationend', timeout = 3000) {
134
139
  return new Promise(resolve => {
140
+ let timeoutId = null;
135
141
  const handler = () => {
142
+ if (timeoutId !== null) clearTimeout(timeoutId);
136
143
  element.removeEventListener(eventType, handler);
137
144
  resolve();
138
145
  };
139
146
  element.addEventListener(eventType, handler);
147
+ if (timeout > 0) {
148
+ timeoutId = setTimeout(handler, timeout);
149
+ }
140
150
  });
141
151
  }
142
152
 
@@ -149,13 +159,48 @@ export class DOMUtils {
149
159
 
150
160
  /**
151
161
  * Parse HTML string safely
162
+ * Sanitizes by default: strips script-capable elements, inline event
163
+ * handlers and javascript: URLs so the returned node is inert even if the
164
+ * input contains an XSS payload. Pass { sanitize: false } only for trusted,
165
+ * non-user-controlled markup.
152
166
  */
153
- static parseHTML(htmlString) {
167
+ static parseHTML(htmlString, { sanitize = true } = {}) {
154
168
  const template = document.createElement('template');
155
169
  template.innerHTML = htmlString.trim();
170
+ if (sanitize) {
171
+ this._sanitizeNode(template.content);
172
+ }
156
173
  return template.content.firstChild;
157
174
  }
158
175
 
176
+ /**
177
+ * Remove script-capable elements, on* handlers and javascript: URLs
178
+ * from a parsed DOM fragment (in place)
179
+ */
180
+ static _sanitizeNode(root) {
181
+ const DANGEROUS_TAGS = 'script, iframe, object, embed, link, meta, base';
182
+ root.querySelectorAll(DANGEROUS_TAGS).forEach(el => el.remove());
183
+
184
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
185
+ let node = walker.nextNode();
186
+ while (node) {
187
+ for (const attr of Array.from(node.attributes)) {
188
+ const name = attr.name.toLowerCase();
189
+ // Browsers ignore control chars/whitespace inside URL schemes,
190
+ // so strip them before checking for javascript:/data: payloads
191
+ const value = attr.value.replace(/[\u0000-\u0020]/g, '').toLowerCase();
192
+ if (
193
+ name.startsWith('on') ||
194
+ value.startsWith('javascript:') ||
195
+ value.startsWith('data:text/html')
196
+ ) {
197
+ node.removeAttribute(attr.name);
198
+ }
199
+ }
200
+ node = walker.nextNode();
201
+ }
202
+ }
203
+
159
204
  /**
160
205
  * Escape HTML to prevent XSS
161
206
  */
@@ -234,14 +279,13 @@ export class DOMUtils {
234
279
  }
235
280
 
236
281
  /**
237
- * Clone element with event listeners
282
+ * Clone an element. Event listeners are NOT copied — the Web Platform
283
+ * provides no way to enumerate listeners, so callers must re-attach their
284
+ * own handlers to the clone.
285
+ * @deprecated Use element.cloneNode(deep) directly and re-bind listeners.
238
286
  */
239
287
  static cloneWithEvents(element, deep = true) {
240
- const clone = element.cloneNode(deep);
241
-
242
- // Copy event listeners (Note: This is a simplified version)
243
- // In production, you'd need a more robust event copying mechanism
244
- return clone;
288
+ return element.cloneNode(deep);
245
289
  }
246
290
 
247
291
  /**
@@ -266,13 +310,18 @@ export class DOMUtils {
266
310
  const handleKeyDown = e => {
267
311
  if (e.key !== 'Tab') return;
268
312
 
313
+ // Inside Shadow DOM, document.activeElement reports the host element;
314
+ // the shadow root's own activeElement gives the truly focused node.
315
+ const root = container.getRootNode();
316
+ const activeElement = root.activeElement ?? document.activeElement;
317
+
269
318
  if (e.shiftKey) {
270
- if (document.activeElement === firstFocusable) {
319
+ if (activeElement === firstFocusable) {
271
320
  lastFocusable?.focus();
272
321
  e.preventDefault();
273
322
  }
274
323
  } else {
275
- if (document.activeElement === lastFocusable) {
324
+ if (activeElement === lastFocusable) {
276
325
  firstFocusable?.focus();
277
326
  e.preventDefault();
278
327
  }
@@ -321,9 +321,9 @@ export class StyleUtils {
321
321
  */
322
322
  static getContrastColor(bgColor) {
323
323
  const color = bgColor.replace('#', '');
324
- const r = parseInt(color.substr(0, 2), 16);
325
- const g = parseInt(color.substr(2, 2), 16);
326
- const b = parseInt(color.substr(4, 2), 16);
324
+ const r = parseInt(color.substring(0, 2), 16);
325
+ const g = parseInt(color.substring(2, 4), 16);
326
+ const b = parseInt(color.substring(4, 6), 16);
327
327
  const yiq = (r * 299 + g * 587 + b * 114) / 1000;
328
328
  return yiq >= 128 ? '#000000' : '#FFFFFF';
329
329
  }
@@ -398,9 +398,9 @@ export class StyleUtils {
398
398
  */
399
399
  static hexToRgba(hex, alpha = 1) {
400
400
  const color = hex.replace('#', '');
401
- const r = parseInt(color.substr(0, 2), 16);
402
- const g = parseInt(color.substr(2, 2), 16);
403
- const b = parseInt(color.substr(4, 2), 16);
401
+ const r = parseInt(color.substring(0, 2), 16);
402
+ const g = parseInt(color.substring(2, 4), 16);
403
+ const b = parseInt(color.substring(4, 6), 16);
404
404
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
405
405
  }
406
406