/** * @fileoverview A reusable stepper component for guided workflows. * Supports step dependencies, persistence, and event-driven actions. */ const ICONS = { pending: '', active: ` `, completed: ` `, error: ` `, skipped: ` ` }; class DsWorkflow extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this._steps = []; } static get observedAttributes() { return ['workflow-id']; } get workflowId() { return this.getAttribute('workflow-id'); } set steps(stepsArray) { this._steps = stepsArray.map(s => ({ status: 'pending', optional: false, dependsOn: [], ...s })); this._loadState(); this._render(); } get steps() { return this._steps; } connectedCallback() { this._renderBase(); } _loadState() { if (!this.workflowId) return; try { const savedState = JSON.parse(localStorage.getItem(`dss_workflow_${this.workflowId}`)); if (savedState) { this._steps.forEach(step => { if (savedState[step.id]) { step.status = savedState[step.id].status; if (savedState[step.id].message) { step.message = savedState[step.id].message; } } }); } } catch (e) { console.error('Failed to load workflow state:', e); } } _saveState() { if (!this.workflowId) return; const stateToSave = this._steps.reduce((acc, step) => { acc[step.id] = { status: step.status, message: step.message || null }; return acc; }, {}); localStorage.setItem(`dss_workflow_${this.workflowId}`, JSON.stringify(stateToSave)); } /** * Update a step's status * @param {string} stepId - The step ID * @param {string} status - 'pending', 'active', 'completed', 'error', 'skipped' * @param {string} [message] - Optional message (for error states) */ updateStepStatus(stepId, status, message = '') { const step = this._steps.find(s => s.id === stepId); if (step) { step.status = status; step.message = message; this._saveState(); this._render(); this.dispatchEvent(new CustomEvent('workflow-step-change', { bubbles: true, composed: true, detail: { ...step } })); // Check if workflow is complete const requiredSteps = this._steps.filter(s => !s.optional); const completedRequired = requiredSteps.filter(s => s.status === 'completed').length; if (completedRequired === requiredSteps.length && requiredSteps.length > 0) { this.dispatchEvent(new CustomEvent('workflow-complete', { bubbles: true, composed: true })); } } } /** * Reset the workflow to initial state */ reset() { this._steps.forEach(step => { step.status = 'pending'; step.message = ''; }); this._saveState(); this._render(); } /** * Skip a step * @param {string} stepId - The step ID to skip */ skipStep(stepId) { const step = this._steps.find(s => s.id === stepId); if (step && step.optional) { this.updateStepStatus(stepId, 'skipped'); } } _determineActiveStep() { const completedIds = new Set( this._steps .filter(s => s.status === 'completed' || s.status === 'skipped') .map(s => s.id) ); let foundActive = false; this._steps.forEach(step => { if (step.status === 'pending' && !foundActive) { const depsMet = (step.dependsOn || []).every(depId => completedIds.has(depId)); if (depsMet) { step.status = 'active'; foundActive = true; } } }); } _getProgress() { const total = this._steps.filter(s => !s.optional).length; const completed = this._steps.filter(s => !s.optional && (s.status === 'completed' || s.status === 'skipped') ).length; return total > 0 ? (completed / total) * 100 : 0; } _renderBase() { this.shadowRoot.innerHTML = `
`; } _render() { const wrapper = this.shadowRoot.getElementById('steps-wrapper'); if (!wrapper || !this._steps || this._steps.length === 0) { return; } // Determine which step should be active this._determineActiveStep(); // Render steps wrapper.innerHTML = this._steps.map(step => this._renderStep(step)).join(''); // Update progress bar const progress = this._getProgress(); const indicator = this.shadowRoot.querySelector('.progress-bar__indicator'); if (indicator) { indicator.style.width = `${progress}%`; } // Add event listeners for action buttons wrapper.querySelectorAll('[data-action-event]').forEach(button => { button.addEventListener('click', () => { this.dispatchEvent(new CustomEvent(button.dataset.actionEvent, { bubbles: true, composed: true, detail: { stepId: button.dataset.stepId } })); }); }); // Add skip button listeners wrapper.querySelectorAll('[data-skip]').forEach(button => { button.addEventListener('click', () => { this.skipStep(button.dataset.skip); }); }); } _renderStep(step) { const isActionable = step.status === 'active' && step.action; const canSkip = step.status === 'active' && step.optional; return `
${ICONS[step.status] || ''}
${this._escapeHtml(step.title)}
${step.optional ? 'Optional' : ''}
${step.description ? `
${this._escapeHtml(step.description)}
` : ''} ${step.status === 'error' && step.message ? `
${this._escapeHtml(step.message)}
` : ''} ${isActionable || canSkip ? `
${isActionable ? ` ${step.action.label} ` : ''} ${canSkip ? ` Skip ` : ''}
` : ''}
`; } _escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } } customElements.define('ds-workflow', DsWorkflow);