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.