/** * Data Binding Utilities for Components * * Provides reactive data binding capabilities for Peek components. * Integrates with signals, schema validation, and external data sources. * * @example * import { DataBoundElement } from './data-binding.js'; * import { signal } from './signals.js'; * * class MyComponent extends DataBoundElement { * static dataSchema = { * type: 'object', * properties: { * title: { type: 'string' }, * count: { type: 'integer', default: 0 } * } * }; * * render() { * return html`
${this.data.title}: ${this.data.count}
`; * } * } * * // Bind to a signal * const dataSignal = signal({ title: 'Hello', count: 5 }); * const el = document.createElement('my-component'); * el.bindTo(dataSignal); */ import { PeekElement, sharedStyles } from './base.js'; import { effect } from './signals.js'; import { validate, applyDefaults } from './schema.js'; /** * Mixin that adds data binding capabilities to a component * * @param {typeof LitElement} Base - Base class to extend * @returns {typeof DataBoundElement} */ export function DataBindingMixin(Base) { return class extends Base { static properties = { ...Base.properties, data: { type: Object } }; /** * Optional JSON Schema for data validation. * Override in subclass to enable validation. * @type {Object|null} */ static dataSchema = null; constructor() { super(); this._data = {}; this._boundSource = null; this._effectDispose = null; this._subscriptions = []; } /** * Get the current data object */ get data() { return this._data; } /** * Set data directly (validates if schema defined) */ set data(value) { const schema = this.constructor.dataSchema; if (schema) { const result = validate(value, schema); if (!result.valid) { console.warn(`[${this.tagName}] Data validation failed:`, result.errors); } this._data = result.data; } else { this._data = value; } this.requestUpdate(); } /** * Bind component to a reactive data source (signal, observable, etc.) * * @param {Object} source - Data source with .value property or subscribe method * @param {Object} [options] * @param {Function} [options.transform] - Transform data before setting * @param {string} [options.path] - Dot-notation path to extract from source * @returns {() => void} - Unbind function * * @example * // Bind to signal * const data = signal({ title: 'Hello' }); * element.bindTo(data); * * // Bind with transform * element.bindTo(rawData, { * transform: (d) => ({ title: d.name, count: d.items.length }) * }); * * // Bind to nested path * element.bindTo(store, { path: 'user.profile' }); */ bindTo(source, options = {}) { // Unbind previous source this.unbind(); this._boundSource = source; const { transform, path } = options; const updateData = () => { let value; // Get value from source if ('value' in source) { value = source.value; } else if (typeof source.get === 'function') { value = source.get(); } else { value = source; } // Extract nested path if specified if (path) { value = getPath(value, path); } // Apply transform if specified if (transform) { value = transform(value); } this.data = value; }; // Set up reactive subscription if ('value' in source) { // Signal-like source - use effect for automatic tracking this._effectDispose = effect(() => { // Access .value to track dependency const _ = source.value; updateData(); }); } else if (typeof source.subscribe === 'function') { // Observable-like source const unsubscribe = source.subscribe(updateData); this._subscriptions.push(unsubscribe); updateData(); // Initial value } else { // Static data - just set once updateData(); } return () => this.unbind(); } /** * Unbind from current data source */ unbind() { if (this._effectDispose) { this._effectDispose(); this._effectDispose = null; } for (const unsub of this._subscriptions) { unsub(); } this._subscriptions = []; this._boundSource = null; } /** * Check if component is bound to a data source */ get isBound() { return this._boundSource !== null; } /** * Update a single property in data (triggers re-render) * @param {string} key - Property name * @param {*} value - New value */ updateData(key, value) { this.data = { ...this._data, [key]: value }; } /** * Merge partial data into current data * @param {Object} partial - Partial data to merge */ mergeData(partial) { this.data = { ...this._data, ...partial }; } disconnectedCallback() { super.disconnectedCallback(); this.unbind(); } }; } /** * Data-bound element base class * Extends PeekElement with data binding capabilities */ export class DataBoundElement extends DataBindingMixin(PeekElement) { static styles = [sharedStyles]; } /** * Get nested value from object using dot notation * @param {Object} obj * @param {string} path - e.g., "user.profile.name" * @returns {*} */ function getPath(obj, path) { return path.split('.').reduce((current, key) => { return current && current[key] !== undefined ? current[key] : undefined; }, obj); } /** * Create a data-bound component dynamically * * @param {string} tagName - Custom element tag name * @param {Object} options * @param {Function} options.render - Render function (data) => TemplateResult * @param {Object} [options.schema] - Data schema * @param {CSSResult[]} [options.styles] - Additional styles * @returns {typeof DataBoundElement} * * @example * const UserCard = createDataComponent('user-card', { * schema: { * type: 'object', * properties: { * name: { type: 'string' }, * avatar: { type: 'string', format: 'uri' } * } * }, * render: (data) => html` * * ${data.name} * ` * }); */ export function createDataComponent(tagName, options) { const { render: renderFn, schema, styles = [] } = options; class DynamicComponent extends DataBoundElement { static dataSchema = schema; static styles = [sharedStyles, ...styles]; render() { return renderFn(this.data, this); } } customElements.define(tagName, DynamicComponent); return DynamicComponent; } /** * Decorator for data-bound properties * Automatically validates against schema when property changes * * @param {Object} schema - Property schema */ export function validatedProperty(schema) { return function(target, propertyKey) { const privateKey = `_${propertyKey}`; Object.defineProperty(target, propertyKey, { get() { return this[privateKey]; }, set(value) { const result = validate(value, schema); if (!result.valid) { console.warn(`[${this.tagName}] Property '${propertyKey}' validation failed:`, result.errors); } this[privateKey] = result.data; this.requestUpdate(); }, configurable: true, enumerable: true }); }; } export default { DataBindingMixin, DataBoundElement, createDataComponent, validatedProperty };