import { LitElement, html, css } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { initializeUSWDSComponent, cleanupUSWDSComponent } from '../../utils/uswds-loader.js'; // Import official USWDS compiled CSS import '../../styles/styles.css'; export interface StepItem { label: string; status: 'complete' | 'current' | 'incomplete'; } /** * USA Step Indicator Web Component * * A simple, accessible USWDS step indicator implementation as a custom element. * Uses official USWDS classes and styling with minimal custom code. * * @element usa-step-indicator * @fires step-click - Dispatched when step is clicked * * @see README.mdx - Complete API documentation, usage examples, and implementation notes * @see CHANGELOG.mdx - Component version history and breaking changes * @see TESTING.mdx - Testing documentation and coverage reports * * @uswds-css-reference https://github.com/uswds/uswds/tree/develop/packages/usa-step-indicator/src/styles/_usa-step-indicator.scss * @uswds-docs https://designsystem.digital.gov/components/step-indicator/ * @uswds-guidance https://designsystem.digital.gov/components/step-indicator/#guidance * @uswds-accessibility https://designsystem.digital.gov/components/step-indicator/#accessibility */ @customElement('usa-step-indicator') export class USAStepIndicator extends LitElement { // Store USWDS module for cleanup private uswdsModule: any = null; static override styles = css` :host { display: block; } `; @property({ type: Array }) steps: StepItem[] = []; @property({ type: Number }) currentStep = 1; @property({ type: Boolean }) showLabels = true; @property({ type: Boolean }) counters = false; @property({ type: Boolean }) center = false; // Alias for center property to match USWDS naming get centered() { return this.center; } set centered(value: boolean) { this.center = value; } @property({ type: Boolean }) small = false; @property({ type: String }) heading = ''; @property({ type: String, attribute: 'aria-label' }) override ariaLabel = 'Step indicator'; private slottedContent: string = ''; // Use light DOM for USWDS compatibility protected override createRenderRoot(): HTMLElement { return this as any; } override connectedCallback() { super.connectedCallback?.(); // Capture any initial content before render if (this.childNodes.length > 0 && this.steps.length === 0) { this.slottedContent = this.innerHTML; this.innerHTML = ''; } // Set web component managed flag to prevent USWDS auto-initialization conflicts this.setAttribute('data-web-component-managed', 'true'); // If steps array is provided, sync currentStep with current status this.syncCurrentStep(); } override firstUpdated(changedProperties: Map) { // ARCHITECTURE: Script Tag Pattern // USWDS is loaded globally via script tag in .storybook/preview-head.html // Components just render HTML - USWDS enhances automatically via window.USWDS super.firstUpdated?.(changedProperties); this.initializeUSWDSStepIndicator(); } override updated(changedProperties: Map) { super.updated(changedProperties); // Handle currentStep changes during the current update cycle // Don't trigger additional updates that would cause re-render loops if (changedProperties.has('currentStep') && !changedProperties.has('steps')) { // Update the internal step statuses without triggering a new update this.updateStepStatusesSync(); } // Apply captured content using DOM manipulation this.applySlottedContent(); } private applySlottedContent() { if (this.slottedContent) { const slotElement = this.querySelector('slot'); if (slotElement && this.steps.length === 0) { const tempDiv = document.createElement('div'); tempDiv.innerHTML = this.slottedContent; slotElement.replaceWith(...Array.from(tempDiv.childNodes)); } } } private updateStepStatusesSync() { // Update step statuses synchronously without triggering new updates // This prevents the "scheduled an update after update completed" warning for (let i = 0; i < this.steps.length; i++) { const newStatus = i + 1 < this.currentStep ? ('complete' as const) : i + 1 === this.currentStep ? ('current' as const) : ('incomplete' as const); // Only update if status actually changed to avoid unnecessary work if (this.steps[i]!.status !== newStatus) { // Create new object to ensure reactivity works properly this.steps[i] = { ...this.steps[i]!, status: newStatus }; } } // Force a re-render since we modified the array in place this.requestUpdate('steps'); } private syncCurrentStep(): void { // Find current step from steps array if not explicitly set const currentIndex = this.steps.findIndex((step) => step.status === 'current'); if (currentIndex !== -1 && this.currentStep !== currentIndex + 1) { this.currentStep = currentIndex + 1; } // Check if any updates are actually needed to avoid unnecessary re-renders const needsUpdate = false; if (needsUpdate) { this.requestUpdate(); } } private renderStepLabel(step: StepItem) { if (!this.showLabels) return ''; const isCurrent = step.status === 'current'; const isComplete = step.status === 'complete'; return html` ${step.label} ${isComplete ? 'completed' : isCurrent ? 'current' : 'not completed'} `; } private renderStep(step: StepItem, _index: number): any { const isCurrent = step.status === 'current'; const isComplete = step.status === 'complete'; const isIncomplete = step.status === 'incomplete'; const segmentClasses = [ 'usa-step-indicator__segment', isComplete ? 'usa-step-indicator__segment--complete' : '', isCurrent ? 'usa-step-indicator__segment--current' : '', isIncomplete ? 'usa-step-indicator__segment--incomplete' : '', ] .filter(Boolean) .join(' '); return html`
  • ${this.renderStepLabel(step)}
  • `; } private async initializeUSWDSStepIndicator() { // Prevent duplicate initialization if (this.uswdsModule) { console.log('⚠️ Step Indicator: Already initialized, skipping duplicate initialization'); return; } try { // Use standardized USWDS loader utility for consistency await this.updateComplete; const element = this.querySelector('.usa-step-indicator'); if (!element) { console.warn('Step indicator element not found'); return; } // Let USWDS handle the component using standard loader this.uswdsModule = await initializeUSWDSComponent(element, 'step-indicator'); console.log('✅ USWDS step indicator initialized successfully'); } catch (error) { console.warn('🔧 Step Indicator: USWDS integration failed:', error); } } override disconnectedCallback() { super.disconnectedCallback(); // Clean up USWDS module using standardized loader cleanupUSWDSComponent(this, this.uswdsModule); this.uswdsModule = null; } private renderHeader() { if (this.steps.length === 0) return ''; const currentStepData = this.steps[this.currentStep - 1]; const currentStepLabel = currentStepData ? currentStepData.label : ''; return html`

    Step ${this.currentStep} of ${this.steps.length} ${this.heading || currentStepLabel}

    `; } override render() { const containerClasses = [ 'usa-step-indicator', this.counters ? 'usa-step-indicator--counters' : '', this.centered ? 'usa-step-indicator--center' : '', this.small ? 'usa-step-indicator--small' : '', !this.showLabels ? 'usa-step-indicator--no-labels' : '', ] .filter(Boolean) .join(' '); return html`
      ${this.steps.length > 0 ? this.steps.map((step, index) => this.renderStep(step, index)) : ''}
    ${this.renderHeader()}
    `; } // Public API methods nextStep() { if (this.currentStep < this.steps.length) { this.currentStep += 1; } } previousStep() { if (this.currentStep > 1) { this.currentStep -= 1; } } goToStep(stepNumber: number) { if (stepNumber >= 1 && stepNumber <= this.steps.length) { this.currentStep = stepNumber; } } markStepComplete(stepNumber: number) { if (stepNumber >= 1 && stepNumber <= this.steps.length) { this.steps = this.steps.map((step, index) => index + 1 === stepNumber ? { ...step, status: 'complete' as const } : step ); this.requestUpdate(); } } setCurrentStep(stepNumber: number) { this.goToStep(stepNumber); } }