/** * Component Directives Implementation * * This file provides built-in directives for components: * 1. Event Binding ($on directives) * - $on: Bind DOM events to component events * - Supports event delegation and parameters * - Handles function, string, and array event handlers * - Type-safe event target handling * * 2. Two-way Data Binding ($bind directive) * - $bind: Sync form elements with component state * - Handles input types: text, checkbox, radio, number, range * - Supports select (single/multiple) and textarea elements * - Automatic value type conversion for numbers * - Support for complex property paths * * 3. Custom Directives * - Extensible directive system via '$' events * - Processes virtual DOM during rendering * - Supports custom attribute directives * * Features: * - Automatic state synchronization * - Type conversion for form inputs * - Event delegation support * - Multiple event handler formats * - Nested property binding * - Custom directive extensibility * * Type Safety Improvements (v3.35.1): * - Added null checks for event targets before type assertions * - Proper typing for different HTML element types * - Enhanced error handling for invalid event targets * - Safer DOM element property access * * Nested State Binding Support (v3.37.4): * - Enhanced $bind directive to support nested object and array paths * - Supports dot notation: 'user.name', 'user.profile.settings.theme' * - Supports bracket notation: 'items[0]', 'users[1].name' * - Supports mixed notation: 'users[0].settings.theme', 'data["key"].value' * - Safe traversal with automatic intermediate object/array creation * - Maintains backward compatibility with simple property binding * * Usage: * ```tsx * // Event binding * * setState(e.target.value)} /> * * // Simple two-way binding * * * * // Nested object binding * * * * // Array element binding * * * * // Mixed nested binding * * * * // Array handlers * * ``` */ import app from './app'; import { safeEventTarget } from './type-utils'; /** * Parse a path string into an array of keys and indices * Supports paths like: 'a.b', 'a[0]', 'a[0].b', 'a["key"]' */ const parsePath = (path: string): Array => { if (!path) return []; const keys: Array = []; let current = ''; let inBracket = false; let quoteChar = ''; for (let i = 0; i < path.length; i++) { const char = path[i]; if (char === '[' && !inBracket) { if (current) { keys.push(current); current = ''; } inBracket = true; } else if (char === ']' && inBracket) { if (quoteChar) { // Remove quotes from string keys current = current.slice(1, -1); } else if (/^\d+$/.test(current)) { // Convert numeric strings to numbers current = parseInt(current, 10) as any; } keys.push(current); current = ''; inBracket = false; quoteChar = ''; } else if ((char === '"' || char === "'") && inBracket) { if (!quoteChar) { quoteChar = char; } else if (char === quoteChar) { quoteChar = ''; } current += char; } else if (char === '.' && !inBracket) { if (current) { keys.push(current); current = ''; } } else { current += char; } } if (current) { keys.push(current); } return keys; }; /** * Safely get a nested value from an object using a path */ const getNestedValue = (obj: any, path: Array): any => { let current = obj; for (const key of path) { if (current == null) return undefined; current = current[key]; } return current; }; /** * Safely set a nested value in an object using a path * Creates intermediate objects/arrays as needed */ const setNestedValue = (obj: any, path: Array, value: any): any => { if (path.length === 0) return value; const result = { ...obj }; let current = result; for (let i = 0; i < path.length - 1; i++) { const key = path[i]; const nextKey = path[i + 1]; if (current[key] == null) { // Create array if next key is numeric, object otherwise current[key] = typeof nextKey === 'number' ? [] : {}; } else if (Array.isArray(current[key])) { current[key] = [...current[key]]; } else if (typeof current[key] === 'object') { current[key] = { ...current[key] }; } current = current[key]; } current[path[path.length - 1]] = value; return result; }; const getStateValue = (component, name) => { if (!name) return component['state'] || ''; const path = parsePath(name); const value = getNestedValue(component['state'], path); return value !== undefined ? value : ''; } const setStateValue = (component, name, value) => { if (!name) { component.setState(value); return; } const path = parsePath(name); const currentState = component['state'] || {}; const newState = setNestedValue(currentState, path, value); component.setState(newState); } const apply_directive = (key: string, props: {}, tag, component) => { if (key.startsWith('$on')) { const event = props[key]; key = key.substring(1) if (typeof event === 'boolean') { props[key] = e => component.run ? component.run(key, e) : app.run(key, e); } else if (typeof event === 'string') { props[key] = e => component.run ? component.run(event, e) : app.run(event, e); } else if (typeof event === 'function') { props[key] = e => component.setState(event(component.state, e)); } else if (Array.isArray(event)) { const [handler, ...p] = event; if (typeof handler === 'string') { props[key] = e => component.run ? component.run(handler, ...p, e) : app.run(handler, ...p, e); } else if (typeof handler === 'function') { props[key] = e => component.setState(handler(component.state, ...p, e)); } } } else if (key === '$bind') { const type = props['type'] || 'text'; const name = typeof props[key] === 'string' ? props[key] : props['name']; if (tag === 'input') { switch (type) { case 'checkbox': props['checked'] = getStateValue(component, name); props['onclick'] = e => { const target = safeEventTarget(e); if (target) { setStateValue(component, name || target.name, target.checked); } }; break; case 'radio': props['checked'] = getStateValue(component, name) === props['value']; props['onclick'] = e => { const target = safeEventTarget(e); if (target) { setStateValue(component, name || target.name, target.value); } }; break; case 'number': case 'range': props['value'] = getStateValue(component, name); props['oninput'] = e => { const target = safeEventTarget(e); if (target) { setStateValue(component, name || target.name, Number(target.value)); } }; break; default: props['value'] = getStateValue(component, name); props['oninput'] = e => { const target = safeEventTarget(e); if (target) { setStateValue(component, name || target.name, target.value); } }; } } else if (tag === 'select') { props['value'] = getStateValue(component, name); props['onchange'] = e => { const target = safeEventTarget(e); if (target && !target.multiple) { // multiple selection use $bind on option setStateValue(component, name || target.name, target.value); } } } else if (tag === 'option') { props['selected'] = getStateValue(component, name); props['onclick'] = e => { const target = safeEventTarget(e); if (target) { setStateValue(component, name || (target as any).name, target.selected); } }; } else if (tag === 'textarea') { props['innerHTML'] = getStateValue(component, name); props['oninput'] = e => { const target = safeEventTarget(e); if (target) { setStateValue(component, name || target.name, target.value); } }; } } else { app.run('$', { key, tag, props, component }); } } const directive = (vdom, component) => { if (Array.isArray(vdom)) { return vdom.map(element => directive(element, component)); } else { let { type, tag, props, children } = vdom; tag = tag || type; children = children || props?.children; if (props) Object.keys(props).forEach(key => { if (key.startsWith('$')) { apply_directive(key, props, tag, component); delete props[key]; } }); if (children) directive(children, component); return vdom; } } export default directive;