@forcecalendar/interface 1.0.59 → 1.0.60

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.60",
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
  `
@@ -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