1import { Controller } from "@hotwired/stimulus"; 2 3const STORAGE_CURRENT_STEP_KEY = "__stepper_current_step"; 4const STORAGE_VISITED_STEPS_KEY = "__stepper_visited_steps"; 5 6export default class extends Controller { 7 static targets = ["step", "indicator", "line", "prevButton", "nextButton", "submitButton"]; 8 9 static values = { 10 // Core behavior 11 currentStep: { type: Number, default: 0 }, // Current active step index (0-based) 12 validateOnNext: { type: Boolean, default: true }, // Whether to validate required fields before advancing 13 allowSkip: { type: Boolean, default: false }, // Allow jumping to any step (bypasses visited-only restriction) 14 scrollOnChange: { type: Boolean, default: false }, // Scroll to stepper top when changing steps 15 saveProgress: { type: Boolean, default: true }, // Save form data to sessionStorage for persistence 16 storageKey: { type: String, default: "stepper-progress" }, // sessionStorage key for saving progress 17 cloak: { type: Boolean, default: true }, // Hide UI until controller is connected 18 animateEntrance: { type: Boolean, default: true }, // Animate first reveal 19 20 // Styling: Default classes that can be overridden via data attributes 21 indicatorBaseClass: { type: String, default: "" }, 22 indicatorCompleteClass: { 23 type: String, 24 default: 25 "bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 cursor-pointer hover:ring-2 hover:ring-black/5 dark:hover:ring-white/10", 26 }, 27 indicatorCurrentClass: { 28 type: String, 29 default: 30 "bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 ring-2 ring-black/5 dark:ring-white/10 cursor-pointer", 31 }, 32 indicatorVisitedClass: { 33 type: String, 34 default: 35 "bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dar
35k:text-neutral-300 border border-neutral-300 dark:border-neutral-600 cursor-pointer hover:bg-neutral-200 dark:hover:bg-neutral-700 hover:ring-2 hover:ring-black/5 dark:hover:ring-white/10", 36 }, 37 indicatorUpcomingClass: { 38 type: String, 39 default: 40 "bg-neutral-100 dark:bg-neutral-800 text-neutral-400 dark:text-neutral-500 border border-neutral-200 dark:border-neutral-700 cursor-not-allowed opacity-75", 41 }, 42 lineBaseClass: { type: String, default: "" }, 43 lineCompleteClass: { 44 type: String, 45 default: "bg-neutral-900 dark:bg-white", 46 }, 47 lineIncompleteClass: { 48 type: String, 49 default: "bg-neutral-200 dark:bg-neutral-700", 50 }, 51 }; 52 53 connect() { 54 this.formData = this.loadProgress() || {}; 55 this.currentStepValue = this.loadCurrentStep(); 56 this.visitedSteps = this.loadVisitedSteps(); 57 this.hasAnimatedInitialProgress = false; 58 59 this.handlePersistProgress = this.persistProgressSnapshot.bind(this); 60 this.handleKeydown = this.handleKeydownEvent.bind(this); 61 62 this.element.addEventListener("input", this.handlePersistProgress); 63 this.element.addEventListener("change", this.handlePersistProgress); 64 this.element.addEventListener("keydown", this.handleKeydown); 65 window.addEventListener("beforeunload", this.handlePersistProgress); 66 window.addEventListener("pagehide", this.handlePersistProgress); 67 68 this.restoreAllStepData(); 69 this.updateView(); 70 this.revealCloakedStepper(); 71 } 72 73 disconnect() { 74 if (!this.handlePersistProgress) return; 75 76 this.persistProgressSnapshot(); 77 this.element.removeEventListener("input", this.handlePersistProgress); 78 this.element.removeEventListener("change", this.handlePersistProgress); 79 this.element.removeEventListener("keydown", this.handleKeydown); 80 window.removeEventListener("beforeunload", this.handlePersistProgress); 81 window.removeEventListener("pagehide", this.handlePersistProgress); 82 } 83 84 handleKeydownEvent(event) { 85 if (event.key !== "Enter") return; 86 87 const target = event.target; 88 if (!(target instanceof HTMLElement)) return; 89 90 // Don't intercept Enter on textareas (allow new lines) 91 if (target.tagName === "TEXTAREA") return; 92 93 // Don't intercept Enter on buttons (let them handle their own action) 94 if (target.tagName === "BUTTON") return; 95 96 // For all other form elements (input, select), trigger next step or submit 97 if (target.matches("input, select")) { 98 event.preventDefault(); 99 100 if (this.currentStepValue === this.stepTargets.length - 1) { 101 this.submit(event); 102 } else { 103 this.next(event); 104 } 105 } 106 } 107 108 revealCloakedStepper() { 109 if (this.element.dataset.stepperRevealed === "true") { 110 return; 111 } 112 113 const shouldAnimateEntrance = this.animateEntranceValue; 114 if (!this.cloakValue && !shouldAnimateEntrance) { 115 return; 116 } 117 118 this.element.dataset.stepperRevealed = "true"; 119 120 requestAnimationFrame(() => { 121 if (this.cloakValue) { 122 this.element.style.visibility = "visible"; 123 } 124 125 const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; 126 if (!shouldAnimateEntrance || prefersReducedMotion) { 127 this.element.style.opacity = "1"; 128 this.element.style.transform = "none"; 129 return; 130 } 131 132 // Keep this lightweight: one wrapper animation avoids child-level jank. 133 this.element.style.willChange = "opacity, transform"; 134 135 const animation = this.element.animate( 136 [ 137 { opacity: 0, transform: "translateY(8px)" }, 138 { opacity: 1, transform: "translateY(0)" }, 139 ], 140 { 141 duration: 360, 142 easing: "cubic-bezier(0.22, 1, 0.36, 1)", 143 fill: "forwards", 144 }, 145 ); 146 147 const finalizeStyles = () => { 148 this.element.style.opacity = "1"; 149 this.element.style.transform = "none"; 150 this.element.style.willChange = "auto"; 151 }; 152 153 animation.onfinish = finalizeStyles; 154 animation.oncancel = finalizeStyles; 155 }); 156 } 157 158 next(event) { 159 event?.preventDefault(); 160 161 // Validate current step if validation is enabled 162 if (this.validateOnNextValue && !this.validateCurrentStep()) { 163 return; 164 } 165 166 // Save current step data 167 if (this.saveProgressValue) { 168 this.saveCurrentStepData(); 169 } 170 171 // Move to next step if not at the end 172 if (this.currentStepValue < this.stepTargets.length - 1) { 173 this.currentStepValue++; 174 this.visitedSteps.add(this.currentStepValue); 175 this.updateView(); 176 if (this.scrollOnChangeValue) this.scrollToTop(); 177 } 178 } 179 180 previous(event) { 181 event?.preventDefault(); 182 183 if (this.saveProgressValue) { 184 this.saveCurrentStepData(); 185 } 186 187 if (this.currentStepValue > 0) { 188 this.currentStepValue--; 189 this.updateView(); 190 if (this.scrollOnChangeValue) this.scrollToTop(); 191 } 192 } 193 194 goToStep(event) { 195 event?.preventDefault(); 196 const targetStep = Number.parseInt(event.currentTarget.dataset.step, 10); 197 198 if (!Number.isInteger(targetStep)) return; 199 200 // Allow navigation to: 201 // 1. Any visited step (includes previous steps and future steps that were already visited) 202 // 2. Any step if allowSkip is enabled 203 if (this.allowSkipValue || this.visitedSteps.has(targetStep)) { 204 if (this.saveProgressValue) { 205 this.saveCurrentStepData(); 206 } 207 208 this.currentStepValue = targetStep; 209 this.updateView(); 210 if (this.scrollOnChangeValue) this.scrollToTop(); 211 } 212 } 213 214 validateCurrentStep() { 215 const currentStep = this.stepTargets[this.currentStepValue]; 216 if (!currentStep) return true; 217 218 const requiredInputs = currentStep.querySelectorAll("input[required], select[required], textarea[required]"); 219 let isValid = true; 220
221 requiredInputs.forEach((input) => { 222 if (!input.checkValidity()) { 223 isValid = false; 224 input.reportValidity(); 225 } 226 }); 227 228 const validateEvent = new CustomEvent("stepper:validate", { 229 detail: { step: this.currentStepValue, isValid }, 230 bubbles: true, 231 cancelable: true, 232 }); 233 234 this.element.dispatchEvent(validateEvent); 235 236 if (validateEvent.defaultPrevented) { 237 isValid = false; 238 } 239 240 return isValid; 241 } 242 243 saveCurrentStepData() { 244 const currentStep = this.stepTargets[this.currentStepValue]; 245 if (!currentStep) return; 246 247 this.persistStepData(currentStep); 248 this.persistProgress(); 249 250 this.element.dispatchEvent( 251 new CustomEvent("stepper:save", { 252 detail: { step: this.currentStepValue, data: this.formData }, 253 bubbles: true, 254 }), 255 ); 256 } 257 258 persistStepData(step) { 259 const inputs = step.querySelectorAll("input, select, textarea"); 260 261 inputs.forEach((input) => { 262 if (!input.name) return; 263 264 if (input.type === "checkbox") { 265 this.formData[input.name] = input.checked; 266 } else if (input.type === "radio") { 267 if (input.checked) { 268 this.formData[input.name] = input.value; 269 } 270 } else { 271 this.formData[input.name] = input.value; 272 } 273 }); 274 } 275 276 persistProgress() { 277 if (!this.saveProgressValue || typeof sessionStorage === "undefined") return; 278 279 const payload = { 280 ...this.formData, 281 [STORAGE_CURRENT_STEP_KEY]: this.currentStepValue, 282 [STORAGE_VISITED_STEPS_KEY]: Array.from(this.visitedSteps), 283 }; 284 285 sessionStorage.setItem(this.storageKeyValue, JSON.stringify(payload)); 286 } 287 288 persistProgressSnapshot() { 289 if (!this.saveProgressValue) return; 290 291 const currentStep = this.stepTargets[this.currentStepValue]; 292 if (currentStep) this.persistStepData(currentStep); 293 this.persistProgress(); 294 } 295 296 loadProgress() { 297 if (!this.saveProgressValue || typeof sessionStorage === "undefined") return {}; 298 299 const saved = sessionStorage.getItem(this.storageKeyValue); 300 if (!saved) return {}; 301 302 try { 303 const parsed = JSON.parse(saved); 304 return parsed && typeof parsed === "object" ? parsed : {}; 305 } catch (error) { 306 return {}; 307 } 308 } 309 310 loadCurrentStep() { 311 const savedStep = Number.parseInt(this.formData[STORAGE_CURRENT_STEP_KEY], 10); 312 if (Number.isNaN(savedStep)) return this.currentStepValue; 313 314 const maxStep = Math.max(this.stepTargets.length - 1, 0); 315 return Math.min(Math.max(savedStep, 0), maxStep); 316 } 317 318 loadVisitedSteps() { 319 const maxStep = Math.max(this.stepTargets.length - 1, 0); 320 const savedVisitedSteps = this.formData[STORAGE_VISITED_STEPS_KEY]; 321 322 const visited = new Set(); 323 324 if (Array.isArray(savedVisitedSteps)) { 325 savedVisitedSteps.forEach((step) => { 326 if (Number.isInteger(step) && step >= 0 && step <= maxStep) { 327 visited.add(step); 328 } 329 }); 330 } 331 332 // Ensure previously completed path can always be navigated. 333 for (let i = 0; i <= this.currentStepValue; i++) { 334 visited.add(i); 335 } 336 337 visited.add(0); 338 return visited; 339 } 340 341 clearProgress() { 342 this.formData = {}; 343 this.visitedSteps = new Set([0]); 344 345 if (typeof sessionStorage !== "undefined") { 346 sessionStorage.removeItem(this.storageKeyValue); 347 } 348 } 349 350 updateView() { 351 this.stepTargets.forEach((step, index) => { 352 if (index === this.currentStepValue) { 353 step.classList.remove("hidden"); 354 step.classList.add("block"); 355 this.restoreStepData(step); 356 } else { 357 step.classList.add("hidden"); 358 step.classList.remove("block"); 359 } 360 }); 361 362 this.updateStepScopedElements(); 363 this.updateIndicators(); 364 this.updateButtons(); 365 this.updateReviewSections(); 366 367 this.element.dispatchEvent( 368 new CustomEvent("stepper:change", { 369 detail: { 370 step: this.currentStepValue, 371 totalSteps: this.stepTargets.length, 372 isFirst: this.currentStepValue === 0, 373 isLast: this.currentStepValue === this.stepTargets.length - 1, 374 }, 375 bubbles: true, 376 }), 377 ); 378 } 379 380 updateStepScopedElements() { 381 const stepScopedElements = this.element.querySelectorAll("[data-stepper-only-step]"); 382
383 stepScopedElements.forEach((element) => { 384 const onlyStep = Number.parseInt(element.dataset.stepperOnlyStep, 10); 385 const showOnCurrentStep = !Number.isNaN(onlyStep) && onlyStep === this.currentStepValue; 386 element.classList.toggle("hidden", !showOnCurrentStep); 387 }); 388 } 389 390 restoreStepData(step) { 391 const inputs = step.querySelectorAll("input, select, textarea"); 392 393 inputs.forEach((input) => { 394 if (input.name && this.formData[input.name] !== undefined) { 395 if (input.type === "checkbox") { 396 input.checked = this.formData[input.name]; 397 } else if (input.type === "radio") { 398 input.checked = input.value === this.formData[input.name]; 399 } else { 400 input.value = this.formData[input.name]; 401 } 402 } 403 }); 404 } 405 406 restoreAllStepData() { 407 this.stepTargets.forEach((step) => this.restoreStepData(step)); 408 } 409 410 updateIndicators() { 411 this.indicatorTargets.forEach((indicator, index) => { 412 const state = this.getIndicatorState(index); 413 414 this.applyIndicatorState(indicator, state); 415 indicator.setAttribute("aria-current", state === "current" ? "step" : "false"); 416 }); 417 418 if (this.hasLineTarget) { 419 this.lineTargets.forEach((line, index) => { 420 this.applyLineState(line, index < this.currentStepValue); 421 }); 422 } 423 } 424 425 getIndicatorState(index) { 426 if (index < this.currentStepValue) return "complete"; 427 if (index === this.currentStepValue) return "current"; 428 if (this.visitedSteps.has(index)) return "visited"; 429 return "upcoming"; 430 } 431 432 applyIndicatorState(indicator, state) { 433 const numberSpan = indicator.querySelector(".step-number"); 434 const checkIcon = indicator.querySelector(".step-check"); 435 436 if (state === "complete") { 437 numberSpan?.classList.add("hidden"); 438 checkIcon?.classList.remove("hidden"); 439 } else { 440 numberSpan?.classList.remove("hidden"); 441 checkIcon?.classList.add("hidden"); 442 } 443 444 const allStateClasses = [ 445 this.indicatorCompleteClassValue, 446 this.indicatorCurrentClassValue, 447 this.indicatorVisitedClassValue, 448 this.indicatorUpcomingClassValue, 449 ] 450 .join(" ") 451 .split(" ") 452 .filter((cls) => cls); 453 454 allStateClasses.forEach((cls) => indicator.classList.remove(cls)); 455 456 if (this.indicatorBaseClassValue) { 457 this.indicatorBaseClassValue.split(" ").forEach((cls) => { 458 if (cls) indicator.classList.add(cls); 459 }); 460 } 461 462 const stateClassMap = { 463 complete: this.indicatorCompleteClassValue, 464 current: this.indicatorCurrentClassValue, 465 visited: this.indicatorVisitedClassValue, 466 upcoming: this.indicatorUpcomingClassValue, 467 }; 468 469 const stateClasses = stateClassMap[state]; 470 if (stateClasses) { 471 stateClasses.split(" ").forEach((cls) => { 472 if (cls) indicator.classList.add(cls); 473 }); 474 } 475 } 476 477 applyLineState(line, isComplete) { 478 const allStateClasses = [this.lineCompleteClassValue, this.lineIncompleteClassValue] 479 .join(" ") 480 .split(" ") 481 .filter((cls) => cls); 482 483 allStateClasses.forEach((cls) => line.classList.remove(cls)); 484 485 if (this.lineBaseClassValue) { 486 this.lineBaseClassValue.split(" ").forEach((cls) => { 487 if (cls) line.classList.add(cls); 488 }); 489 } 490 491 const stateClass = isComplete ? this.lineCompleteClassValue : this.lineIncompleteClassValue; 492 if (stateClass) { 493 stateClass.split(" ").forEach((cls) => { 494 if (cls) line.classList.add(cls); 495 }); 496 } 497 } 498 499 updateButtons() { 500 const isFirstStep = this.currentStepValue === 0; 501 const isLastStep = this.currentStepValue === this.stepTargets.length - 1; 502 503 if (this.hasPrevButtonTarget) { 504 this.prevButtonTargets.forEach((btn) => { 505 if (isFirstStep) { 506 btn.classList.add("hidden"); 507 btn.disabled = true; 508 } else { 509 btn.classList.remove("hidden"); 510 btn.disabled = false; 511 } 512 }); 513 } 514 515 if (this.hasNextButtonTarget) { 516 this.nextButtonTargets.forEach((btn) => { 517 if (isLastStep) { 518 btn.classList.add("hidden"); 519 } else { 520 btn.classList.remove("hidden"); 521 } 522 }); 523 } 524 525 if (this.hasSubmitButtonTarget) {
526 this.submitButtonTargets.forEach((btn) => { 527 if (isLastStep) { 528 btn.classList.remove("hidden"); 529 } else { 530 btn.classList.add("hidden"); 531 this.setSubmitButtonLoadingState(btn, false); 532 } 533 }); 534 } 535 } 536 537 scrollToTop() { 538 this.element.scrollIntoView({ behavior: "smooth", block: "start" }); 539 } 540 541 updateReviewSections() { 542 this.element.querySelectorAll("[data-review-field]").forEach((field) => { 543 const fieldName = field.dataset.reviewField; 544 545 if (fieldName === "full_name" && this.formData.first_name && this.formData.last_name) { 546 field.textContent = `${this.formData.first_name} ${this.formData.last_name}`; 547 return; 548 } 549 550 const value = this.formData[fieldName]; 551 field.textContent = value !== undefined && value !== null && value !== "" ? value : "â"; 552 }); 553 554 this.updateProgressBar(); 555 this.updateStepCounter(); 556 this.updateStepLabel(); 557 } 558 559 updateProgressBar() { 560 const progressBar = this.element.querySelector("[data-stepper-progress-bar]"); 561 if (!progressBar || this.stepTargets.length === 0) return; 562 563 const percentage = ((this.currentStepValue + 1) / this.stepTargets.length) * 100; 564 565 if (!this.hasAnimatedInitialProgress) { 566 this.hasAnimatedInitialProgress = true; 567 progressBar.style.width = "0%"; 568 569 requestAnimationFrame(() => { 570 progressBar.style.width = `${percentage}%`; 571 }); 572 573 return; 574 } 575 576 progressBar.style.width = `${percentage}%`; 577 } 578 579 updateStepCounter() { 580 const stepCounter = this.element.querySelector("[data-stepper-step-counter]"); 581 if (stepCounter) { 582 stepCounter.textContent = `Step ${this.currentStepValue + 1} of ${this.stepTargets.length}`; 583 } 584 } 585 586 updateStepLabel() { 587 const stepLabel = this.element.querySelector("[data-stepper-step-label]"); 588 if (stepLabel?.dataset.stepLabels) { 589 const labels = stepLabel.dataset.stepLabels.split(","); 590 stepLabel.textContent = labels[this.currentStepValue] || `Step ${this.currentStepValue + 1}`; 591 } 592 } 593 594 reset() { 595 this.currentStepValue = 0; 596 this.clearProgress(); 597 this.updateView(); 598 } 599 600 submit(event) { 601 event?.preventDefault(); 602 603 if (this.validateOnNextValue && !this.validateCurrentStep()) { 604 return; 605 } 606 607 if (this.saveProgressValue) { 608 this.saveCurrentStepData(); 609 } 610 611 const submitEvent = new CustomEvent("stepper:submit", { 612 detail: { data: this.formData }, 613 bubbles: true, 614 cancelable: true, 615 }); 616 617 this.element.dispatchEvent(submitEvent); 618 619 if (!submitEvent.defaultPrevented) { 620 const form = this.element.closest("form"); 621 if (form) { 622 this.submitButtonTargets.forEach((button) => { 623 button.disabled = true; 624 this.setSubmitButtonLoadingState(button, true); 625 }); 626 627 this.clearProgress(); 628 form.requestSubmit(); 629 } else { 630 alert("Form submitted! (This is just a preview)"); 631 } 632 } 633 } 634 635 setSubmitButtonLoadingState(button, isLoading) { 636 const idleContent = button.querySelector("[data-stepper-submit-idle]"); 637 const loadingContent = button.querySelector("[data-stepper-submit-loading]"); 638 if (!idleContent || !loadingContent) return; 639 640 idleContent.classList.toggle("hidden", isLoading); 641 loadingContent.classList.toggle("hidden", !isLoading); 642 } 643};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.