experiments in a post-browser web
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};