PageSourceSearch

https://railsblocks.com/assets/controllers/stepper_controller-5f3f…cc0eaa71d044f1c4d200d669159edb93ab741a76.js

js railsblocks.com collected 2026-09-25 20:24:21 UTC 19,560 bytes, 643 lines download raw bytes

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.