experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(components): add Phase 2 reactive system - signals, schema, data binding, events

+1678 -4
+236
app/components/README.md
··· 342 342 343 343 --- 344 344 345 + ## Reactive System 346 + 347 + ### Signals 348 + 349 + Reactive primitives for state management. Native JavaScript implementation following the TC39 Signals proposal pattern. 350 + 351 + ```javascript 352 + import { signal, computed, effect, batch, watch } from 'peek://app/components/signals.js'; 353 + 354 + // Create reactive value 355 + const count = signal(0); 356 + console.log(count.value); // 0 357 + 358 + // Computed values auto-update 359 + const doubled = computed(() => count.value * 2); 360 + 361 + // Effects run when dependencies change 362 + const dispose = effect(() => { 363 + console.log(`Count: ${count.value}, Doubled: ${doubled.value}`); 364 + }); 365 + 366 + // Update triggers effect 367 + count.value = 5; // Logs: "Count: 5, Doubled: 10" 368 + 369 + // Batch multiple updates 370 + batch(() => { 371 + count.value = 10; 372 + // other updates... 373 + }); // Effects run once at end 374 + 375 + // Watch specific signal 376 + const stop = watch(count, (newVal, oldVal) => { 377 + console.log(`Changed from ${oldVal} to ${newVal}`); 378 + }); 379 + 380 + // Cleanup 381 + dispose(); 382 + stop(); 383 + ``` 384 + 385 + #### Signal API 386 + 387 + | Function | Description | 388 + |----------|-------------| 389 + | `signal(value)` | Create reactive value with `.value` getter/setter | 390 + | `computed(fn)` | Create derived value that auto-updates | 391 + | `effect(fn)` | Run side effects when dependencies change | 392 + | `batch(fn)` | Batch updates, run effects once at end | 393 + | `watch(signal, handler)` | Watch specific signal for changes | 394 + | `fromExternal(get, set, subscribe)` | Bridge external state to signals | 395 + 396 + --- 397 + 398 + ### Schema Validation 399 + 400 + Lightweight JSON Schema validation for component data. 401 + 402 + ```javascript 403 + import { validate, createValidator, Schema } from 'peek://app/components/schema.js'; 404 + 405 + // Define schema 406 + const userSchema = { 407 + type: 'object', 408 + required: ['name', 'email'], 409 + properties: { 410 + name: { type: 'string', minLength: 1 }, 411 + email: { type: 'string', format: 'email' }, 412 + age: { type: 'integer', minimum: 0, default: 0 } 413 + } 414 + }; 415 + 416 + // Validate data 417 + const result = validate({ name: 'Alice', email: 'alice@example.com' }, userSchema); 418 + // { valid: true, errors: [], data: { name: 'Alice', email: 'alice@example.com', age: 0 } } 419 + 420 + // Create reusable validator 421 + const validateUser = createValidator(userSchema); 422 + validateUser({ name: '', email: 'invalid' }); 423 + // { valid: false, errors: [...] } 424 + 425 + // Schema builders 426 + const schema = Schema.object({ 427 + title: Schema.string({ minLength: 1 }), 428 + count: Schema.integer({ minimum: 0 }), 429 + tags: Schema.array(Schema.string()) 430 + }, { required: ['title'] }); 431 + ``` 432 + 433 + #### Supported Keywords 434 + 435 + | Keyword | Types | Description | 436 + |---------|-------|-------------| 437 + | `type` | all | `string`, `number`, `integer`, `boolean`, `array`, `object`, `null` | 438 + | `required` | object | Array of required property names | 439 + | `properties` | object | Property schemas | 440 + | `items` | array | Schema for array items | 441 + | `enum` | all | Allowed values | 442 + | `minimum`, `maximum` | number | Number bounds | 443 + | `minLength`, `maxLength` | string | String length | 444 + | `minItems`, `maxItems` | array | Array length | 445 + | `pattern` | string | Regex pattern | 446 + | `format` | string | `email`, `uri`, `date`, `date-time`, `uuid` | 447 + | `default` | all | Default value | 448 + 449 + --- 450 + 451 + ### Data Binding 452 + 453 + Bind components to reactive data sources with automatic updates. 454 + 455 + ```javascript 456 + import { DataBoundElement, createDataComponent } from 'peek://app/components/data-binding.js'; 457 + import { signal } from 'peek://app/components/signals.js'; 458 + import { html, css } from 'lit'; 459 + 460 + // Extend DataBoundElement 461 + class UserCard extends DataBoundElement { 462 + static dataSchema = { 463 + type: 'object', 464 + properties: { 465 + name: { type: 'string' }, 466 + avatar: { type: 'string', format: 'uri' } 467 + } 468 + }; 469 + 470 + render() { 471 + return html` 472 + <img src=${this.data.avatar}> 473 + <span>${this.data.name}</span> 474 + `; 475 + } 476 + } 477 + customElements.define('user-card', UserCard); 478 + 479 + // Bind to signal 480 + const userData = signal({ name: 'Alice', avatar: 'https://...' }); 481 + const card = document.querySelector('user-card'); 482 + card.bindTo(userData); 483 + 484 + // Updates automatically when signal changes 485 + userData.value = { name: 'Bob', avatar: 'https://...' }; 486 + 487 + // Or create data component dynamically 488 + const StatusBadge = createDataComponent('status-badge', { 489 + schema: { type: 'object', properties: { status: { type: 'string' } } }, 490 + render: (data) => html`<span class=${data.status}>${data.status}</span>` 491 + }); 492 + ``` 493 + 494 + #### DataBoundElement API 495 + 496 + | Method | Description | 497 + |--------|-------------| 498 + | `bindTo(source, options)` | Bind to signal, observable, or data source | 499 + | `unbind()` | Disconnect from data source | 500 + | `updateData(key, value)` | Update single property | 501 + | `mergeData(partial)` | Merge partial data into current | 502 + | `data` | Get/set the data object | 503 + | `isBound` | Check if bound to a source | 504 + 505 + --- 506 + 507 + ### Event Bus 508 + 509 + Cross-component communication that works across Shadow DOM. 510 + 511 + ```javascript 512 + import { on, emit, channel, waitFor, EventBusMixin } from 'peek://app/components/events.js'; 513 + 514 + // Subscribe to events 515 + const unsubscribe = on('user:login', (user) => { 516 + console.log('User logged in:', user.name); 517 + }); 518 + 519 + // Emit events 520 + emit('user:login', { name: 'Alice', id: 123 }); 521 + 522 + // Wildcard subscriptions 523 + on('user:*', (data, eventName) => { 524 + console.log(`User event: ${eventName}`, data); 525 + }); 526 + 527 + // Namespaced channels 528 + const userChannel = channel('user'); 529 + userChannel.on('login', handler); 530 + userChannel.emit('login', userData); 531 + userChannel.onAny(handler); // All 'user:*' events 532 + 533 + // Promise-based waiting 534 + const user = await waitFor('user:login', { timeout: 5000 }); 535 + 536 + // Replay last value 537 + emit('config:loaded', config, { retain: true }); 538 + on('config:loaded', handler, { replay: true }); // Gets config immediately 539 + 540 + // Unsubscribe 541 + unsubscribe.unsubscribe(); 542 + ``` 543 + 544 + #### Component Integration 545 + 546 + ```javascript 547 + import { EventBusMixin } from 'peek://app/components/events.js'; 548 + import { PeekElement } from 'peek://app/components/base.js'; 549 + 550 + class MyComponent extends EventBusMixin(PeekElement) { 551 + connectedCallback() { 552 + super.connectedCallback(); 553 + // Auto-cleanup on disconnect 554 + this.subscribe('data:update', this.handleUpdate); 555 + } 556 + 557 + handleUpdate = (data) => { 558 + this.data = data; 559 + } 560 + 561 + save() { 562 + this.publish('data:saved', this.data); 563 + } 564 + } 565 + ``` 566 + 567 + #### Event Bus API 568 + 569 + | Function | Description | 570 + |----------|-------------| 571 + | `on(event, handler, options)` | Subscribe to event | 572 + | `once(event, handler)` | Subscribe once | 573 + | `emit(event, data, options)` | Emit event | 574 + | `channel(namespace)` | Create namespaced channel | 575 + | `waitFor(event, options)` | Promise-based event waiting | 576 + | `typedEvent(name)` | Create typed event emitter | 577 + | `EventBusMixin(Base)` | Mixin for auto-cleanup subscriptions | 578 + 579 + --- 580 + 345 581 ## Browser Support 346 582 347 583 Components use modern CSS features:
+302
app/components/data-binding.js
··· 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 + 31 + import { PeekElement, sharedStyles } from './base.js'; 32 + import { effect } from './signals.js'; 33 + import { 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 + */ 41 + export 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 + */ 212 + export 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 + */ 222 + function 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 + */ 253 + export 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 + */ 275 + export 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 + 297 + export default { 298 + DataBindingMixin, 299 + DataBoundElement, 300 + createDataComponent, 301 + validatedProperty 302 + };
+390
app/components/events.js
··· 1 + /** 2 + * Component Communication Bus 3 + * 4 + * A lightweight pubsub system for cross-component communication. 5 + * Works across Shadow DOM boundaries and supports typed events. 6 + * 7 + * @example 8 + * import { bus, on, emit, channel } from './events.js'; 9 + * 10 + * // Subscribe to events 11 + * const unsubscribe = on('user:login', (user) => { 12 + * console.log('User logged in:', user); 13 + * }); 14 + * 15 + * // Publish events 16 + * emit('user:login', { name: 'Alice', id: 123 }); 17 + * 18 + * // Create typed channels 19 + * const userChannel = channel('user'); 20 + * userChannel.on('login', handler); 21 + * userChannel.emit('login', userData); 22 + */ 23 + 24 + /** 25 + * @typedef {Object} Subscription 26 + * @property {() => void} unsubscribe - Stop receiving events 27 + * @property {boolean} active - Whether subscription is active 28 + */ 29 + 30 + /** 31 + * Event bus instance 32 + */ 33 + class EventBus { 34 + constructor() { 35 + /** @type {Map<string, Set<Function>>} */ 36 + this._handlers = new Map(); 37 + /** @type {Map<string, *>} */ 38 + this._lastValues = new Map(); 39 + } 40 + 41 + /** 42 + * Subscribe to an event 43 + * @param {string} event - Event name (supports wildcards: 'user:*') 44 + * @param {Function} handler - Event handler 45 + * @param {Object} [options] 46 + * @param {boolean} [options.once=false] - Only handle once then unsubscribe 47 + * @param {boolean} [options.replay=false] - Replay last value immediately if available 48 + * @returns {Subscription} 49 + */ 50 + on(event, handler, options = {}) { 51 + const { once = false, replay = false } = options; 52 + 53 + let wrappedHandler = handler; 54 + let active = true; 55 + 56 + if (once) { 57 + wrappedHandler = (...args) => { 58 + if (active) { 59 + active = false; 60 + this._removeHandler(event, wrappedHandler); 61 + handler(...args); 62 + } 63 + }; 64 + } 65 + 66 + if (!this._handlers.has(event)) { 67 + this._handlers.set(event, new Set()); 68 + } 69 + this._handlers.get(event).add(wrappedHandler); 70 + 71 + // Replay last value if requested and available 72 + if (replay && this._lastValues.has(event)) { 73 + queueMicrotask(() => { 74 + if (active) { 75 + wrappedHandler(this._lastValues.get(event)); 76 + } 77 + }); 78 + } 79 + 80 + return { 81 + unsubscribe: () => { 82 + active = false; 83 + this._removeHandler(event, wrappedHandler); 84 + }, 85 + get active() { 86 + return active; 87 + } 88 + }; 89 + } 90 + 91 + /** 92 + * Subscribe to event, receiving only the next occurrence 93 + * @param {string} event 94 + * @param {Function} handler 95 + * @returns {Subscription} 96 + */ 97 + once(event, handler) { 98 + return this.on(event, handler, { once: true }); 99 + } 100 + 101 + /** 102 + * Emit an event to all subscribers 103 + * @param {string} event - Event name 104 + * @param {*} [data] - Event data 105 + * @param {Object} [options] 106 + * @param {boolean} [options.retain=false] - Store value for replay 107 + */ 108 + emit(event, data, options = {}) { 109 + const { retain = false } = options; 110 + 111 + if (retain) { 112 + this._lastValues.set(event, data); 113 + } 114 + 115 + // Exact match handlers 116 + const handlers = this._handlers.get(event); 117 + if (handlers) { 118 + for (const handler of [...handlers]) { 119 + try { 120 + handler(data); 121 + } catch (error) { 122 + console.error(`[EventBus] Error in handler for '${event}':`, error); 123 + } 124 + } 125 + } 126 + 127 + // Wildcard handlers (e.g., 'user:*' matches 'user:login') 128 + for (const [pattern, patternHandlers] of this._handlers) { 129 + if (pattern.endsWith(':*')) { 130 + const prefix = pattern.slice(0, -1); 131 + if (event.startsWith(prefix) && pattern !== event) { 132 + for (const handler of [...patternHandlers]) { 133 + try { 134 + handler(data, event); 135 + } catch (error) { 136 + console.error(`[EventBus] Error in wildcard handler for '${event}':`, error); 137 + } 138 + } 139 + } 140 + } 141 + } 142 + } 143 + 144 + /** 145 + * Remove all handlers for an event 146 + * @param {string} event 147 + */ 148 + off(event) { 149 + this._handlers.delete(event); 150 + this._lastValues.delete(event); 151 + } 152 + 153 + /** 154 + * Remove all handlers and clear state 155 + */ 156 + clear() { 157 + this._handlers.clear(); 158 + this._lastValues.clear(); 159 + } 160 + 161 + /** 162 + * Get count of handlers for an event 163 + * @param {string} event 164 + * @returns {number} 165 + */ 166 + listenerCount(event) { 167 + return this._handlers.get(event)?.size ?? 0; 168 + } 169 + 170 + _removeHandler(event, handler) { 171 + const handlers = this._handlers.get(event); 172 + if (handlers) { 173 + handlers.delete(handler); 174 + if (handlers.size === 0) { 175 + this._handlers.delete(event); 176 + } 177 + } 178 + } 179 + } 180 + 181 + /** 182 + * Global event bus instance 183 + */ 184 + export const bus = new EventBus(); 185 + 186 + /** 187 + * Subscribe to an event on the global bus 188 + * @param {string} event 189 + * @param {Function} handler 190 + * @param {Object} [options] 191 + * @returns {Subscription} 192 + */ 193 + export function on(event, handler, options) { 194 + return bus.on(event, handler, options); 195 + } 196 + 197 + /** 198 + * Subscribe once to an event 199 + * @param {string} event 200 + * @param {Function} handler 201 + * @returns {Subscription} 202 + */ 203 + export function once(event, handler) { 204 + return bus.once(event, handler); 205 + } 206 + 207 + /** 208 + * Emit an event on the global bus 209 + * @param {string} event 210 + * @param {*} [data] 211 + * @param {Object} [options] 212 + */ 213 + export function emit(event, data, options) { 214 + bus.emit(event, data, options); 215 + } 216 + 217 + /** 218 + * Create a namespaced channel 219 + * 220 + * @param {string} namespace - Channel namespace (e.g., 'user', 'editor') 221 + * @returns {Channel} 222 + * 223 + * @example 224 + * const userChannel = channel('user'); 225 + * userChannel.on('login', (user) => console.log(user)); 226 + * userChannel.emit('login', { name: 'Alice' }); 227 + * // Equivalent to: bus.emit('user:login', { name: 'Alice' }) 228 + */ 229 + export function channel(namespace) { 230 + return { 231 + on(event, handler, options) { 232 + return bus.on(`${namespace}:${event}`, handler, options); 233 + }, 234 + once(event, handler) { 235 + return bus.once(`${namespace}:${event}`, handler); 236 + }, 237 + emit(event, data, options) { 238 + bus.emit(`${namespace}:${event}`, data, options); 239 + }, 240 + off(event) { 241 + bus.off(`${namespace}:${event}`); 242 + }, 243 + /** 244 + * Subscribe to all events in this channel 245 + */ 246 + onAny(handler, options) { 247 + return bus.on(`${namespace}:*`, handler, options); 248 + } 249 + }; 250 + } 251 + 252 + /** 253 + * Create a typed event emitter for a specific event 254 + * 255 + * @template T 256 + * @param {string} event - Event name 257 + * @returns {{ emit: (data: T) => void, on: (handler: (data: T) => void) => Subscription }} 258 + * 259 + * @example 260 + * const userLogin = typedEvent('user:login'); 261 + * userLogin.on((user) => console.log(user.name)); 262 + * userLogin.emit({ name: 'Alice', id: 123 }); 263 + */ 264 + export function typedEvent(event) { 265 + return { 266 + emit(data, options) { 267 + bus.emit(event, data, options); 268 + }, 269 + on(handler, options) { 270 + return bus.on(event, handler, options); 271 + }, 272 + once(handler) { 273 + return bus.once(event, handler); 274 + } 275 + }; 276 + } 277 + 278 + /** 279 + * Wait for an event (Promise-based) 280 + * 281 + * @param {string} event - Event to wait for 282 + * @param {Object} [options] 283 + * @param {number} [options.timeout] - Timeout in ms (rejects if exceeded) 284 + * @param {Function} [options.filter] - Only resolve if filter returns true 285 + * @returns {Promise<*>} 286 + * 287 + * @example 288 + * const userData = await waitFor('user:login', { timeout: 5000 }); 289 + */ 290 + export function waitFor(event, options = {}) { 291 + const { timeout, filter } = options; 292 + 293 + return new Promise((resolve, reject) => { 294 + let timeoutId; 295 + let subscription; 296 + 297 + const cleanup = () => { 298 + if (timeoutId) clearTimeout(timeoutId); 299 + if (subscription) subscription.unsubscribe(); 300 + }; 301 + 302 + subscription = bus.on(event, (data) => { 303 + if (!filter || filter(data)) { 304 + cleanup(); 305 + resolve(data); 306 + } 307 + }); 308 + 309 + if (timeout) { 310 + timeoutId = setTimeout(() => { 311 + cleanup(); 312 + reject(new Error(`Timeout waiting for event: ${event}`)); 313 + }, timeout); 314 + } 315 + }); 316 + } 317 + 318 + /** 319 + * Mixin that adds event bus methods to a component 320 + * 321 + * @param {typeof LitElement} Base 322 + * @returns {typeof LitElement} 323 + * 324 + * @example 325 + * class MyComponent extends EventBusMixin(LitElement) { 326 + * connectedCallback() { 327 + * super.connectedCallback(); 328 + * this.subscribe('data:update', this.handleUpdate); 329 + * } 330 + * 331 + * handleUpdate = (data) => { 332 + * this.data = data; 333 + * } 334 + * } 335 + */ 336 + export function EventBusMixin(Base) { 337 + return class extends Base { 338 + constructor() { 339 + super(); 340 + this._busSubscriptions = []; 341 + } 342 + 343 + /** 344 + * Subscribe to an event (auto-cleanup on disconnect) 345 + */ 346 + subscribe(event, handler, options) { 347 + const sub = bus.on(event, handler, options); 348 + this._busSubscriptions.push(sub); 349 + return sub; 350 + } 351 + 352 + /** 353 + * Emit an event 354 + */ 355 + publish(event, data, options) { 356 + bus.emit(event, data, options); 357 + } 358 + 359 + disconnectedCallback() { 360 + super.disconnectedCallback(); 361 + // Clean up all subscriptions 362 + for (const sub of this._busSubscriptions) { 363 + sub.unsubscribe(); 364 + } 365 + this._busSubscriptions = []; 366 + } 367 + }; 368 + } 369 + 370 + /** 371 + * Create a new isolated event bus instance 372 + * Useful for testing or isolated component trees 373 + * 374 + * @returns {EventBus} 375 + */ 376 + export function createBus() { 377 + return new EventBus(); 378 + } 379 + 380 + export default { 381 + bus, 382 + on, 383 + once, 384 + emit, 385 + channel, 386 + typedEvent, 387 + waitFor, 388 + EventBusMixin, 389 + createBus 390 + };
+16 -4
app/components/index.js
··· 8 8 * Usage: 9 9 * import 'peek://app/components/index.js'; 10 10 * 11 - * Or import individual components: 12 - * import 'peek://app/components/peek-button.js'; 13 - * import 'peek://app/components/peek-card.js'; 14 - * import 'peek://app/components/peek-list.js'; 11 + * Or import individual modules: 12 + * import { signal, effect } from 'peek://app/components/signals.js'; 13 + * import { on, emit } from 'peek://app/components/events.js'; 14 + * import { DataBoundElement } from 'peek://app/components/data-binding.js'; 15 15 * 16 16 * Components automatically register with the custom elements registry. 17 17 * ··· 22 22 23 23 // Base utilities 24 24 export { PeekElement, sharedStyles } from './base.js'; 25 + 26 + // Reactive system 27 + export { signal, computed, effect, batch, watch, fromExternal } from './signals.js'; 28 + 29 + // Schema validation 30 + export { validate, createValidator, assertValid, isValid, Schema } from './schema.js'; 31 + 32 + // Data binding 33 + export { DataBoundElement, DataBindingMixin, createDataComponent } from './data-binding.js'; 34 + 35 + // Event bus 36 + export { bus, on, once, emit, channel, typedEvent, waitFor, EventBusMixin } from './events.js'; 25 37 26 38 // Components 27 39 export { PeekButton } from './peek-button.js';
+390
app/components/schema.js
··· 1 + /** 2 + * Lightweight Schema Validation 3 + * 4 + * A native JSON Schema validator supporting common validation patterns. 5 + * No external dependencies - covers typical component data validation needs. 6 + * 7 + * Supported JSON Schema keywords: 8 + * - type: string, number, integer, boolean, array, object, null 9 + * - required: array of required property names 10 + * - properties: object property schemas 11 + * - items: array item schema 12 + * - enum: allowed values 13 + * - minimum, maximum: number bounds 14 + * - minLength, maxLength: string length 15 + * - minItems, maxItems: array length 16 + * - pattern: regex pattern for strings 17 + * - format: common formats (email, uri, date, date-time) 18 + * - default: default value 19 + * 20 + * @example 21 + * import { createValidator, validate } from './schema.js'; 22 + * 23 + * const schema = { 24 + * type: 'object', 25 + * required: ['title'], 26 + * properties: { 27 + * title: { type: 'string', minLength: 1 }, 28 + * count: { type: 'integer', minimum: 0 } 29 + * } 30 + * }; 31 + * 32 + * const validator = createValidator(schema); 33 + * const result = validator({ title: 'Test', count: 5 }); 34 + * // { valid: true, errors: [], data: { title: 'Test', count: 5 } } 35 + */ 36 + 37 + /** 38 + * Validation result 39 + * @typedef {Object} ValidationResult 40 + * @property {boolean} valid - Whether data is valid 41 + * @property {ValidationError[]} errors - Array of validation errors 42 + * @property {*} data - The validated data (with defaults applied) 43 + */ 44 + 45 + /** 46 + * Validation error 47 + * @typedef {Object} ValidationError 48 + * @property {string} path - JSON path to the error (e.g., ".items[0].name") 49 + * @property {string} message - Human-readable error message 50 + * @property {string} keyword - Schema keyword that failed 51 + */ 52 + 53 + // Common format validators 54 + const FORMATS = { 55 + email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, 56 + uri: /^https?:\/\/.+/, 57 + 'date-time': /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/, 58 + date: /^\d{4}-\d{2}-\d{2}$/, 59 + time: /^\d{2}:\d{2}:\d{2}(\.\d+)?$/, 60 + uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i 61 + }; 62 + 63 + /** 64 + * Get the type of a value 65 + * @param {*} value 66 + * @returns {string} 67 + */ 68 + function getType(value) { 69 + if (value === null) return 'null'; 70 + if (Array.isArray(value)) return 'array'; 71 + return typeof value; 72 + } 73 + 74 + /** 75 + * Check if value matches expected type 76 + * @param {*} value 77 + * @param {string|string[]} type 78 + * @returns {boolean} 79 + */ 80 + function checkType(value, type) { 81 + const types = Array.isArray(type) ? type : [type]; 82 + const actualType = getType(value); 83 + 84 + return types.some(t => { 85 + if (t === 'integer') { 86 + return actualType === 'number' && Number.isInteger(value); 87 + } 88 + return actualType === t; 89 + }); 90 + } 91 + 92 + /** 93 + * Validate a value against a schema 94 + * @param {*} value - Value to validate 95 + * @param {Object} schema - JSON Schema 96 + * @param {string} [path=''] - Current path for error messages 97 + * @returns {ValidationResult} 98 + */ 99 + export function validate(value, schema, path = '') { 100 + const errors = []; 101 + let data = value; 102 + 103 + // Handle default value 104 + if (value === undefined && schema.default !== undefined) { 105 + data = structuredClone(schema.default); 106 + value = data; 107 + } 108 + 109 + // Null check 110 + if (value === undefined || value === null) { 111 + if (schema.type && !checkType(value, schema.type)) { 112 + errors.push({ 113 + path, 114 + message: `Expected ${schema.type}, got ${getType(value)}`, 115 + keyword: 'type' 116 + }); 117 + } 118 + return { valid: errors.length === 0, errors, data }; 119 + } 120 + 121 + // Type validation 122 + if (schema.type && !checkType(value, schema.type)) { 123 + errors.push({ 124 + path, 125 + message: `Expected ${Array.isArray(schema.type) ? schema.type.join(' | ') : schema.type}, got ${getType(value)}`, 126 + keyword: 'type' 127 + }); 128 + return { valid: false, errors, data }; 129 + } 130 + 131 + // Enum validation 132 + if (schema.enum && !schema.enum.includes(value)) { 133 + errors.push({ 134 + path, 135 + message: `Value must be one of: ${schema.enum.join(', ')}`, 136 + keyword: 'enum' 137 + }); 138 + } 139 + 140 + // String validations 141 + if (typeof value === 'string') { 142 + if (schema.minLength !== undefined && value.length < schema.minLength) { 143 + errors.push({ 144 + path, 145 + message: `String must be at least ${schema.minLength} characters`, 146 + keyword: 'minLength' 147 + }); 148 + } 149 + if (schema.maxLength !== undefined && value.length > schema.maxLength) { 150 + errors.push({ 151 + path, 152 + message: `String must be at most ${schema.maxLength} characters`, 153 + keyword: 'maxLength' 154 + }); 155 + } 156 + if (schema.pattern) { 157 + const regex = new RegExp(schema.pattern); 158 + if (!regex.test(value)) { 159 + errors.push({ 160 + path, 161 + message: `String must match pattern: ${schema.pattern}`, 162 + keyword: 'pattern' 163 + }); 164 + } 165 + } 166 + if (schema.format && FORMATS[schema.format]) { 167 + if (!FORMATS[schema.format].test(value)) { 168 + errors.push({ 169 + path, 170 + message: `Invalid ${schema.format} format`, 171 + keyword: 'format' 172 + }); 173 + } 174 + } 175 + } 176 + 177 + // Number validations 178 + if (typeof value === 'number') { 179 + if (schema.minimum !== undefined && value < schema.minimum) { 180 + errors.push({ 181 + path, 182 + message: `Value must be >= ${schema.minimum}`, 183 + keyword: 'minimum' 184 + }); 185 + } 186 + if (schema.maximum !== undefined && value > schema.maximum) { 187 + errors.push({ 188 + path, 189 + message: `Value must be <= ${schema.maximum}`, 190 + keyword: 'maximum' 191 + }); 192 + } 193 + if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) { 194 + errors.push({ 195 + path, 196 + message: `Value must be > ${schema.exclusiveMinimum}`, 197 + keyword: 'exclusiveMinimum' 198 + }); 199 + } 200 + if (schema.exclusiveMaximum !== undefined && value >= schema.exclusiveMaximum) { 201 + errors.push({ 202 + path, 203 + message: `Value must be < ${schema.exclusiveMaximum}`, 204 + keyword: 'exclusiveMaximum' 205 + }); 206 + } 207 + } 208 + 209 + // Array validations 210 + if (Array.isArray(value)) { 211 + if (schema.minItems !== undefined && value.length < schema.minItems) { 212 + errors.push({ 213 + path, 214 + message: `Array must have at least ${schema.minItems} items`, 215 + keyword: 'minItems' 216 + }); 217 + } 218 + if (schema.maxItems !== undefined && value.length > schema.maxItems) { 219 + errors.push({ 220 + path, 221 + message: `Array must have at most ${schema.maxItems} items`, 222 + keyword: 'maxItems' 223 + }); 224 + } 225 + if (schema.uniqueItems && new Set(value.map(JSON.stringify)).size !== value.length) { 226 + errors.push({ 227 + path, 228 + message: 'Array items must be unique', 229 + keyword: 'uniqueItems' 230 + }); 231 + } 232 + if (schema.items) { 233 + data = value.map((item, index) => { 234 + const result = validate(item, schema.items, `${path}[${index}]`); 235 + errors.push(...result.errors); 236 + return result.data; 237 + }); 238 + } 239 + } 240 + 241 + // Object validations 242 + if (getType(value) === 'object') { 243 + // Clone to apply defaults 244 + data = { ...value }; 245 + 246 + // Required properties 247 + if (schema.required) { 248 + for (const key of schema.required) { 249 + if (!(key in value)) { 250 + errors.push({ 251 + path: path ? `${path}.${key}` : key, 252 + message: `Missing required property: ${key}`, 253 + keyword: 'required' 254 + }); 255 + } 256 + } 257 + } 258 + 259 + // Property validation 260 + if (schema.properties) { 261 + for (const [key, propSchema] of Object.entries(schema.properties)) { 262 + const propPath = path ? `${path}.${key}` : key; 263 + const result = validate(value[key], propSchema, propPath); 264 + errors.push(...result.errors); 265 + if (result.data !== undefined) { 266 + data[key] = result.data; 267 + } 268 + } 269 + } 270 + 271 + // Additional properties check 272 + if (schema.additionalProperties === false && schema.properties) { 273 + const allowed = new Set(Object.keys(schema.properties)); 274 + for (const key of Object.keys(value)) { 275 + if (!allowed.has(key)) { 276 + errors.push({ 277 + path: path ? `${path}.${key}` : key, 278 + message: `Unknown property: ${key}`, 279 + keyword: 'additionalProperties' 280 + }); 281 + } 282 + } 283 + } 284 + } 285 + 286 + return { valid: errors.length === 0, errors, data }; 287 + } 288 + 289 + /** 290 + * Create a reusable validator function from a schema 291 + * @param {Object} schema - JSON Schema 292 + * @returns {(value: *) => ValidationResult} 293 + * 294 + * @example 295 + * const validateUser = createValidator({ 296 + * type: 'object', 297 + * required: ['name', 'email'], 298 + * properties: { 299 + * name: { type: 'string', minLength: 1 }, 300 + * email: { type: 'string', format: 'email' } 301 + * } 302 + * }); 303 + * 304 + * const result = validateUser({ name: 'Alice', email: 'alice@example.com' }); 305 + */ 306 + export function createValidator(schema) { 307 + return (value) => validate(value, schema); 308 + } 309 + 310 + /** 311 + * Assert that data is valid, throwing if not 312 + * @param {*} value - Value to validate 313 + * @param {Object} schema - JSON Schema 314 + * @throws {ValidationError} If validation fails 315 + * 316 + * @example 317 + * assertValid({ name: '' }, { type: 'object', properties: { name: { minLength: 1 } } }); 318 + * // Throws: ValidationError: name: String must be at least 1 characters 319 + */ 320 + export function assertValid(value, schema) { 321 + const result = validate(value, schema); 322 + if (!result.valid) { 323 + const error = new Error( 324 + result.errors.map(e => `${e.path || 'root'}: ${e.message}`).join('; ') 325 + ); 326 + error.name = 'ValidationError'; 327 + error.errors = result.errors; 328 + throw error; 329 + } 330 + return result.data; 331 + } 332 + 333 + /** 334 + * Check if value is valid (boolean result only) 335 + * @param {*} value 336 + * @param {Object} schema 337 + * @returns {boolean} 338 + */ 339 + export function isValid(value, schema) { 340 + return validate(value, schema).valid; 341 + } 342 + 343 + /** 344 + * Apply defaults from schema to value 345 + * @param {*} value 346 + * @param {Object} schema 347 + * @returns {*} Value with defaults applied 348 + */ 349 + export function applyDefaults(value, schema) { 350 + return validate(value, schema).data; 351 + } 352 + 353 + /** 354 + * Common schema builders for convenience 355 + */ 356 + export const Schema = { 357 + string(options = {}) { 358 + return { type: 'string', ...options }; 359 + }, 360 + 361 + number(options = {}) { 362 + return { type: 'number', ...options }; 363 + }, 364 + 365 + integer(options = {}) { 366 + return { type: 'integer', ...options }; 367 + }, 368 + 369 + boolean(options = {}) { 370 + return { type: 'boolean', ...options }; 371 + }, 372 + 373 + array(items, options = {}) { 374 + return { type: 'array', items, ...options }; 375 + }, 376 + 377 + object(properties, options = {}) { 378 + return { type: 'object', properties, ...options }; 379 + }, 380 + 381 + enum(values, options = {}) { 382 + return { enum: values, ...options }; 383 + }, 384 + 385 + nullable(schema) { 386 + return { ...schema, type: [schema.type, 'null'] }; 387 + } 388 + }; 389 + 390 + export default { validate, createValidator, assertValid, isValid, applyDefaults, Schema };
+344
app/components/signals.js
··· 1 + /** 2 + * Native Signals Implementation 3 + * 4 + * A lightweight reactive primitives library following the TC39 Signals proposal pattern. 5 + * No external dependencies - pure JavaScript implementation. 6 + * 7 + * @example 8 + * import { signal, computed, effect } from './signals.js'; 9 + * 10 + * const count = signal(0); 11 + * const doubled = computed(() => count.value * 2); 12 + * 13 + * effect(() => { 14 + * console.log('Count:', count.value, 'Doubled:', doubled.value); 15 + * }); 16 + * 17 + * count.value = 5; // Logs: "Count: 5 Doubled: 10" 18 + */ 19 + 20 + // Track the currently running effect for automatic dependency collection 21 + let currentEffect = null; 22 + const effectStack = []; 23 + 24 + /** 25 + * Signal - A reactive value container 26 + * 27 + * @template T 28 + * @param {T} initialValue - Initial value 29 + * @returns {Signal<T>} - Signal object with .value property 30 + * 31 + * @example 32 + * const name = signal('Alice'); 33 + * console.log(name.value); // 'Alice' 34 + * name.value = 'Bob'; // Notifies all subscribers 35 + */ 36 + export function signal(initialValue) { 37 + let value = initialValue; 38 + const subscribers = new Set(); 39 + 40 + return { 41 + /** 42 + * Get or set the signal value. 43 + * Getting tracks the current effect as a dependency. 44 + * Setting notifies all subscribers. 45 + */ 46 + get value() { 47 + // Track dependency 48 + if (currentEffect) { 49 + subscribers.add(currentEffect); 50 + } 51 + return value; 52 + }, 53 + 54 + set value(newValue) { 55 + if (!Object.is(value, newValue)) { 56 + value = newValue; 57 + // Notify subscribers (copy to avoid mutation during iteration) 58 + for (const subscriber of [...subscribers]) { 59 + subscriber(); 60 + } 61 + } 62 + }, 63 + 64 + /** 65 + * Get value without tracking (peek at value) 66 + * @returns {T} 67 + */ 68 + peek() { 69 + return value; 70 + }, 71 + 72 + /** 73 + * Subscribe to changes manually (for non-effect use cases) 74 + * @param {Function} callback - Called when value changes 75 + * @returns {Function} - Unsubscribe function 76 + */ 77 + subscribe(callback) { 78 + subscribers.add(callback); 79 + return () => subscribers.delete(callback); 80 + }, 81 + 82 + /** 83 + * Number of active subscribers 84 + */ 85 + get subscriberCount() { 86 + return subscribers.size; 87 + } 88 + }; 89 + } 90 + 91 + /** 92 + * Computed - A derived reactive value 93 + * 94 + * Automatically tracks dependencies and recomputes when they change. 95 + * Lazy evaluation - only computes when .value is accessed. 96 + * 97 + * @template T 98 + * @param {() => T} fn - Computation function 99 + * @returns {ComputedSignal<T>} - Read-only signal 100 + * 101 + * @example 102 + * const firstName = signal('John'); 103 + * const lastName = signal('Doe'); 104 + * const fullName = computed(() => `${firstName.value} ${lastName.value}`); 105 + * 106 + * console.log(fullName.value); // 'John Doe' 107 + * firstName.value = 'Jane'; 108 + * console.log(fullName.value); // 'Jane Doe' 109 + */ 110 + export function computed(fn) { 111 + let cachedValue; 112 + let dirty = true; 113 + const subscribers = new Set(); 114 + 115 + // Internal effect to track dependencies and mark dirty 116 + const recompute = () => { 117 + dirty = true; 118 + // Notify our subscribers that we changed 119 + for (const subscriber of [...subscribers]) { 120 + subscriber(); 121 + } 122 + }; 123 + 124 + return { 125 + get value() { 126 + // Track this computed as a dependency 127 + if (currentEffect) { 128 + subscribers.add(currentEffect); 129 + } 130 + 131 + if (dirty) { 132 + // Run computation with dependency tracking 133 + const previousEffect = currentEffect; 134 + currentEffect = recompute; 135 + effectStack.push(recompute); 136 + 137 + try { 138 + cachedValue = fn(); 139 + dirty = false; 140 + } finally { 141 + effectStack.pop(); 142 + currentEffect = previousEffect; 143 + } 144 + } 145 + 146 + return cachedValue; 147 + }, 148 + 149 + /** 150 + * Get value without tracking 151 + */ 152 + peek() { 153 + if (dirty) { 154 + // Compute without tracking 155 + const previousEffect = currentEffect; 156 + currentEffect = null; 157 + try { 158 + cachedValue = fn(); 159 + dirty = false; 160 + } finally { 161 + currentEffect = previousEffect; 162 + } 163 + } 164 + return cachedValue; 165 + }, 166 + 167 + /** 168 + * Subscribe to changes manually 169 + */ 170 + subscribe(callback) { 171 + subscribers.add(callback); 172 + return () => subscribers.delete(callback); 173 + } 174 + }; 175 + } 176 + 177 + /** 178 + * Effect - Run side effects when dependencies change 179 + * 180 + * Automatically tracks signal/computed dependencies accessed during execution. 181 + * Re-runs whenever any dependency changes. 182 + * 183 + * @param {() => void | (() => void)} fn - Effect function. May return cleanup function. 184 + * @returns {() => void} - Dispose function to stop the effect 185 + * 186 + * @example 187 + * const count = signal(0); 188 + * 189 + * const dispose = effect(() => { 190 + * console.log('Count is:', count.value); 191 + * return () => console.log('Cleanup'); 192 + * }); 193 + * 194 + * count.value = 1; // Logs: "Cleanup" then "Count is: 1" 195 + * dispose(); // Stops the effect 196 + */ 197 + export function effect(fn) { 198 + let cleanup = null; 199 + let disposed = false; 200 + 201 + const execute = () => { 202 + if (disposed) return; 203 + 204 + // Run cleanup from previous execution 205 + if (cleanup && typeof cleanup === 'function') { 206 + cleanup(); 207 + cleanup = null; 208 + } 209 + 210 + // Run effect with dependency tracking 211 + const previousEffect = currentEffect; 212 + currentEffect = execute; 213 + effectStack.push(execute); 214 + 215 + try { 216 + cleanup = fn(); 217 + } finally { 218 + effectStack.pop(); 219 + currentEffect = previousEffect; 220 + } 221 + }; 222 + 223 + // Initial execution 224 + execute(); 225 + 226 + // Return dispose function 227 + return () => { 228 + disposed = true; 229 + if (cleanup && typeof cleanup === 'function') { 230 + cleanup(); 231 + } 232 + }; 233 + } 234 + 235 + /** 236 + * Batch multiple signal updates into a single notification 237 + * 238 + * @param {() => void} fn - Function containing signal updates 239 + * 240 + * @example 241 + * const a = signal(1); 242 + * const b = signal(2); 243 + * 244 + * batch(() => { 245 + * a.value = 10; 246 + * b.value = 20; 247 + * }); // Effects run once, not twice 248 + */ 249 + let batchDepth = 0; 250 + let batchedEffects = new Set(); 251 + 252 + export function batch(fn) { 253 + batchDepth++; 254 + try { 255 + fn(); 256 + } finally { 257 + batchDepth--; 258 + if (batchDepth === 0) { 259 + const effects = [...batchedEffects]; 260 + batchedEffects.clear(); 261 + for (const effect of effects) { 262 + effect(); 263 + } 264 + } 265 + } 266 + } 267 + 268 + /** 269 + * Create a signal from an external data source 270 + * 271 + * Useful for bridging external state (localStorage, APIs, etc.) with signals. 272 + * 273 + * @template T 274 + * @param {() => T} get - Getter function 275 + * @param {(value: T) => void} set - Setter function 276 + * @param {(callback: () => void) => () => void} subscribe - Subscribe to external changes 277 + * @returns {Signal<T>} 278 + * 279 + * @example 280 + * const storedValue = fromExternal( 281 + * () => localStorage.getItem('key'), 282 + * (v) => localStorage.setItem('key', v), 283 + * (cb) => { window.addEventListener('storage', cb); return () => window.removeEventListener('storage', cb); } 284 + * ); 285 + */ 286 + export function fromExternal(get, set, subscribe) { 287 + const s = signal(get()); 288 + 289 + // Sync from external source 290 + const unsubscribe = subscribe(() => { 291 + s.value = get(); 292 + }); 293 + 294 + // Return a proxy that syncs back to external 295 + return { 296 + get value() { 297 + return s.value; 298 + }, 299 + set value(newValue) { 300 + set(newValue); 301 + s.value = newValue; 302 + }, 303 + peek: s.peek, 304 + subscribe: s.subscribe, 305 + dispose: unsubscribe 306 + }; 307 + } 308 + 309 + /** 310 + * Watch a signal and call handler when it changes 311 + * 312 + * Similar to effect but more explicit - only watches specified signals. 313 + * 314 + * @template T 315 + * @param {Signal<T> | ComputedSignal<T>} source - Signal to watch 316 + * @param {(value: T, oldValue: T) => void} handler - Called on change 317 + * @param {Object} [options] 318 + * @param {boolean} [options.immediate=false] - Call handler immediately with current value 319 + * @returns {() => void} - Stop watching 320 + * 321 + * @example 322 + * const count = signal(0); 323 + * const stop = watch(count, (newVal, oldVal) => { 324 + * console.log(`Changed from ${oldVal} to ${newVal}`); 325 + * }); 326 + */ 327 + export function watch(source, handler, options = {}) { 328 + let oldValue = source.peek(); 329 + 330 + if (options.immediate) { 331 + handler(oldValue, undefined); 332 + } 333 + 334 + return source.subscribe(() => { 335 + const newValue = source.peek(); 336 + if (!Object.is(newValue, oldValue)) { 337 + const prev = oldValue; 338 + oldValue = newValue; 339 + handler(newValue, prev); 340 + } 341 + }); 342 + } 343 + 344 + export default { signal, computed, effect, batch, fromExternal, watch };