PageSourceSearch

https://railsblocks.com/assets/controllers/toast_controller-35d241…10b4e0cde9508a0a82c06756433cb0d2e7d68a0f.js

js railsblocks.com collected 2026-09-25 20:24:30 UTC 30,832 bytes, 819 lines download raw bytes

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.