UNPKG

@profullstack/state-manager

Version:

Enhanced state manager with web component integration, persistence, and subscription management

470 lines (409 loc) 15.1 kB
/** * Web Component Integration for State Manager * * Provides utilities for integrating state management with web components. */ /** * Create web component integration * @param {Object} stateManager - State manager instance * @returns {Object} Web component integration utilities */ export function createWebComponentIntegration(stateManager) { /** * Create a connected web component * @param {string} tagName - Custom element tag name * @param {class} BaseComponent - Base component class (extends HTMLElement) * @param {Object} options - Configuration options * @param {string|string[]} options.statePaths - State paths to subscribe to * @param {Function} options.mapStateToProps - Function to map state to properties * @param {Object} options.actions - Actions to bind to the component * @returns {class} Connected component class */ function createConnectedComponent(tagName, BaseComponent, options = {}) { if (customElements.get(tagName)) { console.warn(`Custom element '${tagName}' is already defined`); return customElements.get(tagName); } // Default options const config = { statePaths: [], mapStateToProps: null, actions: {}, ...options }; // Create a new class that extends the base component const ConnectedComponent = class extends BaseComponent { constructor() { super(); // State management this._stateUnsubscribe = null; this._boundActions = {}; // Bind actions this._bindActions(config.actions); } /** * Bind actions to the component * @param {Object} actions - Actions to bind * @private */ _bindActions(actions) { for (const [name, action] of Object.entries(actions)) { if (typeof action === 'function') { this._boundActions[name] = (...args) => { // Call the action with the component as context return action.apply(this, args); }; } } } /** * Get state from the state manager * @param {string|string[]} [path] - Optional path to get a specific part of the state * @returns {any} The requested state */ getState(path) { return stateManager.getState(path); } /** * Update state in the state manager * @param {Object|Function} update - Object to merge with state or function that returns an update object * @param {Object} options - Update options * @returns {Object} The new state */ setState(update, options) { return stateManager.setState(update, options); } /** * Handle state changes * @param {any} state - New state * @param {string|string[]} path - Path that changed * @param {Object} fullState - Full state object * @private */ _handleStateChange(state, path, fullState) { // Map state to props if a mapper function is provided if (typeof config.mapStateToProps === 'function') { const props = config.mapStateToProps(fullState, this); // Update component properties for (const [key, value] of Object.entries(props)) { if (this[key] !== value) { this[key] = value; } } } // Call the component's stateChanged method if it exists if (typeof this.stateChanged === 'function') { this.stateChanged(state, path, fullState); } // Call the component's render method if it exists if (typeof this.render === 'function') { this.render(); } } /** * Called when the element is added to the DOM */ connectedCallback() { // Call the parent connectedCallback if it exists if (super.connectedCallback) { super.connectedCallback(); } // Subscribe to state changes this._stateUnsubscribe = stateManager.subscribe( this._handleStateChange.bind(this), config.statePaths ); // Initial state update if (typeof config.mapStateToProps === 'function') { const fullState = stateManager.getState(); const props = config.mapStateToProps(fullState, this); // Update component properties for (const [key, value] of Object.entries(props)) { this[key] = value; } } // Call the component's render method if it exists if (typeof this.render === 'function') { this.render(); } } /** * Called when the element is removed from the DOM */ disconnectedCallback() { // Unsubscribe from state changes if (this._stateUnsubscribe) { this._stateUnsubscribe(); this._stateUnsubscribe = null; } // Call the parent disconnectedCallback if it exists if (super.disconnectedCallback) { super.disconnectedCallback(); } } }; // Add action methods to the component prototype for (const [name, action] of Object.entries(config.actions)) { if (typeof action === 'function') { ConnectedComponent.prototype[name] = function(...args) { return this._boundActions[name](...args); }; } } // Register the custom element customElements.define(tagName, ConnectedComponent); return ConnectedComponent; } /** * Create a state mixin for web components * @param {Object} options - Configuration options * @param {string|string[]} options.statePaths - State paths to subscribe to * @param {Function} options.mapStateToProps - Function to map state to properties * @param {Object} options.actions - Actions to bind to the component * @returns {Function} Mixin function */ function createStateMixin(options = {}) { // Default options const config = { statePaths: [], mapStateToProps: null, actions: {}, ...options }; return (BaseClass) => { return class extends BaseClass { constructor() { super(); // State management this._stateUnsubscribe = null; this._statePaths = Array.isArray(config.statePaths) ? config.statePaths : [config.statePaths]; this._boundActions = {}; // Bind actions this._bindActions(config.actions); } /** * Bind actions to the component * @param {Object} actions - Actions to bind * @private */ _bindActions(actions) { for (const [name, action] of Object.entries(actions)) { if (typeof action === 'function') { this._boundActions[name] = (...args) => { // Call the action with the component as context return action.apply(this, args); }; } } } /** * Connect to specific state paths * @param {string|string[]} paths - State paths to subscribe to * @returns {this} The component instance */ connectToState(paths) { this._statePaths = Array.isArray(paths) ? paths : [paths]; // If already connected, unsubscribe first if (this._stateUnsubscribe) { this._stateUnsubscribe(); } // Subscribe to state changes this._stateUnsubscribe = stateManager.subscribe( this._handleStateChange.bind(this), this._statePaths ); return this; } /** * Get state from the state manager * @param {string|string[]} [path] - Optional path to get a specific part of the state * @returns {any} The requested state */ getState(path) { return stateManager.getState(path); } /** * Update state in the state manager * @param {Object|Function} update - Object to merge with state or function that returns an update object * @param {Object} options - Update options * @returns {Object} The new state */ setState(update, options) { return stateManager.setState(update, options); } /** * Handle state changes * @param {any} state - New state * @param {string|string[]} path - Path that changed * @param {Object} fullState - Full state object * @private */ _handleStateChange(state, path, fullState) { // Map state to props if a mapper function is provided if (typeof config.mapStateToProps === 'function') { const props = config.mapStateToProps(fullState, this); // Update component properties for (const [key, value] of Object.entries(props)) { if (this[key] !== value) { this[key] = value; } } } // Call the component's stateChanged method if it exists if (typeof this.stateChanged === 'function') { this.stateChanged(state, path, fullState); } // Call the component's render method if it exists if (typeof this.render === 'function') { this.render(); } } /** * Called when the element is added to the DOM */ connectedCallback() { // Call the parent connectedCallback if it exists if (super.connectedCallback) { super.connectedCallback(); } // Subscribe to state changes if not already subscribed if (!this._stateUnsubscribe && this._statePaths.length > 0) { this._stateUnsubscribe = stateManager.subscribe( this._handleStateChange.bind(this), this._statePaths ); } // Initial state update if (typeof config.mapStateToProps === 'function') { const fullState = stateManager.getState(); const props = config.mapStateToProps(fullState, this); // Update component properties for (const [key, value] of Object.entries(props)) { this[key] = value; } } // Call the component's render method if it exists if (typeof this.render === 'function') { this.render(); } } /** * Called when the element is removed from the DOM */ disconnectedCallback() { // Unsubscribe from state changes if (this._stateUnsubscribe) { this._stateUnsubscribe(); this._stateUnsubscribe = null; } // Call the parent disconnectedCallback if it exists if (super.disconnectedCallback) { super.disconnectedCallback(); } } }; }; } /** * Create a LitElement connector * @param {Object} options - Configuration options * @param {string|string[]} options.statePaths - State paths to subscribe to * @param {Function} options.mapStateToProps - Function to map state to properties * @returns {Function} LitElement connector function */ function createLitElementConnector(options = {}) { // Default options const config = { statePaths: [], mapStateToProps: null, ...options }; return (BaseElement) => { return class extends BaseElement { constructor() { super(); this._stateUnsubscribe = null; } connectedCallback() { super.connectedCallback(); // Subscribe to state changes this._stateUnsubscribe = stateManager.subscribe( this._handleStateChange.bind(this), config.statePaths ); // Initial state update if (typeof config.mapStateToProps === 'function') { const state = stateManager.getState(); const props = config.mapStateToProps(state, this); // Update component properties for (const [key, value] of Object.entries(props)) { this[key] = value; } } } disconnectedCallback() { // Unsubscribe from state changes if (this._stateUnsubscribe) { this._stateUnsubscribe(); this._stateUnsubscribe = null; } super.disconnectedCallback(); } _handleStateChange(state, path, fullState) { // Map state to props if a mapper function is provided if (typeof config.mapStateToProps === 'function') { const props = config.mapStateToProps(fullState, this); // Update component properties let hasChanges = false; for (const [key, value] of Object.entries(props)) { if (this[key] !== value) { this[key] = value; hasChanges = true; } } // Request an update if properties changed if (hasChanges && typeof this.requestUpdate === 'function') { this.requestUpdate(); } } } getState(path) { return stateManager.getState(path); } setState(update, options) { return stateManager.setState(update, options); } }; }; } // Return the web component integration utilities return { createConnectedComponent, createStateMixin, createLitElementConnector }; } /** * State mixin factory * @param {Object} stateManager - State manager instance * @returns {Function} State mixin factory function */ export function StateMixin(stateManager) { return (options = {}) => { const integration = createWebComponentIntegration(stateManager); return integration.createStateMixin(options); }; } /** * Create a connected component * @param {string} tagName - Custom element tag name * @param {class} BaseComponent - Base component class * @param {Object} options - Configuration options * @param {Object} stateManager - State manager instance * @returns {class} Connected component class */ export function createConnectedComponent(tagName, BaseComponent, options = {}, stateManager) { const integration = createWebComponentIntegration(stateManager); return integration.createConnectedComponent(tagName, BaseComponent, options); } export default createWebComponentIntegration;