PageSourceSearch

https://railsblocks.com/assets/controllers/feedback_controller-b3c…9946bd4ab76d27b5eadc25aeadd9f46f9f14a3ae.js

js railsblocks.com collected 2026-09-25 20:23:20 UTC 19,793 bytes, 660 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2import { animate, stagger, spring, delay } from "motion";
3
4export default class extends Controller {
5  static targets = ["button", "buttonText", "form", "textarea"];
6  static values = {
7    expanded: { type: Boolean, default: false },
8    anchorPoint: { type: String, default: "center" },
9  };
10
11  // Get anchor point configuration (transform origin, positioning, and transforms)
12  getAnchorConfig() {
13    switch (this.anchorPointValue) {
14      case "top-left":
15        return {
16          transformOrigin: "top left",
17          positioning: { top: "0", left: "0" },
18          baseTransform: "",
19        };
20      case "top":
21        return {
22          transformOrigin: "top center",
23          positioning: { top: "0", left: "50%" },
24          baseTransform: "translateX(-50%)",
25        };
26      case "top-right":
27        return {
28          transformOrigin: "top right",
29          positioning: { top: "0", right: "0" },
30          baseTransform: "",
31        };
32      case "left":
33        return {
34          transformOrigin: "center left",
35          positioning: { top: "50%", left: "0" },
36          baseTransform: "translateY(-50%)",
37        };
38      case "right":
39        return {
40          transformOrigin: "center right",
41          positioning: { top: "50%", right: "0" },
42          baseTransform: "translateY(-50%)",
43        };
44      case "bottom-left":
45        return {
46          transformOrigin: "bottom left",
47          positioning: { bottom: "0", left: "0" },
48          baseTransform: "",
49        };
50      case "bottom":
51        return {
52          transformOrigin: "bottom center",
53          positioning: { bottom: "0", left: "50%" },
54          baseTransform: "translateX(-50%)",
55        };
56      case "bottom-right":
57        return {
58          transformOrigin: "bottom right",
59          positioning: { bottom: "0", right: "0" },
60          baseTransform: "",
61        };
62      case "center":
63      default:
64        return {
65          transformOrigin: "center center",
66          positioning: { top: "50%", left: "50%" },
67          baseTransform: "translate(-50%, -50%)",
68        };
69    }
70  }
71
72  // Reset positioning and transform styles
73  resetPositioningStyles(element) {
74    element.style.top = "";
75    element.style.left = "";
76    element.style.right = "";
77    element.style.bottom = "";
78    element.style.transform = "";
79  }
80
81  // Apply positioning styles to the form based on anchor point value
82  applyPositioning() {
83    if (!this.hasFormTarget) return;
84
85    const config = this.getAnchorConfig();
86
87    // Reset all positioning first
88    this.resetPositioningStyles(this.formTarget);
89
90    // Apply positioning from config
91    Object.entries(config.positioning).forEach(([property, value]) => {
92      this.formTarget.style[property] = value;
93    });
94
95    // Apply base transform if present
96    if (config.baseTransform) {
97      this.formTarget.style.transform = config.baseTransform;
98    }
99  }
100
101  // Measure and cache the button's natural size (without inline overrides)
102  measureInitialButtonSize() {
103    if (!this.hasButtonTarget) return;
104
105    // Ensure the button is visible and has computed styles
106    if (this.buttonTarget.offsetParent === null || this.buttonTarget.offsetWidth === 0) {
107      // Element not visible yet, defer measurement
108      return false;
109    }
110
111    const rect = this.buttonTarget.getBoundingClientRect();
112    this.initialButtonWidth = Math.round(rect.width);
113    this.initialButtonHeight = Math.round(rect.height);
114
115    // Store and immediately apply the original button width to prevent any layout shifts
116    this.buttonTarget.style.width = `${this.initialButtonWidth}px`;
117
118    return true;
119  }
120
121  // Deferred initialization that waits for the DOM to be ready
122  deferredInitialization() {
123    // Use requestAnimationFrame to ensure DOM is fully rendered
124    requestAnimationFrame(() => {
125      const measured = this.measureInitialButtonSize();
126      if (!measured) {
127        // If measurement failed, try again after a short delay
128        delay(() => {
129          this.measureInitialButtonSize();
130        }, 0.05);
131      }
132    });
133  }
134
135  setupInitialState() {
136    this.resetToButtonState();
137    this.resetToFormState();
138  }
139
140  resetToFormState() {
141    if (this.hasFormTarget) {
142      // Hide form initially and reset all properties
143      this.formTarget.style.display = "none";
144      this.formTarget.style.opacity = "0";
145      this.formTarget.style.transformOrigin = "";
146      this.formTarget.style.width = "";
147      this.formTarget.style.height = "";
148      this.formTarget.style.zIndex = "";
149      this.formTarget.style.borderRadius = "";
150
151      // Apply positioning based on position value
152      this.applyPositioning();
153
154      // Set initial state for form elements to enable stagger animation
155      const formElements = [
156        this.formTarget.querySelector("textarea"),
157        this.formTarget.querySelector('button[type="submit"]'),
158      ].filter(Boolean);
159
160      formElements.forEach((element) => {
161        if (element) {
162          element.style.opacity = "0";
163          element.style.transform = "translateY(10px)";
164        }
165      });
166    }
167  }
168
169  resetToButtonState() {
170    if (this.hasButtonTarget) {
171      // Keep CSS hover/focus color transitions, but avoid transition-all conflicts
172      // with transform/opacity animations (Safari can flash on close/reset).
173      this.buttonTarget.style.transitionProperty = "background-color, color, border-color, box-shadow";
174      this.buttonTarget.style.transform = "scale(1)";
175      this.buttonTarget.style.opacity = "1";
176      this.buttonTarget.style.pointerEvents = "auto";
177      this.buttonTarget.style.display = "flex";
178      // Keep the measured width instead of resetting to auto
179      if (this.initialButtonWidth) {
180        this.buttonTarget.style.width = `${this.initialButtonWidth}px`;
181      }
182      this.buttonTarget.style.height = "auto";
183      this.buttonTarget.style.borderRadius = "8px";
184
185      // Force a reflow to ensure the button is interactive
186      this.buttonTarget.offsetHeight;
187    }
188
189    if (this.hasButtonTextTarget) {
190      this.buttonTextTarget.style.transform = "none";
191      this.buttonTextTarget.style.opacity = "1";
192      this.buttonTextTarget.style.pointerEvents = "none";
193    }
194  }
195
196  async toggle() {
197    if (this.expandedValue) {
198      await this.collapse();
199    } else {
200      await this.expand();
201    }
202  }
203
204  async expand() {
205    if (this.expandedValue) return;
206
207    this.expandedValue = true;
208
209    // Show form
210    this.formTarget.style.display = "block";
211    this.formTarget.style.zIndex = "50";
212
213    // Temporarily disable button clicks during animation
214    this.buttonTarget.style.pointerEvents = "none";
215    this.buttonTextTarget.style.pointerEvents = "none";
216
217    // Get button dimensions for smooth transition
218    const buttonRect = this.buttonTarget.getBoundingClientRect();
219
220    // Ensure form is properly measured by forcing a layout
221    this.formTarget.offsetHeight;
222
223    // Measure target form dimensions from CSS (ignores transforms)
224    const formWidth = this.formTarget.offsetWidth;
225    const formHeight = this.formTarget.offsetHeight;
226
227    // Calculate scale factors for performant transform-based animation
228    const scaleX = formWidth / buttonRect.width;
229    const scaleY = formHeight / buttonRect.height;
230
231    // Apply positioning and set form to final size but scaled down to button size initially
232    this.applyPositioning();
233    this.formTarget.style.width = `${formWidth}px`;
234    this.formTarget.style.height = `${formHeight}px`;
235    this.formTarget.style.transformOrigin = this.getAnchorConfig().transformOrigin;
236
237    // Handle positioning with existing transforms
238    const baseTransform = this.getAnchorConfig().baseTransform;
239    this.formTarget.style.transform = `${baseTransform} scale(${1 / scaleX}, ${1 / scaleY})`;
240    this.formTarget.style.opacity = "0";
241
242    // Use transform-based animation for better performance (avoids layout recalculation)
243    const finalTransform = `${baseTransform} scale(1, 1)`;
244    const formAnimation = animate(
245      this.formTarget,
246      {
247        opacity: 1,
248        transform: finalTransform,
249      },
250      {
251        type: spring,
252        stiffness: 300,
253        damping: 25,
254      }
255    );
256
257    // Animate border radius on the form
258    const borderAnimation = animate(
259      this.formTarget,
260      {
261        borderRadius: "12px",
262      },
263      {
264        type: spring,
265        stiffness: 300,
266        damping: 25,
267      }
268    );
269
270    // Simply fade out the button instead of resizing it
271    const buttonAnimation = animate(
272      this.buttonTarget,
273      {
274        opacity: 0,
275        transform: "scale(0.90)",
276      },
277      {
278        type: spring,
279        stiffness: 420,
280        damping: 32,
281        onComplete: () => {
282          // Ensure button stays in the final state
283          this.buttonTarget.style.opacity = "0";
284          this.buttonTarget.style.transform = "scale(0.90)";
285        },
286      }
287    );
288
289    // Stagger animate form elements for polished entrance
290    delay(() => {
291      if (this.hasTextareaTarget) {
292        const formElements = [
293          this.formTarget.querySelector("textarea"),
294          this.formTarget.querySelector('button[type="submit"]'),
295        ].filter(Boolean);
296
297        if (formElements.length > 0) {
298          animate(
299            formElements,
300            {
301              opacity: [0, 1],
302              y: [10, 0],
303            },
304            {
305              type: spring,
306              bounce: 0.35,
307              duration: 0.25,
308              delay: stagger(0.0, { startDelay: 0.15 }),
309            }
310          );
311        }
312      }
313    }, 0.05);
314
315    // Animate textarea focus
316    if (this.hasTextareaTarget) {
317      delay(() => {
318        this.textareaTarget.focus();
319      }, 0.3);
320    }
321
322    await Promise.all([formAnimation.finished, borderAnimation.finished, buttonAnimation.finished]);
323
324    // Re-enable form interactions once fully expanded
325    this.formTarget.style.pointerEvents = "auto";
326  }
327
328  async collapse() {
329    if (!this.expandedValue) return;
330
331    this.expandedValue = false;
332    this.formTarget.style.zIndex = "50";
333    this.formTarget.style.pointerEvents = "none";
334
335    // Use the cached button dimensions measured on page load
336    // Fallback to current measurements if initial size wasn't captured
337    if (!this.initialButtonWidth || !this.initialButtonHeight) {
338      this.measureInitialButtonSize();
339    }
340
341    // Fade out form contents
342    const formInnerElements = [
343      this.formTarget.querySelector("textarea"),
344      this.formTarget.querySelector('button[type="submit"]'),
345    ].filter(Boolean);
346
347    if (formInnerElements.length > 0) {
348      animate(
349        formInnerElements,
350        {
351          opacity: 0,
352        },
353        {
354          duration: 0.15,
355        }
356      );
357    }
358
359    // Use performant transform-based collapse animation
360    const currentFormRect = this.formTarget.getBoundingClientRect();
361    const scaleToButtonX = this.initialButtonWidth / currentFormRect.width;
362    const scaleToButtonY = this.initialButtonHeight / currentFormRect.height;
363
364    const animationOptions = {
365      type: spring,
366      stiffness: 300,
367      damping: 30,
368    };
369
370    // Scale down the form to button size and fade out
371    const baseTransform = this.getAnchorConfig().baseTransform;
372    const collapseTransform = `${baseTransform} scale(${scaleToButtonX}, ${scaleToButtonY})`;
373
374    const formAnimation = animate(
375      this.formTarget,
376      {
377        transform: collapseTransform,
378        opacity: 0,
379        borderRadius: "8px",
380      },
381      animationOptions
382    );
383
384    // Show the button by scaling it back up and fading in
385    const buttonAnimation = animate(
386      this.buttonTarget,
387      {
388        opacity: 1,
389        transform: "scale(1)",
390      },
391      {
392        ...animationOptions,
393        onComplete: () => {
394          // Ensure button stays in the final state
395          this.buttonTarget.style.opacity = "1";
396          this.buttonTarget.style.transform = "scale(1)";
397        },
398      }
399    );
400
401    // Wait for both animations to finish
402    await Promise.all([buttonAnimation.finished, formAnimation.finished]);
403
404    // Reset form state
405    this.resetToFormState();
406
407    // Set final button state after animation completes
408    if (this.hasButtonTarget) {
409      this.buttonTarget.style.pointerEvents = "auto";
410      this.buttonTarget.style.display = "flex";
411      if (this.initialButtonWidth) {
412        this.buttonTarget.style.width = `${this.initialButtonWidth}px`;
413      }
414      this.buttonTarget.style.height = "auto";
415      this.buttonTarget.style.borderRadius = "8px";
416    }
417
418    if (this.hasButtonTextTarget) {
419      this.buttonTextTarget.style.pointerEvents = "none";
420    }
421  }
422
423  // Show success state with smooth animation
424  async showSuccessState() {
425    if (!this.hasButtonTextTarget) return;
426
427    // Store original text if not already stored
428    if (!this.originalButtonText) {
429      this.originalButtonText = this.buttonTextTarget.textContent.trim();
430    }
431
432    // Animate text fade out
433    await animate(
434      this.buttonTextTarget,
435      {
436        opacity: 0,
437        y: -5,
438      },
439      {
440        duration: 0,
441      }
442    ).finished;
443
444    // Change content to success state
445    this.buttonTextTarget.innerHTML = `
446      <svg xmlns="http://www.w3.org/2000/svg" class="inline-block size-3.5" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" fill="none" stroke="currentColor" class="nc-icon-wrapper" d="M2.75 9.25L6.75 14.25 15.25 3.75"></path></g></svg>
447      Sent
448    `;
449
450    // Animate text fade in
451    await animate(
452      this.buttonTextTarget,
453      {
454        opacity: 1,
455        y: 0,
456      },
457      {
458        duration: 0.2,
459      }
460    ).finished;
461  }
462
463  // Reset button text to original state
464  async resetButtonText() {
465    if (!this.hasButtonTextTarget || !this.originalButtonText) return;
466
467    // Animate text fade out
468    await animate(
469      this.buttonTextTarget,
470      {
471        opacity: 0,
472        y: 5,
473      },
474      {
475        duration: 0.25,
476        type: spring,
477        bounce: 0.25,
478      }
479    ).finished;
480
481    // Reset to original text
482    this.buttonTextTarget.textContent = this.originalButtonText;
483
484    // Animate text fade in
485    await animate(
486      this.buttonTextTarget,
487      {
488        opacity: 1,
489        y: 0,
490      },
491      {
492        duration: 0.2,
493        type: spring,
494        bounce: 0.25,
495      }
496    ).finished;
497  }
498
499  // Handle form submission
500  async submit(event) {
501    event.preventDefault();
502
503    // Show success state on button
504    this.showSuccessState();
505
506    // Add submit logic here
507    console.log("Feedback submitted:", this.textareaTarget.value);
508
509    // Collapse after showing success
510    await this.collapse();
511
512    // Clear the form
513    if (this.hasTextareaTarget) {
514      this.textareaTarget.value = "";
515    }
516
517    // Reset button to original state after a delay
518    await this.resetButtonText();
519  }
520
521  // Close when clicking outside
522  handleOutsideClick(event) {
523    if (this.expandedValue && !this.element.contains(event.target)) {
524      this.collapse();
525    }
526  }
527
528  // Close when mouse down outside
529  handleOutsideMouseDown(event) {
530    if (this.expandedValue && !this.element.contains(event.target)) {
531      this.collapse();
532    }
533  }
534
535  // Handle escape key
536  async handleEscapeKey(event) {
537    if (event.key === "Escape" && this.expandedValue) {
538      await this.collapse();
539      // Focus the button after collapsing with escape
540      if (this.hasButtonTarget) {
541        this.buttonTarget.focus();
542      }
543    }
544  }
545
546  // Handle keyboard shortcuts on textarea
547  handleTextareaKeyDown(event) {
548    // Check for Ctrl+Enter or Cmd+Enter to submit
549    if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
550      event.preventDefault();
551      // Trigger the form's submit event to respect validation
552      const form = this.formTarget.querySelector("form");
553      if (form) {
554        form.requestSubmit();
555      }
556    }
557  }
558
559  // Handle Turbo navigation cleanup
560  beforeCache() {
561    // Ensure component is in collapsed state before page caching
562    if (this.expandedValue) {
563      this.expandedValue = false;
564      this.resetToButtonState();
565      this.resetToFormState();
566    }
567
568    // Reset button text to original state
569    if (this.hasButtonTextTarget && this.originalButtonText) {
570      this.buttonTextTarget.textContent = this.originalButtonText;
571      this.buttonTextTarget.style.opacity = "1";
572      this.buttonTextTarget.style.transform = "none";
573    }
574
575    // Maintain button width
576    if (this.hasButtonTarget && this.initialButtonWidth) {
577      this.buttonTarget.style.width = `${this.initialButtonWidth}px`;
578    }
579  }
580
581  beforeVisit() {
582    // Clean up any ongoing animations before navigation
583    if (this.expandedValue) {
584      this.expandedValue = false;
585    }
586  }
587
588  // Add event listeners when component mounts
589  connect() {
590    // Initialize state
591    this.expandedValue = false;
592    this.initialButtonWidth = null;
593    this.initialButtonHeight = null;
594    this.originalButtonText = null;
595
596    this.setupInitialState();
597
598    if (this.hasButtonTarget) {
599      // Ensure motion animations control transform/opacity without CSS transition interference.
600      this.buttonTarget.style.transitionProperty = "background-color, color, border-color, box-shadow";
601    }
602
603    // Use deferred initialization to handle Turbo timing issues
604    this.deferredInitialization();
605
606    // Bind event handlers
607    this.boundHandleOutsideClick = this.handleOutsideClick.bind(this);
608    this.boundHandleOutsideMouseDown = this.handleOutsideMouseDown.bind(this);
609    this.boundHandleEscapeKey = this.handleEscapeKey.bind(this);
610    this.boundHandleTextareaKeyDown = this.handleTextareaKeyDown.bind(this);
611    this.boundBeforeCache = this.beforeCache.bind(this);
612    this.boundBeforeVisit = this.beforeVisit.bind(this);
613
614    // Add document event listeners
615    document.addEventListener("click", this.boundHandleOutsideClick);
616    document.addEventListener("mousedown", this.boundHandleOutsideMouseDown);
617    document.addEventListener("keydown", this.boundHandleEscapeKey);
618
619    // Add textarea-specific event listener for keyboard shortcuts
620    if (this.hasTextareaTarget) {
621      this.textareaTarget.addEventListener("keydown", this.boundHandleTextareaKeyDown);
622    }
623
624    // Add Turbo event listeners
625    document.addEventListener("turbo:before-cache", this.boundBeforeCache);
626    document.addEventListener("turbo:before-visit", this.boundBeforeVisit);
627  }
628
629  // Remove event listeners when component unmounts
630  disconnect() {
631    // Clean up component state
632    if (this.expandedValue) {
633      this.expandedValue = false;
634    }
635
636    // Reset button text to original state
637    if (this.hasButtonTextTarget && this.originalButtonText) {
638      this.buttonTextTarget.textContent = this.originalButtonText;
639      this.buttonTextTarget.style.opacity = "1";
640      this.buttonTextTarget.style.transform = "none";
641    }
642
643    // Maintain button width
644    if (this.hasButtonTarget && this.initialButtonWidth) {
645      this.buttonTarget.style.width = `${this.initialButtonWidth}px`;
646    }
647
648    // Remove all event listeners
649    document.removeEventListener("click", this.boundHandleOutsideClick);
650    document.removeEventListener("mousedown", this.boundHandleOutsideMouseDown);
651    document.removeEventListener("keydown", this.boundHandleEscapeKey);
652    document.removeEventListener("turbo:before-cache", this.boundBeforeCache);
653    document.removeEventListener("turbo:before-visit", this.boundBeforeVisit);
654
655    // Remove textarea-specific event listener
656    if (this.hasTextareaTarget) {
657      this.textareaTarget.removeEventListener("keydown", this.boundHandleTextareaKeyDown);
658    }
659  }
660};

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.