UNPKG

svarog-ui-core

Version:

Core components of Svarog UI without themes

340 lines (284 loc) 9.98 kB
// src/components/StepsIndicator/StepsIndicator.js import { createElement, validateProps, createComponent, } from '../../utils/componentFactory.js'; import { createBaseComponent } from '../../utils/baseComponent.js'; import { withThemeAwareness } from '../../utils/composition.js'; // CSS injection imports import { createStyleInjector } from '../../utils/styleInjection.js'; import { stepsIndicatorStyles } from './StepsIndicator.styles.js'; // Create style injector for StepsIndicator component const injectStepsIndicatorStyles = createStyleInjector('StepsIndicator'); // Helper to determine section state function getSectionState(sectionIndex, steps, activeIndex) { const totalSteps = steps.length; // For section active state let isActive = false; if (totalSteps <= 3) { // Direct mapping for 3 or fewer steps isActive = sectionIndex <= activeIndex; } else { // Proportional mapping for more than 3 steps const progressRatio = (activeIndex + 1) / totalSteps; const sectionThreshold = (sectionIndex + 1) / 3; isActive = progressRatio >= sectionThreshold; } // For section completed state let isCompleted = false; if (totalSteps <= 3) { isCompleted = sectionIndex < activeIndex; } else { const progressRatio = activeIndex / totalSteps; const sectionThreshold = sectionIndex / 3; isCompleted = progressRatio > sectionThreshold; } // For section success state let isSuccess = false; if (totalSteps <= 3) { // If we have exactly 3 steps, check direct mapping if (sectionIndex < totalSteps) { // Special case for last step const step = steps[sectionIndex]; isSuccess = step && step.completed === true && (sectionIndex !== activeIndex || sectionIndex === totalSteps - 1); } } else { // For more steps, calculate which steps correspond to this section const stepsPerSection = totalSteps / 3; const startStep = Math.floor(sectionIndex * stepsPerSection); const endStep = Math.floor((sectionIndex + 1) * stepsPerSection) - 1; // Check if all steps in this section are completed let allCompleted = true; for (let i = startStep; i <= endStep && i < steps.length; i++) { if (!steps[i] || steps[i].completed !== true) { allCompleted = false; break; } } // If all steps in this section are completed, it's a success isSuccess = allCompleted && startStep <= activeIndex && (endStep >= activeIndex || activeIndex === totalSteps - 1); } return { isActive, isCompleted, isSuccess }; } // Render function to create component DOM function renderStepsIndicator(state) { const { steps, activeIndex, className, loading } = state; // Inject styles on first render (automatically cached) injectStepsIndicatorStyles(stepsIndicatorStyles); // Main container with ARIA attributes const container = createElement('div', { classes: [ 'steps-indicator', className, loading ? 'steps-indicator--loading' : '', ].filter(Boolean), attributes: { role: 'navigation', 'aria-label': 'Progress indicator', 'aria-busy': loading ? 'true' : 'false', }, }); // Store state directly on element for easier access container.state = { ...state }; // Progress bar with exactly 3 sections const progressBar = createElement('div', { classes: 'steps-indicator__progress-bar', attributes: { 'aria-hidden': 'true', }, }); // Create the three sections for (let i = 0; i < 3; i++) { const sectionState = getSectionState(i, steps, activeIndex); const { isActive, isCompleted, isSuccess } = sectionState; // Apply CSS classes based on section state const classes = ['steps-indicator__section']; // Apply success class with highest priority if (isSuccess) { classes.push('steps-indicator__section--success'); } else if (isCompleted) { classes.push('steps-indicator__section--completed'); } else if (isActive) { classes.push('steps-indicator__section--active'); } const section = createElement('div', { classes }); progressBar.appendChild(section); } // Step indicators row const stepsRow = createElement('div', { classes: 'steps-indicator__steps', attributes: { role: 'list' }, }); // Create each step marker steps.forEach((step, index) => { const isActive = index === activeIndex; const isCompleted = step && step.completed === true; const stepName = step && (step.name || `Step ${index + 1}`); const stepEl = createElement('div', { classes: [ 'steps-indicator__step', isActive ? 'steps-indicator__step--active' : '', isCompleted ? 'steps-indicator__step--completed' : '', ].filter(Boolean), attributes: { role: 'listitem', 'aria-current': isActive ? 'step' : null, 'aria-label': `Step ${index + 1}: ${stepName}${isCompleted ? ', completed' : ''}${isActive ? ', current step' : ''}`, 'data-step-index': index.toString(), }, }); // Number container for positioning const numberContainer = createElement('div', { classes: 'steps-indicator__number-container', }); // Step number const number = createElement('div', { classes: 'steps-indicator__number', text: (index + 1).toString(), attributes: { 'aria-hidden': 'true' }, }); // Step label const label = createElement('div', { classes: 'steps-indicator__label', text: stepName, }); numberContainer.appendChild(number); stepEl.appendChild(numberContainer); stepEl.appendChild(label); stepsRow.appendChild(stepEl); }); // Assemble the component container.appendChild(progressBar); container.appendChild(stepsRow); return container; } // Function to migrate legacy props to standardized props function migrateLegacyProps(props) { const migrated = { ...props }; // Support for value alias of activeIndex if ('value' in props && !('activeIndex' in props)) { migrated.activeIndex = props.value; } // Support for onChange alias of onStepChange if ('onChange' in props && !('onStepChange' in props)) { migrated.onStepChange = props.onChange; } return migrated; } // Factory function to create the component function createStepsIndicator(props) { // Migrate legacy props to standardized props const normalizedProps = migrateLegacyProps(props); // Validate required props with component name validateProps(normalizedProps, ['steps', 'activeIndex'], 'StepsIndicator'); // Validate numeric activeIndex if (typeof normalizedProps.activeIndex !== 'number') { throw new Error('StepsIndicator: activeIndex must be a number'); } // Validate steps array if (!Array.isArray(normalizedProps.steps)) { throw new Error('StepsIndicator: steps must be an array'); } // Validate empty steps array if (normalizedProps.steps.length === 0) { throw new Error('StepsIndicator: steps array cannot be empty'); } // Validate activeIndex within bounds if ( normalizedProps.activeIndex < 0 || normalizedProps.activeIndex >= normalizedProps.steps.length ) { throw new Error( 'StepsIndicator: activeIndex must be within steps array bounds' ); } // Create base component with render function const stepsIndicator = createBaseComponent(renderStepsIndicator)({ steps: normalizedProps.steps, activeIndex: normalizedProps.activeIndex, className: normalizedProps.className || '', loading: normalizedProps.loading || false, onStepChange: normalizedProps.onStepChange, }); // Add component API methods stepsIndicator.getState = function () { return { ...this.getElement().state }; }; stepsIndicator.setActiveStep = function (index) { // Validate index within bounds const currentState = this.getState(); const steps = currentState.steps || []; if (index < 0 || index >= steps.length) { throw new Error( 'StepsIndicator: activeIndex must be within steps array bounds' ); } // Call onStepChange callback if provided if ( currentState.onStepChange && typeof currentState.onStepChange === 'function' ) { currentState.onStepChange(index); } return this.update({ activeIndex: index }); }; stepsIndicator.completeStep = function (index) { const currentState = this.getState(); const updatedSteps = [...currentState.steps]; // Validate index if (index < 0 || index >= updatedSteps.length) { throw new Error( 'StepsIndicator: index must be within steps array bounds' ); } // Ensure proper cloning and property setting if (updatedSteps[index]) { updatedSteps[index] = { ...updatedSteps[index], completed: true, }; return this.update({ steps: updatedSteps }); } return this; }; stepsIndicator.resetSteps = function () { const currentState = this.getState(); const updatedSteps = currentState.steps.map((step) => ({ ...step, completed: false, })); // Call onStepChange callback if provided since we're moving to step 0 if ( currentState.onStepChange && typeof currentState.onStepChange === 'function' && currentState.activeIndex !== 0 ) { currentState.onStepChange(0); } return this.update({ steps: updatedSteps, activeIndex: 0, }); }; // Alias method for setActiveStep to match value pattern stepsIndicator.setValue = function (index) { return this.setActiveStep(index); }; // Force full re-render on update stepsIndicator.shouldRerender = function () { return true; }; return stepsIndicator; } // Create the component with theme awareness const StepsIndicator = withThemeAwareness( createComponent('StepsIndicator', createStepsIndicator) ); export default StepsIndicator;