1import { Controller } from "@hotwired/stimulus"; 2 3// Connects to data-controller="toast" 4export default class extends Controller { 5 static targets = ["container"]; 6 static values = { 7 position: { type: String, default: "top-center" }, 8 layout: { type: String, default: "default" }, // "default" (stacked) or "expanded" (all visible) 9 gap: { type: Number, default: 14 }, // Gap between toasts in expanded mode 10 autoDismissDuration: { type: Number, default: 4000 }, 11 limit: { type: Number, default: 3 }, // Maximum number of visible toasts 12 }; 13 14 connect() { 15 this.toasts = []; 16 this.heights = []; // Track toast heights like Sonner 17 this.expanded = this.layoutValue === "expanded"; 18 this.interacting = false; 19 this.isPrimaryController = false; 20 this.defaultMountParent = this.element?.parentNode || null; 21 this.defaultMountNextSibling = this.element?.nextSibling || null; 22 this.hoverZonePadding = 10; 23 24 this.registerController(); 25 this.activatePrimaryIfNeeded(); 26 } 27 28 updatePositionClasses() { 29 const container = this.containerTarget; 30 // Remove all position classes 31 container.classList.remove( 32 "right-0", 33 "left-0", 34 "left-1/2", 35 "-translate-x-1/2", 36 "top-0", 37 "bottom-0", 38 "mt-4", 39 "mb-4", 40 "mr-4", 41 "ml-4", 42 "sm:mt-6", 43 "sm:mb-6", 44 "sm:mr-6", 45 "sm:ml-6", 46 ); 47 48 // Add new position classes 49 const classes = this.positionClasses.split(" "); 50 container.classList.add(...classes); 51 } 52 53 disconnect() { 54 this.unregisterController(); 55 56 // Clear all auto-dismiss timers 57 if (this.autoDismissTimers) { 58 Object.values(this.autoDismissTimers).forEach((timer) => clearTimeout(timer)); 59 this.autoDismissTimers = {}; 60 } 61 62 // Clean up all toasts from the DOM 63 this.clearAllToasts(); 64 65 if (!this.isPrimaryController) return; 66 67 this.removePrimaryListeners(); 68 69 if (window.toast === this.boundShowToast) { 70 delete window.toast; 71 } 72 73 if (window.__toastPrimaryController === this) { 74 window.__toastPrimaryController = null; 75 } 76 77 this.isPrimaryController = false; 78 this.promoteNextPrimaryController(); 79 } 80 81 showToast(message, options = {}) { 82 const detail = { 83 type: options.type || "default", 84 message: message, 85 description: options.description || "", 86 position: options.position || window.currentToastPosition || this.positionValue, // Use stored position 87 html: options.html || "", 88 action: options.action || null, 89 secondaryAction: options.secondaryAction || null, 90 }; 91 92 window.dispatchEvent(new CustomEvent("toast-show", { detail })); 93 } 94 95 handleToastShow(event) { 96 if (!this.isPrimaryController) return; 97 event.stopPropagation(); 98 99 this.ensureGlobalHostVisible(); 100 101 // Update container position if a position is specified for this toast 102 if (event.detail.position) { 103 this.positionValue = event.detail.position; 104 window.currentToastPosition = event.detail.position; // Store globally 105 this.updatePositionClasses(); 106 } 107 108 const toast = { 109 id: `toast-${Math.random().toString(16).slice(2)}`, 110 mounted: false, 111 removed: false, 112 message: event.detail.message, 113 description: event.detail.description, 114 type: event.detail.type, 115 html: event.detail.html, 116 action: event.detail.action, 117 secondaryAction: event.detail.secondaryAction, 118 }; 119 120 // Add toast at the beginning of the array (newest first) 121 this.toasts.unshift(toast); 122 123 // Enforce toast limit synchronously to prevent race conditions 124 const activeToasts = this.toasts.filter((t) => !t.removed); 125 if (activeToasts.length > this.limitValue) { 126 const oldestActiveToast = activeToasts[activeToasts.length - 1]; 127 if (oldestActiveToast && !oldestActiveToast.removed) { 128 this.removeToast(oldestActiveToast.id, true); 129 } 130 } 131 132 this.renderToast(toast); 133 } 134 135 handleLayoutChange(event) { 136 if (!this.isPrimaryController) return; 137 this.layoutValue = event.detail.layout; 138 this.expanded = this.layoutValue === "expanded"; 139 this.updateAllToasts(); 140 } 141 142 beforeCache() { 143 if (!this.isPrimaryController) return; 144 145 // Clear all toasts before the page is cached to prevent stale toasts on navigation 146 this.clearAllToasts(); 147 // Reset position to default on navigation 148 window.currentToastPosition = this.element.dataset.toastPositionValue || "top-center"; 149 } 150 151 clearAllToasts() { 152 // Remove all toast elements from DOM 153 const container = this.containerTarget; 154 if (container) { 155 while (container.firstChild) { 156 container.removeChild(container.firstChild); 157 } 158 } 159 160 // Clear arrays 161 this.toasts = []; 162 this.heights = []; 163 if (this.layoutValue === "default") { 164 this.expanded = false;
165 } 166 167 // Clear all timers 168 if (this.autoDismissTimers) { 169 Object.values(this.autoDismissTimers).forEach((timer) => clearTimeout(timer)); 170 this.autoDismissTimers = {}; 171 } 172 173 this.hideGlobalHostIfIdle(); 174 } 175 176 handleMouseEnter() { 177 this.setExpandedState(true); 178 } 179 180 handleMouseLeave() { 181 this.setExpandedState(false); 182 } 183 184 renderToast(toast) { 185 this.ensureGlobalHostVisible(); 186 187 const container = this.containerTarget; 188 const li = this.createToastElement(toast); 189 container.insertBefore(li, container.firstChild); 190 191 // Measure height after a short delay to ensure rendering is complete 192 requestAnimationFrame(() => { 193 const toastEl = document.getElementById(toast.id); 194 if (toastEl) { 195 const height = toastEl.getBoundingClientRect().height; 196 197 // Add height to the beginning of heights array 198 this.heights.unshift({ 199 toastId: toast.id, 200 height: height, 201 }); 202 203 // Count only active (non-removed) toasts 204 const activeToasts = this.toasts.filter((t) => !t.removed); 205 206 // Trigger mount animation 207 requestAnimationFrame(() => { 208 toast.mounted = true; 209 toastEl.dataset.mounted = "true"; 210 211 // Update all toast positions 212 this.updateAllToasts(); 213 }); 214 215 // Schedule auto-dismiss for visible toasts 216 const activeToastIndex = activeToasts.findIndex((t) => t.id === toast.id); 217 if (activeToastIndex < this.limitValue) { 218 this.scheduleAutoDismiss(toast.id); 219 } 220 } 221 }); 222 } 223 224 scheduleAutoDismiss(toastId) { 225 if (!this.autoDismissTimers) { 226 this.autoDismissTimers = {}; 227 } 228 229 if (this.autoDismissTimers[toastId]) { 230 clearTimeout(this.autoDismissTimers[toastId]); 231 } 232 233 this.autoDismissTimers[toastId] = setTimeout(() => { 234 this.removeToast(toastId); 235 delete this.autoDismissTimers[toastId]; 236 }, this.autoDismissDurationValue); 237 } 238 239 createToastElement(toast) { 240 const li = document.createElement("li"); 241 li.id = toast.id; 242 li.className = "toast-item sm:max-w-xs"; 243 li.style.pointerEvents = "auto"; 244 li.dataset.mounted = "false"; 245 li.dataset.removed = "false"; 246 li.dataset.position = this.positionValue; 247 li.dataset.expanded = this.expanded.toString(); 248 li.dataset.visible = "true"; 249 li.dataset.front = "false"; 250 li.dataset.index = "0"; 251 252 if (!toast.description) { 253 li.classList.add("toast-no-description"); 254 } 255 256 const span = document.createElement("span"); 257 span.className = `relative flex flex-col items-start shadow-xs w-full transition-all duration-200 bg-white border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 rounded-lg sm:rounded-xl sm:max-w-xs group ${ 258 toast.html ? "p-0" : "p-4" 259 }`; 260 span.style.transitionTimingFunction = "cubic-bezier(0.4, 0, 0.2, 1)"; 261 262 if (toast.html) { 263 span.innerHTML = toast.html; 264 } else { 265 span.innerHTML = this.getToastHTML(toast); 266 } 267 268 // Add action button event listeners if not using custom HTML 269 if (!toast.html && (toast.action || toast.secondaryAction)) { 270 requestAnimationFrame(() => { 271 if (toast.action) { 272 const primaryBtn = span.querySelector('[data-action-type="primary"]'); 273 if (primaryBtn) { 274 primaryBtn.addEventListener("click", (e) => { 275 e.stopPropagation(); 276 toast.action.onClick(); 277 this.removeToast(toast.id); 278 }); 279 } 280 } 281 if (toast.secondaryAction) { 282 const secondaryBtn = span.querySelector('[data-action-type="secondary"]'); 283 if (secondaryBtn) { 284 secondaryBtn.addEventListener("click", (e) => { 285 e.stopPropagation(); 286 toast.secondaryAction.onClick(); 287 this.removeToast(toast.id); 288 }); 289 } 290 } 291 }); 292 } 293 294 // Add close button 295 const closeBtn = document.createElement("span"); 296 const hasActions = toast.action || toast.secondaryAction; 297 closeBtn.className = `absolute right-0 p-1.5 mr-2.5 text-neutral-400 duration-100 ease-in-out rounded-full cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-700 hover:text-neutral-500 dark:hover:text-neutral-300 ${ 298 !toast.description && !toast.html && !hasActions ? "top-1/2 -translate-y-1/2" : "top-0 mt-2.5" 299 }`; 300 closeBtn.innerHTML = `<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>`; 301 closeBtn.dataset.toastId = toast.id; 302 closeBtn.addEventListener("click", (e) => { 303 e.stopPropagation(); 304 this.removeToast(toast.id); 305 }); 306 307 span.appendChild(closeBtn); 308 li.appendChild(span); 309 310 return li; 311 } 312 313 getToastHTML(toast) { 314 const typeColors = { 315 success: "text-green-500 dark:text-green-400", 316 error: "text-red-500 dark:text-red-400", 317 info: "text-blue-500 dark:text-blue-400", 318 warning: "text-orange-400 dark:text-orange-300", 319 danger: "text-red-500 dark:text-red-400", 320 loading: "text-neutral-500 dark:text-neutral-400", 321 default: "text-neutral-800 dark:text-neutral-200", 322 }; 323 324 const color = typeColors[toast.type] || typeColors.default; 325 326 const icons = { 327 success: `<svg class="size-4.5 mr-1.5 -ml-1" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M9,1C4.589,1,1,4.589,1,9s3.589,8,8,8,8-3.589,8-8S13.411,1,9,1Zm3.843,5.708l-4.25,5.5c-.136,.176-.343,.283-.565,.291-.01,0-.019,0-.028,0-.212,0-.415-.09-.558-.248l-2.25-2.5c-.277-.308-.252-.782,.056-1.06,.309-.276,.781-.252,1.06,.056l1.648,1.832,3.701-4.789c.253-.328,.725-.388,1.052-.135,.328,.253,.388,.724,.135,1.052Z"></path></g></svg>`, 328 error: `<svg class="size-4.5 mr-1.5 -ml-1" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M9,1C4.589,1,1,4.589,1,9s3.589,8,8,8,8-3.589,8-8S13.411,1,9,1Zm3.28,10.22c.293,.293,.293,.768,0,1.061-.146,.146-.338,.22-.53,.22s-.384-.073-.53-.22l-2.22-2.22-2.22,2.22c-.146,.146-.338,.22-.53,.22s-.384-.073-.53-.22c-.293-.293-.293-.768,0-1.061l2.22-2.22-2.22-2.22c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l2.22,2.22,2.22-2.22c.293-.293,.768-.293,1.061,0s.293,.768,0,1.061l-2.22,2.22,2.22,2.22Z"></path></g></svg>`, 329 info: `<svg class="size-4.5 mr-1.5 -ml-1" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M9 1C4.5889 1 1 4.5889 1 9C1 13.4111 4.5889 17 9 17C13.4111 17 17 13.4111 17 9C17 4.5889 13.4111 1 9 1ZM9.75 12.75C9.75 13.1641 9.4141 13.5 9 13.5C8.5859 13.5 8.25 13.1641 8.25 12.75V9.5H7.75C7.3359 9.5 7 9.1641 7 8.75C7 8.3359 7.3359 8 7.75 8H8.5C9.1895 8 9.75 8.5605 9.75 9.25V12.75ZM9 6.75C8.448 6.75 8 6.301 8 5.75C8 5.199 8.448 4.75 9 4.75C9.552 4.75 10 5.199 10 5.75C10 6.301 9.552 6.75 9 6.75Z"></path></g></svg>`, 330 warning: `<svg class="size-4.5 mr-1.5 -ml-1" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M16.4364 12.5151L11.0101 3.11316C10.5902 2.39096 9.83872 1.96045 8.99982
3301.96045C8.16092 1.96045 7.40952 2.39106 6.98952 3.11316C6.98902 3.11366 6.98902 3.11473 6.98852 3.11523L1.56272 12.5156C1.14332 13.2436 1.14332 14.1128 1.56372 14.8398C1.98362 15.5664 2.73562 16 3.57492 16H14.4245C15.2639 16 16.0158 15.5664 16.4357 14.8398C16.8561 14.1127 16.8563 13.2436 16.4364 12.5151ZM8.24992 6.75C8.24992 6.3359 8.58582 6 8.99992 6C9.41402 6 9.74992 6.3359 9.74992 6.75V9.75C9.74992 10.1641 9.41402 10.5 8.99992 10.5C8.58582 10.5 8.24992 10.1641 8.24992 9.75V6.75ZM8.99992 13.5C8.44792 13.5 7.99992 13.0498 7.99992 12.5C7.99992 11.9502 8.44792 11.5 8.99992 11.5C9.55192 11.5 9.99992 11.9502 9.99992 12.5C9.99992 13.0498 9.55192 13.5 8.99992 13.5Z"></path></g></svg>`, 331 danger: `<svg class="size-4.5 mr-1.5 -ml-1" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M16.4364 12.5151L11.0101 3.11316C10.5902 2.39096 9.83872 1.96045 8.99982 1.96045C8.16092 1.96045 7.40952 2.39106 6.98952 3.11316C6.98902 3.11366 6.98902 3.11473 6.98852 3.11523L1.56272 12.5156C1.14332 13.2436 1.14332 14.1128 1.56372 14.8398C1.98362 15.5664 2.73562 16 3.57492 16H14.4245C15.2639 16 16.0158 15.5664 16.4357 14.8398C16.8561 14.1127 16.8563 13.2436 16.4364 12.5151ZM8.24992 6.75C8.24992 6.3359 8.58582 6 8.99992 6C9.41402 6 9.74992 6.3359 9.74992 6.75V9.75C9.74992 10.1641 9.41402 10.5 8.99992 10.5C8.58582 10.5 8.24992 10.1641 8.24992 9.75V6.75ZM8.99992 13.5C8.44792 13.5 7.99992 13.0498 7.99992 12.5C7.99992 11.9502 8.44792 11.5 8.99992 11.5C9.55192 11.5 9.99992 11.9502 9.99992 12.5C9.99992 13.0498 9.55192 13.5 8.99992 13.5Z"></path></g></svg>`, 332 loading: `<svg class="size-4.5 mr-1.5 -ml-1 animate-spin" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="m9,17c-4.4111,0-8-3.5889-8-8S4.5889,1,9,1s8,3.5889,8,8-3.5889,8-8,8Zm0-14.5c-3.584,0-6.5,2.916-6.5,6.5s2.916,6.5,6.5,6.5,6.5-2.916,6.5-6.5-2.916-6.5-6.5-6.5Z" opacity=".4" stroke-width="0"></path><path d="m16.25,9.75c-.4141,0-.75-.3359-.75-.75,0-3.584-2.916-6.5-6.5-6.5-.4141,0-.75-.3359-.75-.75s.3359-.75.75-.75c4.4111,0,8,3.5889,8,8,0,.4141-.3359.75-.75.75Z" stroke-width="0"></path></g></svg> 333 `, 334 }; 335 336 const icon = icons[toast.type] || ""; 337 338 // Action buttons HTML 339 const hasActions = toast.action || toast.secondaryAction; 340 const actionsHTML = hasActions 341 ? `<div></div> 342 <div class="flex justify-end items-center gap-2 mt-0.5"> 343 ${ 344 toast.secondaryAction 345 ? `<button data-action-type="secondary" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-2 py-1.5 text-xs font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-neutral-700/50 dark:text-neutral-50 dark:hover:bg-neutral-700/75 dark:focus-visible:outline-neutral-200">${toast.secondaryAction.label}</button>` 346 : "" 347 } 348 ${ 349 toast.action 350 ? `<button data-action-type="primary" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-2 py-1.5 text-xs font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">${toast.action.label}</button>` 351 : "" 352 } 353 </div>` 354 : ""; 355 356 return ` 357 <div class="relative w-full"> 358 <div class="grid grid-cols-[auto_1fr] gap-y-1.5 items-start"> 359 <div class="flex items-center h-full ${color}"> 360 ${icon} 361 </div> 362 <p class="text-[13px] font-medium text-neutral-800 dark:text-neutral-200 pr-6"> 363 ${toast.message} 364 </p> 365 ${ 366 toast.description 367 ? `<div></div>
368 <div class="text-xs text-neutral-600 dark:text-neutral-400"> 369 ${toast.description} 370 </div>` 371 : "" 372 } 373 ${actionsHTML} 374 </div> 375 </div> 376 `; 377 } 378 379 removeToast(id, isOverflow = false) { 380 const toast = this.toasts.find((t) => t.id === id); 381 if (!toast || toast.removed) return; 382 383 const toastEl = document.getElementById(id); 384 if (!toastEl) return; 385 386 // Mark as removed 387 toast.removed = true; 388 toastEl.dataset.removed = "true"; 389 390 // Mark if this is an overflow removal 391 if (isOverflow) { 392 toastEl.dataset.overflow = "true"; 393 } 394 395 // Clear auto-dismiss timer 396 if (this.autoDismissTimers && this.autoDismissTimers[id]) { 397 clearTimeout(this.autoDismissTimers[id]); 398 delete this.autoDismissTimers[id]; 399 } 400 401 // Wait for exit animation to complete 402 setTimeout(() => { 403 // Remove from arrays 404 this.toasts = this.toasts.filter((t) => t.id !== id); 405 this.heights = this.heights.filter((h) => h.toastId !== id); 406 407 // Remove from DOM 408 if (toastEl.parentNode) { 409 toastEl.parentNode.removeChild(toastEl); 410 } 411 412 // Update remaining toasts 413 this.updateAllToasts(); 414 415 this.hideGlobalHostIfIdle(); 416 417 // Schedule auto-dismiss for newly visible toast 418 if (this.toasts.length >= this.limitValue) { 419 const newlyVisibleToast = this.toasts[this.limitValue - 1]; 420 if (newlyVisibleToast && !this.autoDismissTimers[newlyVisibleToast.id]) { 421 this.scheduleAutoDismiss(newlyVisibleToast.id); 422 } 423 } 424 }, 400); // Match the exit animation duration (400ms) 425 } 426 427 updateAllToasts() { 428 requestAnimationFrame(() => { 429 const visibleToasts = this.limitValue; 430 431 // Calculate visual index (excluding removed toasts) 432 let visualIndex = 0; 433 434 this.toasts.forEach((toast, index) => { 435 const toastEl = document.getElementById(toast.id); 436 if (!toastEl) return; 437 438 // Handle overflow toasts (removed due to limit) separately 439 if (toast.removed && toastEl.dataset.overflow === "true") { 440 // Position as if it's the last visible toast 441 toastEl.dataset.index = String(this.limitValue - 1); 442 toastEl.dataset.visible = "true"; 443 toastEl.dataset.expanded = this.expanded.toString(); 444 toastEl.dataset.position = this.positionValue; 445 // Set lowest z-index so it appears behind all active toasts 446 toastEl.style.setProperty("--toast-z-index", 0); 447 toastEl.style.setProperty("--toast-index", this.limitValue - 1); 448 return; 449 } 450 451 // Skip other removed toasts 452 if (toast.removed) return; 453 454 const isVisible = visualIndex < visibleToasts; 455 const isFront = visualIndex === 0; 456 457 // Calculate offset (cumulative height of non-removed toasts before this one) 458 let offset = 0; 459 for (let i = 0; i < index; i++) { 460 if (this.toasts[i].removed) continue; 461 462 const heightInfo = this.heights.find((h) => h.toastId === this.toasts[i].id); 463 if (heightInfo) { 464 offset += heightInfo.height + this.gapValue; 465 } 466 } 467 468 // Update data attributes - CSS will handle styling 469 toastEl.dataset.expanded = this.expanded.toString(); 470 toastEl.dataset.visible = isVisible.toString(); 471 toastEl.dataset.front = isFront.toString(); 472 toastEl.dataset.index = visualIndex.toString(); 473 toastEl.dataset.position = this.positionValue; 474 475 // Set CSS custom properties for dynamic values 476 toastEl.style.setProperty("--toast-z-index", 100 - visualIndex); 477 toastEl.style.setProperty("--toast-offset", `${offset}px`); 478 toastEl.style.setProperty("--toast-index", visualIndex); 479 480 // Set the initial height of this specific toast 481 const heightInfo = this.heights.find((h) => h.toastId === toast.id); 482 if (heightInfo) { 483 toastEl.style.setProperty("--initial-height", `${heightInfo.height}px`); 484 } 485 486 // In stacked mode, set all toasts to front toast height for uniform appearance 487 if (!this.expanded) { 488 const frontHeight = this.heights[0]?.height || 0; 489 toastEl.style.setProperty("--front-toast-height", `${frontHeight}px`); 490 } else { 491 toastEl.style.removeProperty("--front-toast-height"); 492 } 493 494 // Increment visual index for next non-removed toast 495 visualIndex++; 496 }); 497 498 // Update container height immediately and after transitions complete 499 this.updateContainerHeight(); 500 setTimeout(() => this.updateContainerHeight(), 400); 501 }); 502 } 503 504 updateContainerHeight() { 505 // Count non-removed toasts 506 const activeToasts = this.toasts.filter((t) => !t.removed); 507 508 if (activeToasts.length === 0) { 509 this.containerTarget.style.height = "0px"; 510 this.hideGlobalHostIfIdle(); 511 return; 512 } 513 514 if (this.expanded) { 515 // In expanded mode, calculate total height of all visible non-removed toasts 516 let totalHeight = 0; 517 const visibleToasts = Math.min(activeToasts.length, this.limitValue); 518 519 for (let i = 0; i < visibleToasts; i++) { 520 const heightInfo = this.heights.find((h) => h.toastId === activeToasts[i].id); 521 if (heightInfo) { 522 totalHeight += heightInfo.height; 523 if (i < visibleToasts - 1) { 524 totalHeight += this.gapValue; 525 } 526 } 527 } 528 529 this.containerTarget.style.height = totalHeight + "px"; 530 } else { 531 // In stacked mode, calculate based on front non-removed toast + peek amounts 532 const frontToast = activeToasts[0]; 533 const frontHeight = frontToast ? this.heights.find((h) => h.toastId === frontToast.id)?.height || 0 : 0; 534 const peekAmount = 24; 535 const visibleCount = Math.min(activeToasts.length, this.limitValue); 536 const totalHeight = frontHeight + peekAmount * (visibleCount - 1); 537 538 this.containerTarget.style.height = totalHeight + "px"; 539 } 540 } 541 542 get positionClasses() { 543 const positions = { 544 "top-right": "right-0 top-0 mt-4 mr-4 sm:mt-6 sm:mr-6", 545 "top-left": "left-0 top-0 mt-4 ml-4 sm:mt-6 sm:ml-6", 546 "top-center": "left-1/2 -translate-x-1/2 top-0 mt-4 sm:mt-6", 547 "bottom-right": "right-0 bottom-0 mb-4 mr-4 sm:mr-6 sm:mb-6", 548 "bottom-left": "left-0 bottom-0 mb-4 ml-4 sm:ml-6 sm:mb-6", 549 "bottom-center": "left-1/2 -translate-x-1/2 bottom-0 mb-4 sm:mb-6", 550 }; 551 552 return positions[this.positionValue] || positions["top-center"]; 553 } 554 555 registerController() { 556 if (!window.__toastControllers) { 557 window.__toastControllers = new Set(); 558 } 559 560 window.__toastControllers.add(this); 561 } 562 563 unregisterController() { 564 if (window.__toastControllers) { 565 window.__toastControllers.delete(this); 566 } 567 } 568 569 activatePrimaryIfNeeded() { 570 const existingPrimary = window.__toastPrimaryController; 571 const existingPrimaryConnected = existingPrimary?.element?.isConnected; 572 573 if (existingPrimary && existingPrimary !== this && existingPrimaryConnected) { 574 return; 575 } 576 577 this.becomePrimaryController(); 578 } 579 580 becomePrimaryController() { 581 if (this.isPrimaryController) return; 582 583 this.isPrimaryController = true; 584 window.__toastPrimaryController = this; 585 586 this.initializePositionState(); 587 this.updatePositionClasses(); 588 this.mountToGlobalHost(); 589 this.applyPointerEventPolicy(); 590 591 this.boundShowToast = this.showToast.bind(this); 592 window.toast = this.boundShowToast; 593 594 this.boundHandleToastShow = this.handleToastShow.bind(this); 595 this.boundHandleLayoutChange = this.handleLayoutChange.bind(this); 596 this.boundBeforeCache = this.beforeCache.bind(this); 597 this.boundPointerMove = this.handleGlobalPointerMove.bind(this); 598 this.boundPointerLeave = this.handleGlobalPointerLeave.bind(this); 599 this.boundHandleDialogStateChange = this.handleDialogStateChange.bind(this); 600 601 window.addEventListener("toast-show", this.boundHandleToastShow); 602 window.addEventListener("set-toasts-layout", this.boundHandleLayoutChange); 603 window.addEventListener("pointermove", this.boundPointerMove, { passive: true }); 604 window.addEventListener("pointerleave", this.boundPointerLeave); 605 window.addEventListener("blur", this.boundPointerLeave); 606 document.addEventListener("turbo:before-cache", this.boundBeforeCache); 607 this.startDialogStateObserver(); 608 } 609 610 removePrimaryListeners() { 611 if (this.boundHandleToastShow) { 612 window.removeEventListener("toast-show", this.boundHandleToastShow); 613 } 614 615 if (this.boundHandleLayoutChange) { 616 window.removeEventListener("set-toasts-layout", this.boundHandleLayoutChange); 617 } 618 619 if (this.boundPointerMove) { 620 window.removeEventListener("pointermove", this.boundPointerMove); 621 } 622 623 if (this.boundPointerLeave) { 624 window.removeEventListener("pointerleave", this.boundPointerLeave); 625 window.removeEventListener("blur", this.boundPointerLeave); 626 } 627 628 if (this.boundBeforeCache) { 629 document.removeEventListener("turbo:before-cache", this.boundBeforeCache); 630 } 631 632 this.stopDialogStateObserver(); 633 } 634 635 promoteNextPrimaryController() { 636 if (!window.__toastControllers?.size) return; 637 638 const nextPrimary = Array.from(window.__toastControllers).find((controller) => controller?.element?.isConnected); 639 if (nextPrimary) { 640 nextPrimary.becomePrimaryController(); 641 } 642 } 643 644 initializePositionState() { 645 if (!window.currentToastPosition) { 646 window.currentToastPosition = this.positionValue; 647 return; 648 } 649 650 this.positionValue = window.currentToastPosition; 651 } 652 653 mountToGlobalHost() { 654 this.globalHost = this.element; 655 656 if (!this.globalHost) return; 657 658 if (typeof this.globalHost.showPopover === "function" && !this.globalHost.hasAttribute("popover")) { 659 this.globalHost.setAttribute("popover", "manual"); 660 } 661 662 const mountRoot = this.preferredMountRoot(); 663 if (!mountRoot) return; 664 665 if (this.globalHost.parentNode !== mountRoot) { 666 if (typeof this.globalHost.hidePopover === "function") { 667 try { 668 if (this.globalHost.matches(":popover-open")) { 669 this.globalHost.hidePopover(); 670 } 671 } catch { 672 // Ignore popover state errors before remount. 673 } 674 } 675 676 if (mountRoot === this.defaultMountParent && this.defaultMountNextSibling?.parentNode === mountRoot) { 677 mountRoot.insertBefore(this.globalHost, this.defaultMountNextSibling); 678 } else { 679 mountRoot.appendChild(this.globalHost); 680 } 681 } 682 } 683 684 preferredMountRoot() { 685 const openDialog = this.topmostOpenDialog(); 686 if (openDialog) return openDialog; 687 688 const parentDialog = this.defaultMountParent instanceof Element ? this.defaultMountParent.closest("dialog") : null; 689 if (parentDialog && !parentDialog.open) return document.body; 690 691 if (this.defaultMountParent?.tagName === "DIALOG" && !this.defaultMountParent.open) { 692 return document.body; 693 } 694 695 return this.defaultMountParent || document.body; 696 } 697 698 topmostOpenDialog() { 699 const openDialogs = Array.from(document.querySelectorAll("dialog[open]")).filter((dialog) => dialog.isConnected); 700 if (openDialogs.length === 0) return null; 701 702 return openDialogs[openDialogs.length - 1]; 703 } 704 705 applyPointerEventPolicy() { 706 if (this.element) { 707 this.element.style.pointerEvents = "none"; 708 } 709 710 if (this.hasContainerTarget) { 711 this.containerTarget.style.pointerEvents = "none"; 712 } 713 } 714 715 ensureGlobalHostVisible() { 716 this.mountToGlobalHost(); 717 718 if (!this.globalHost || typeof this.globalHost.showPopover !== "function") return; 719 720 try { 721 if (this.globalHost.matches(":popover-open")) { 722 this.globalHost.hidePopover(); 723 } 724 } catch { 725 // Ignore popover close errors. 726 } 727 728 try { 729 this.globalHost.showPopover(); 730 } catch { 731 // No-op: keep standard DOM rendering when Popover API isn't available. 732 } 733 } 734 735 hideGlobalHostIfIdle() { 736 if (!this.globalHost || typeof this.globalHost.hidePopover !== "function") return; 737 738 const hasActiveToasts = this.toasts.some((toast) => !toast.removed); 739 if (hasActiveToasts) return; 740 741 try { 742 if (this.globalHost.matches(":popover-open")) { 743 this.globalHost.hidePopover(); 744 } 745 } catch { 746 // No-op: host stays visible when popover state isn't available. 747 } 748 } 749 750 startDialogStateObserver() { 751 if (this.dialogStateObserver) return; 752 if (!document.body) return; 753 754 this.dialogStateObserver = new MutationObserver(this.boundHandleDialogStateChange); 755 this.dialogStateObserver.observe(document.body, { 756 subtree: true, 757 childList: true, 758 attributes: true, 759 attributeFilter: ["open"], 760 }); 761 } 762 763 stopDialogStateObserver() { 764 if (!this.dialogStateObserver) return; 765 this.dialogStateObserver.disconnect(); 766 this.dialogStateObserver = null; 767 } 768 769 handleDialogStateChange() { 770 if (!this.isPrimaryController) return; 771 772 this.mountToGlobalHost(); 773 774 const hasActiveToasts = this.toasts.some((toast) => !toast.removed); 775 if (hasActiveToasts) { 776 this.ensureGlobalHostVisible(); 777 } else { 778 this.hideGlobalHostIfIdle(); 779 } 780 } 781 782 setExpandedState(nextExpanded) { 783 if (this.layoutValue !== "default") return; 784 if (nextExpanded === this.expanded) return; 785 if (!nextExpanded && this.interacting) return; 786 787 this.expanded = nextExpanded; 788 this.updateAllToasts(); 789 } 790 791 handleGlobalPointerMove(event) { 792 if (!this.isPrimaryController) return; 793 if (!this.hasContainerTarget) return; 794 if (!this.toasts.some((toast) => !toast.removed)) { 795 this.setExpandedState(false); 796 return; 797 } 798 799 const inHoverZone = this.isInHoverZone(event.clientX, event.clientY); 800 this.setExpandedState(inHoverZone); 801 } 802 803 handleGlobalPointerLeave() { 804 if (!this.isPrimaryController) return; 805 this.setExpandedState(false); 806 } 807
808 isInHoverZone(clientX, clientY) { 809 const rect = this.containerTarget.getBoundingClientRect(); 810 const pad = this.hoverZonePadding; 811 812 return ( 813 clientX >= rect.left - pad && 814 clientX <= rect.right + pad && 815 clientY >= rect.top - pad && 816 clientY <= rect.bottom + pad 817 ); 818 } 819};
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.