PageSourceSearch

https://railsblocks.com/assets/controllers/infinite_scroll_control…37e8bd396d2c65fffc1aed3f70e777ef82bfb24e.js

js railsblocks.com collected 2026-09-25 20:23:33 UTC 6,612 bytes, 208 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2
3export default class extends Controller {
4  static targets = ["viewport", "list", "sentinel", "controls", "button", "spinner", "label", "end", "status"];
5  static values = {
6    url: String,
7    scrollRoot: { type: String, default: "window" },
8    rootMargin: { type: String, default: "0px 0px 120px" },
9    animate: { type: Boolean, default: true },
10    manual: { type: Boolean, default: false },
11    loadMoreLabel: { type: String, default: "Load more" },
12    loadingLabel: { type: String, default: "Loading..." },
13    endMessage: { type: String, default: "You're all caught up" },
14    errorMessage: { type: String, default: "More items could not be loaded. Try again." },
15  };
16
17  connect() {
18    this.loading = false;
19    this.abortController = null;
20    this.buttonFadeFrame = null;
21    this.nextAnimationStart = performance.now();
22    this.animateItems(Array.from(this.listTarget.children));
23    this.resetControls();
24
25    this.observer = new IntersectionObserver(
26      ([entry]) => {
27        if (entry.isIntersecting) this.load();
28      },
29      {
30        root: this.scrollRootValue === "container" ? this.viewportTarget : null,
31        rootMargin: this.rootMarginValue,
32        threshold: 0.01,
33      },
34    );
35
36    if (this.hasUrlValue && this.urlValue) {
37      if (!this.manualValue) this.observer.observe(this.sentinelTarget);
38    } else {
39      this.finish();
40    }
41  }
42
43  disconnect() {
44    this.observer?.disconnect();
45    this.abortController?.abort();
46    this.cancelButtonFade();
47  }
48
49  async load() {
50    if (this.loading || !this.hasUrlValue || !this.urlValue) return;
51
52    this.loading = true;
53    this.observer.unobserve(this.sentinelTarget);
54    this.setControlsSticky(true);
55    this.controlsTarget.setAttribute("aria-busy", "true");
56    this.showButton({ fade: true });
57    this.endTarget.hidden = true;
58    this.buttonTarget.disabled = true;
59    this.spinnerTarget.hidden = false;
60    this.labelTarget.textContent = this.loadingLabelValue;
61    this.labelTarget.classList.add("animate-pulse");
62    const requestController = new AbortController();
63    this.abortController = requestController;
64
65    try {
66      const response = await fetch(this.urlValue, {
67        headers: { Accept: "application/json" },
68        credentials: "same-origin",
69        signal: requestController.signal,
70      });
71
72      if (!response.ok) throw new Error(`Request failed with ${response.status}`);
73
74      const { html, next_url: nextUrl } = await response.json();
75      const newItems = this.append(html);
76
77      this.urlValue = nextUrl || "";
78      this.statusTarget.textContent = `${newItems.length} more items loaded.`;
79      this.dispatch("loaded", { detail: { count: newItems.length, nextUrl: this.urlValue } });
80
81      if (this.urlValue && newItems.length > 0) {
82        this.resetControls();
83        if (!this.manualValue) this.observer.observe(this.sentinelTarget);
84      } else {
85        this.finish();
86      }
87    } catch (error) {
88      if (error.name !== "AbortError") this.showError();
89    } finally {
90      if (this.abortController === requestController) {
91        this.loading = false;
92        this.abortController = null;
93      }
94    }
95  }
96
97  append(html) {
98    const template = document.createElement("template");
99    template.innerHTML = html?.trim() || "";
100
101    const items = Array.from(template.content.children);
102    this.listTarget.append(template.content);
103    this.animateItems(items);
104
105    return items;
106  }
107
108  animateItems(items) {
109    if (!this.animateValue || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
110
111    const pendingItems = items.filter((item) => item.dataset.entered !== "true");
112    const now = performance.now();
113    const firstStart = Math.max(this.nextAnimationStart, now);
114
115    pendingItems.forEach((item, index) => {
116      const delay = firstStart + index * 45 - now;
117      this.animateItem(item, delay);
118    });
119
120    this.nextAnimationStart = firstStart + pendingItems.length * 45;
121  }
122
123  animateItem(item, delay) {
124    item.dataset.entered = "true";
125    item.animate?.(
126      [
127        { opacity: 0, transform: "translateY(8px) scale(0.99)" },
128        { opacity: 1, transform: "translateY(0) scale(1)" },
129      ],
130      {
131        duration: 240,
132        delay,
133        easing: "cubic-bezier(0.215, 0.61, 0.355, 1)",
134        fill: "backwards",
135      },
136    );
137  }
138
139  resetControls() {
140    this.setControlsSticky(true);
141    this.controlsTarget.removeAttribute("aria-busy");
142    this.manualValue ? this.showButton() : this.hideButton();
143    this.buttonTarget.disabled = false;
144    this.spinnerTarget.hidden = true;
145    this.labelTarget.textContent = this.loadMoreLabelValue;
146    this.labelTarget.classList.remove("animate-pulse");
147    this.endTarget.hidden = true;
148  }
149
150  finish() {
151    this.observer?.disconnect();
152    this.setControlsSticky(false);
153    this.controlsTarget.removeAttribute("aria-busy");
154    this.hideButton();
155    this.labelTarget.classList.remove("animate-pulse");
156    this.endTarget.hidden = false;
157    this.endTarget.textContent = this.endMessageValue;
158    this.statusTarget.textContent = "All items loaded.";
159  }
160
161  showError() {
162    this.setControlsSticky(true);
163    this.controlsTarget.removeAttribute("aria-busy");
164    this.showButton();
165    this.buttonTarget.disabled = false;
166    this.spinnerTarget.hidden = true;
167    this.labelTarget.textContent = "Try again";
168    this.labelTarget.classList.remove("animate-pulse");
169    this.statusTarget.textContent = this.errorMessageValue;
170  }
171
172  setControlsSticky(sticky) {
173    this.controlsTarget.classList.toggle("sticky", sticky);
174  }
175
176  showButton({ fade = false } = {}) {
177    this.cancelButtonFade();
178    this.buttonTarget.hidden = false;
179
180    if (!fade || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
181      this.buttonTarget.classList.remove("opacity-0");
182      this.buttonTarget.classList.add("opacity-100");
183      return;
184    }
185
186    this.buttonTarget.classList.remove("opacity-100");
187    this.buttonTarget.classList.add("opacity-0");
188    this.buttonFadeFrame = requestAnimationFrame(() => {
189      this.buttonTarget.classList.remove("opacity-0");
190      this.buttonTarget.classList.add("opacity-100");
191      this.buttonFadeFrame = null;
192    });
193  }
194
195  hideButton() {
196    this.cancelButtonFade();
197    this.buttonTarget.hidden = true;
198    this.buttonTarget.classList.remove("opacity-100");
199    this.buttonTarget.classList.add("opacity-0");
200  }
201
202  cancelButtonFade() {
203    if (this.buttonFadeFrame === null) return;
204
205    cancelAnimationFrame(this.buttonFadeFrame);
206    this.buttonFadeFrame = null;
207  }
208};

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.