UNPKG

@arnelirobles/rnxjs

Version:

Minimalist Vanilla JS component system with reactive data binding.

390 lines (338 loc) 12.3 kB
/** * Data binding system for rnxJS * Processes data-bind attributes and synchronizes DOM with reactive state */ // Track subscriptions for cleanup const bindingSubscriptions = new WeakMap(); /** * Get nested property value from object * @param {Object} obj - Source object * @param {string} path - Dot-notation path (e.g., 'user.email') * @returns {*} - Property value or undefined */ function getNestedValue(obj, path) { try { return path.split('.').reduce((current, key) => current?.[key], obj); } catch (error) { console.error(`[rnxJS] Error getting nested value for path "${path}":`, error); return undefined; } } /** * Set nested property value in object * Creates intermediate objects if they don't exist * @param {Object} obj - Target object * @param {string} path - Dot-notation path * @param {*} value - Value to set */ function setNestedValue(obj, path, value) { try { const keys = path.split('.'); const lastKey = keys.pop(); // Create intermediate objects const target = keys.reduce((current, key) => { if (typeof current[key] !== 'object' || current[key] === null) { current[key] = {}; } return current[key]; }, obj); // Set the final value target[lastKey] = value; } catch (error) { console.error(`[rnxJS] Error setting nested value for path "${path}":`, error); } } /** * Coerce value to appropriate type based on input element * @param {HTMLElement} element - Input element * @param {*} value - Value to coerce * @returns {*} - Coerced value */ function coerceValueToType(element, value) { const type = element.type; try { if (type === 'number') { const num = parseFloat(value); return isNaN(num) ? 0 : num; } if (type === 'date' || type === 'datetime-local') { return value; // Keep as string for now, can be enhanced } if (type === 'checkbox') { return Boolean(value); } return value; } catch (error) { console.error(`[rnxJS] Error coercing value for type "${type}":`, error); return value; } } /** * Validate a value against a set of rules * @param {*} value - Value to validate * @param {string} rules - Pipe-separated rules (e.g., "required|email|min:3") * @returns {string|null} - Error message or null if valid */ function validateField(value, rules) { if (!rules) return null; const ruleList = rules.split('|'); for (const rule of ruleList) { const [name, param] = rule.split(':'); if (name === 'required') { if (value === null || value === undefined || value === '') { return 'This field is required'; } } if (name === 'email') { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (value && !emailRegex.test(String(value))) { return 'Invalid email address'; } } if (name === 'numeric') { if (value && isNaN(Number(value))) { return 'Must be a number'; } } if (name === 'min') { const min = parseFloat(param); if (typeof value === 'string' && value.length < min) { return `Must be at least ${min} characters`; } if (typeof value === 'number' && value < min) { return `Must be at least ${min}`; } } if (name === 'max') { const max = parseFloat(param); if (typeof value === 'string' && value.length > max) { return `Must be no more than ${max} characters`; } if (typeof value === 'number' && value > max) { return `Must be no more than ${max}`; } } if (name === 'pattern') { try { const regex = new RegExp(param); if (value && !regex.test(String(value))) { return 'Invalid format'; } } catch (e) { console.warn('[rnxJS] Invalid regex pattern in validation rule:', param); } } } return null; } /** * Bind data-bind attributes to reactive state * Sets up two-way binding for inputs and one-way binding for display elements * @param {HTMLElement} rootElement - Root element to search for data-bind attributes * @param {Proxy} state - Reactive state object created by createReactiveState */ export function bindData(rootElement = document, state = null) { // Validation if (!rootElement || typeof rootElement.querySelectorAll !== 'function') { console.error('[rnxJS] bindData: rootElement must be a valid DOM element'); return; } if (!state) { console.warn('[rnxJS] bindData called without a reactive state object. Skipping data binding.'); return; } if (typeof state.subscribe !== 'function') { console.error('[rnxJS] bindData: state must be a reactive state object with subscribe method'); return; } // Initialize errors object in state if it doesn't exist if (!state.errors) { try { state.errors = {}; } catch (e) { // State might be sealed or not extensible, proceed without validation support if so console.warn('[rnxJS] Could not initialize state.errors. Validation may not work.'); } } // Track subscriptions for this root element if (!bindingSubscriptions.has(rootElement)) { bindingSubscriptions.set(rootElement, []); } const subscriptions = bindingSubscriptions.get(rootElement); // Find all elements with data-bind attribute const boundElements = rootElement.querySelectorAll('[data-bind]'); boundElements.forEach(element => { const path = element.getAttribute('data-bind'); if (!path || typeof path !== 'string') { console.warn('[rnxJS] data-bind attribute is empty or invalid on element:', element); return; } // Validate path format (basic check) if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/.test(path)) { console.warn(`[rnxJS] Invalid data-bind path "${path}" on element:`, element); return; } const isInput = element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT'; try { if (isInput) { // Two-way binding for form elements const unsubscribe = setupTwoWayBinding(element, state, path); if (unsubscribe) { subscriptions.push(unsubscribe); } } else { // One-way binding for display elements const unsubscribe = setupOneWayBinding(element, state, path); if (unsubscribe) { subscriptions.push(unsubscribe); } } } catch (error) { console.error(`[rnxJS] Error setting up binding for path "${path}":`, error); } }); } /** * Unbind all data bindings for a root element * @param {HTMLElement} rootElement - Root element to unbind */ export function unbindData(rootElement) { if (!bindingSubscriptions.has(rootElement)) { return; } const subscriptions = bindingSubscriptions.get(rootElement); subscriptions.forEach(unsubscribe => { try { unsubscribe(); } catch (error) { console.error('[rnxJS] Error unsubscribing:', error); } }); bindingSubscriptions.delete(rootElement); } /** * Set up two-way binding for input elements * @param {HTMLElement} element - Input element * @param {Proxy} state - Reactive state * @param {string} path - Property path * @returns {Function} - Unsubscribe function */ function setupTwoWayBinding(element, state, path) { const inputType = element.type; const rules = element.getAttribute('data-rule'); // Initialize element value from state const initialValue = getNestedValue(state, path); if (initialValue !== undefined) { updateInputValue(element, initialValue, inputType); // Initial validation if (rules && state.errors) { const error = validateField(initialValue, rules); setNestedValue(state.errors, path, error || ''); } } // Listen for user input const eventType = (inputType === 'checkbox' || inputType === 'radio') ? 'change' : 'input'; const inputHandler = (e) => { try { let value = getInputValue(e.target); // Apply type coercion value = coerceValueToType(e.target, value); setNestedValue(state, path, value); // Validation if (rules && state.errors) { const error = validateField(value, rules); setNestedValue(state.errors, path, error || ''); } } catch (error) { console.error(`[rnxJS] Error handling input for path "${path}":`, error); } }; element.addEventListener(eventType, inputHandler); // Subscribe to state changes const unsubscribe = state.subscribe(path, (newValue) => { try { // Only update if value is different to avoid infinite loops const currentValue = getInputValue(element); if (currentValue !== newValue) { updateInputValue(element, newValue, inputType); // Re-validate on external state change if (rules && state.errors) { const error = validateField(newValue, rules); setNestedValue(state.errors, path, error || ''); } } } catch (error) { console.error(`[rnxJS] Error updating input for path "${path}":`, error); } }); // Return combined cleanup function return () => { element.removeEventListener(eventType, inputHandler); unsubscribe(); }; } /** * Set up one-way binding for display elements * @param {HTMLElement} element - Display element * @param {Proxy} state - Reactive state * @param {string} path - Property path * @returns {Function} - Unsubscribe function */ function setupOneWayBinding(element, state, path) { // Initialize element content from state const initialValue = getNestedValue(state, path); if (initialValue !== undefined) { element.textContent = initialValue; } // Subscribe to state changes return state.subscribe(path, (newValue) => { try { element.textContent = newValue ?? ''; } catch (error) { console.error(`[rnxJS] Error updating display for path "${path}":`, error); } }); } /** * Get value from input element based on type * @param {HTMLElement} element - Input element * @returns {*} - Input value (string, boolean, or array for multi-select) */ function getInputValue(element) { const type = element.type; if (type === 'checkbox') { return element.checked; } if (type === 'radio') { return element.value; } if (element.tagName === 'SELECT' && element.multiple) { return Array.from(element.selectedOptions).map(opt => opt.value); } return element.value; } /** * Update input element value based on type * @param {HTMLElement} element - Input element * @param {*} value - New value * @param {string} type - Input type */ function updateInputValue(element, value, type) { try { if (type === 'checkbox') { element.checked = !!value; } else if (type === 'radio') { element.checked = (element.value === value); } else if (element.tagName === 'SELECT' && element.multiple) { Array.from(element.options).forEach(opt => { opt.selected = Array.isArray(value) && value.includes(opt.value); }); } else { element.value = value ?? ''; } } catch (error) { console.error('[rnxJS] Error updating input value:', error); } }