/** * LEDMatrix URL Input Widget * * URL input with validation and protocol handling. * * Schema example: * { * "website": { * "type": "string", * "format": "uri", * "x-widget": "url-input", * "x-options": { * "placeholder": "https://example.com", * "showIcon": true, * "allowedProtocols": ["http", "https"], * "showPreview": true * } * } * } * * @module UrlInputWidget */ (function() { 'use strict'; const base = window.BaseWidget ? new window.BaseWidget('UrlInput', '1.0.0') : null; function escapeHtml(text) { if (base) return base.escapeHtml(text); const div = document.createElement('div'); div.textContent = String(text); return div.innerHTML.replace(/"/g, '"').replace(/'/g, '''); } function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); } function triggerChange(fieldId, value) { if (base) { base.triggerChange(fieldId, value); } else { const event = new CustomEvent('widget-change', { detail: { fieldId, value }, bubbles: true, cancelable: true }); document.dispatchEvent(event); } } // RFC 3986 scheme pattern: starts with letter, then letters/digits/+/./- const RFC_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*$/; /** * Normalize and validate protocol list against RFC 3986 scheme pattern. * Accepts schemes like "http", "https", "git+ssh", "android-app", etc. * @param {Array|string} protocols - Protocol list (array or comma-separated string) * @returns {Array} Normalized lowercase protocols, defaults to ['http', 'https'] */ function normalizeProtocols(protocols) { let list = protocols; if (typeof list === 'string') { list = list.split(',').map(p => p.trim()).filter(p => p); } else if (!Array.isArray(list)) { return ['http', 'https']; } const normalized = list .map(p => String(p).trim()) .filter(p => RFC_SCHEME_PATTERN.test(p)) .map(p => p.toLowerCase()); return normalized.length > 0 ? normalized : ['http', 'https']; } function isValidUrl(string, allowedProtocols) { try { const url = new URL(string); if (allowedProtocols && allowedProtocols.length > 0) { const protocol = url.protocol.replace(':', '').toLowerCase(); return allowedProtocols.includes(protocol); } return true; } catch (_) { return false; } } window.LEDMatrixWidgets.register('url-input', { name: 'URL Input Widget', version: '1.0.0', render: function(container, config, value, options) { const fieldId = sanitizeId(options.fieldId || container.id || 'url_input'); const xOptions = config['x-options'] || config['x_options'] || {}; const placeholder = xOptions.placeholder || 'https://example.com'; const showIcon = xOptions.showIcon !== false; const showPreview = xOptions.showPreview === true; // Normalize allowedProtocols using RFC 3986 validation const allowedProtocols = normalizeProtocols(xOptions.allowedProtocols); const disabled = xOptions.disabled === true; const required = xOptions.required === true; const currentValue = value || ''; // Escape the protocols for safe HTML attribute interpolation const escapedProtocols = escapeHtml(allowedProtocols.join(',')); let html = `
`; html += '
'; if (showIcon) { html += `
`; } html += ` `; html += '
'; // Preview link (if enabled and value exists) if (showPreview) { html += `
Open link in new tab
`; } // Error message area html += ``; html += '
'; container.innerHTML = html; }, getValue: function(fieldId) { const safeId = sanitizeId(fieldId); const input = document.getElementById(`${safeId}_input`); return input ? input.value : ''; }, setValue: function(fieldId, value) { const safeId = sanitizeId(fieldId); const input = document.getElementById(`${safeId}_input`); if (input) { input.value = value || ''; this.handlers.onInput(fieldId); } }, validate: function(fieldId) { const safeId = sanitizeId(fieldId); const input = document.getElementById(`${safeId}_input`); const errorEl = document.getElementById(`${safeId}_error`); const widget = document.getElementById(`${safeId}_widget`); if (!input) return { valid: true, errors: [] }; const value = input.value; const protocols = normalizeProtocols(widget?.dataset.protocols); let isValid = true; let errorMsg = ''; // First check browser validation (required, type, etc.) if (!input.checkValidity()) { isValid = false; errorMsg = input.validationMessage; } else if (value) { // Then check custom protocol validation if (!isValidUrl(value, protocols)) { isValid = false; errorMsg = `Please enter a valid URL (${protocols.join(', ')} only)`; } } if (errorEl) { if (!isValid) { errorEl.textContent = errorMsg; errorEl.classList.remove('hidden'); input.classList.add('border-red-500'); } else { errorEl.classList.add('hidden'); input.classList.remove('border-red-500'); } } return { valid: isValid, errors: isValid ? [] : [errorMsg] }; }, handlers: { onChange: function(fieldId) { const widget = window.LEDMatrixWidgets.get('url-input'); widget.validate(fieldId); triggerChange(fieldId, widget.getValue(fieldId)); }, onInput: function(fieldId) { const safeId = sanitizeId(fieldId); const input = document.getElementById(`${safeId}_input`); const previewEl = document.getElementById(`${safeId}_preview`); const previewLink = document.getElementById(`${safeId}_preview_link`); const widgetEl = document.getElementById(`${safeId}_widget`); const value = input?.value || ''; const protocols = normalizeProtocols(widgetEl?.dataset.protocols); if (previewEl && previewLink) { if (value && isValidUrl(value, protocols)) { previewLink.href = value; previewEl.classList.remove('hidden'); } else { previewEl.classList.add('hidden'); } } // Validate on input const widget = window.LEDMatrixWidgets.get('url-input'); widget.validate(fieldId); } } }); console.log('[UrlInputWidget] URL input widget registered'); })();