experiments in a post-browser web
at main 302 lines 7.8 kB view raw
1/** 2 * Data Binding Utilities for Components 3 * 4 * Provides reactive data binding capabilities for Peek components. 5 * Integrates with signals, schema validation, and external data sources. 6 * 7 * @example 8 * import { DataBoundElement } from './data-binding.js'; 9 * import { signal } from './signals.js'; 10 * 11 * class MyComponent extends DataBoundElement { 12 * static dataSchema = { 13 * type: 'object', 14 * properties: { 15 * title: { type: 'string' }, 16 * count: { type: 'integer', default: 0 } 17 * } 18 * }; 19 * 20 * render() { 21 * return html`<div>${this.data.title}: ${this.data.count}</div>`; 22 * } 23 * } 24 * 25 * // Bind to a signal 26 * const dataSignal = signal({ title: 'Hello', count: 5 }); 27 * const el = document.createElement('my-component'); 28 * el.bindTo(dataSignal); 29 */ 30 31import { PeekElement, sharedStyles } from './base.js'; 32import { effect } from './signals.js'; 33import { validate, applyDefaults } from './schema.js'; 34 35/** 36 * Mixin that adds data binding capabilities to a component 37 * 38 * @param {typeof LitElement} Base - Base class to extend 39 * @returns {typeof DataBoundElement} 40 */ 41export function DataBindingMixin(Base) { 42 return class extends Base { 43 static properties = { 44 ...Base.properties, 45 data: { type: Object } 46 }; 47 48 /** 49 * Optional JSON Schema for data validation. 50 * Override in subclass to enable validation. 51 * @type {Object|null} 52 */ 53 static dataSchema = null; 54 55 constructor() { 56 super(); 57 this._data = {}; 58 this._boundSource = null; 59 this._effectDispose = null; 60 this._subscriptions = []; 61 } 62 63 /** 64 * Get the current data object 65 */ 66 get data() { 67 return this._data; 68 } 69 70 /** 71 * Set data directly (validates if schema defined) 72 */ 73 set data(value) { 74 const schema = this.constructor.dataSchema; 75 if (schema) { 76 const result = validate(value, schema); 77 if (!result.valid) { 78 console.warn(`[${this.tagName}] Data validation failed:`, result.errors); 79 } 80 this._data = result.data; 81 } else { 82 this._data = value; 83 } 84 this.requestUpdate(); 85 } 86 87 /** 88 * Bind component to a reactive data source (signal, observable, etc.) 89 * 90 * @param {Object} source - Data source with .value property or subscribe method 91 * @param {Object} [options] 92 * @param {Function} [options.transform] - Transform data before setting 93 * @param {string} [options.path] - Dot-notation path to extract from source 94 * @returns {() => void} - Unbind function 95 * 96 * @example 97 * // Bind to signal 98 * const data = signal({ title: 'Hello' }); 99 * element.bindTo(data); 100 * 101 * // Bind with transform 102 * element.bindTo(rawData, { 103 * transform: (d) => ({ title: d.name, count: d.items.length }) 104 * }); 105 * 106 * // Bind to nested path 107 * element.bindTo(store, { path: 'user.profile' }); 108 */ 109 bindTo(source, options = {}) { 110 // Unbind previous source 111 this.unbind(); 112 113 this._boundSource = source; 114 const { transform, path } = options; 115 116 const updateData = () => { 117 let value; 118 119 // Get value from source 120 if ('value' in source) { 121 value = source.value; 122 } else if (typeof source.get === 'function') { 123 value = source.get(); 124 } else { 125 value = source; 126 } 127 128 // Extract nested path if specified 129 if (path) { 130 value = getPath(value, path); 131 } 132 133 // Apply transform if specified 134 if (transform) { 135 value = transform(value); 136 } 137 138 this.data = value; 139 }; 140 141 // Set up reactive subscription 142 if ('value' in source) { 143 // Signal-like source - use effect for automatic tracking 144 this._effectDispose = effect(() => { 145 // Access .value to track dependency 146 const _ = source.value; 147 updateData(); 148 }); 149 } else if (typeof source.subscribe === 'function') { 150 // Observable-like source 151 const unsubscribe = source.subscribe(updateData); 152 this._subscriptions.push(unsubscribe); 153 updateData(); // Initial value 154 } else { 155 // Static data - just set once 156 updateData(); 157 } 158 159 return () => this.unbind(); 160 } 161 162 /** 163 * Unbind from current data source 164 */ 165 unbind() { 166 if (this._effectDispose) { 167 this._effectDispose(); 168 this._effectDispose = null; 169 } 170 for (const unsub of this._subscriptions) { 171 unsub(); 172 } 173 this._subscriptions = []; 174 this._boundSource = null; 175 } 176 177 /** 178 * Check if component is bound to a data source 179 */ 180 get isBound() { 181 return this._boundSource !== null; 182 } 183 184 /** 185 * Update a single property in data (triggers re-render) 186 * @param {string} key - Property name 187 * @param {*} value - New value 188 */ 189 updateData(key, value) { 190 this.data = { ...this._data, [key]: value }; 191 } 192 193 /** 194 * Merge partial data into current data 195 * @param {Object} partial - Partial data to merge 196 */ 197 mergeData(partial) { 198 this.data = { ...this._data, ...partial }; 199 } 200 201 disconnectedCallback() { 202 super.disconnectedCallback(); 203 this.unbind(); 204 } 205 }; 206} 207 208/** 209 * Data-bound element base class 210 * Extends PeekElement with data binding capabilities 211 */ 212export class DataBoundElement extends DataBindingMixin(PeekElement) { 213 static styles = [sharedStyles]; 214} 215 216/** 217 * Get nested value from object using dot notation 218 * @param {Object} obj 219 * @param {string} path - e.g., "user.profile.name" 220 * @returns {*} 221 */ 222function getPath(obj, path) { 223 return path.split('.').reduce((current, key) => { 224 return current && current[key] !== undefined ? current[key] : undefined; 225 }, obj); 226} 227 228/** 229 * Create a data-bound component dynamically 230 * 231 * @param {string} tagName - Custom element tag name 232 * @param {Object} options 233 * @param {Function} options.render - Render function (data) => TemplateResult 234 * @param {Object} [options.schema] - Data schema 235 * @param {CSSResult[]} [options.styles] - Additional styles 236 * @returns {typeof DataBoundElement} 237 * 238 * @example 239 * const UserCard = createDataComponent('user-card', { 240 * schema: { 241 * type: 'object', 242 * properties: { 243 * name: { type: 'string' }, 244 * avatar: { type: 'string', format: 'uri' } 245 * } 246 * }, 247 * render: (data) => html` 248 * <img src=${data.avatar}> 249 * <span>${data.name}</span> 250 * ` 251 * }); 252 */ 253export function createDataComponent(tagName, options) { 254 const { render: renderFn, schema, styles = [] } = options; 255 256 class DynamicComponent extends DataBoundElement { 257 static dataSchema = schema; 258 static styles = [sharedStyles, ...styles]; 259 260 render() { 261 return renderFn(this.data, this); 262 } 263 } 264 265 customElements.define(tagName, DynamicComponent); 266 return DynamicComponent; 267} 268 269/** 270 * Decorator for data-bound properties 271 * Automatically validates against schema when property changes 272 * 273 * @param {Object} schema - Property schema 274 */ 275export function validatedProperty(schema) { 276 return function(target, propertyKey) { 277 const privateKey = `_${propertyKey}`; 278 279 Object.defineProperty(target, propertyKey, { 280 get() { 281 return this[privateKey]; 282 }, 283 set(value) { 284 const result = validate(value, schema); 285 if (!result.valid) { 286 console.warn(`[${this.tagName}] Property '${propertyKey}' validation failed:`, result.errors); 287 } 288 this[privateKey] = result.data; 289 this.requestUpdate(); 290 }, 291 configurable: true, 292 enumerable: true 293 }); 294 }; 295} 296 297export default { 298 DataBindingMixin, 299 DataBoundElement, 300 createDataComponent, 301 validatedProperty 302};