1import { Controller } from '@hotwired/stimulus' 2 3const DURATION_MS = 900 4 5/* 6 * Fetches a number this element is standing in for, then counts up to it when the element 7 * scrolls into view. 8 * 9 * For a figure that comes from somewhere slow enough that waiting on it would hold up the 10 * paint â /susfactor's GitHub star stamp, which is someone else's API. The server renders a 11 * fallback, this replaces it, and the swap is animated because the number arriving is worth 12 * looking at. 13 * 14 * `url` must answer JSON `{ count: <number> }`. 15 * 16 * A failed fetch is deliberately swallowed: the fallback is already on screen and is what 17 * this is meant to degrade to, so there is nothing to report and nothing to change. 18 */ 19export default class extends Controller { 20 static values = { url: String } 21 22 connect () { 23 this.abortController = new AbortController() 24 25 fetch(this.urlValue, { headers: { Accept: 'application/json' }, signal: this.abortController.signal }) 26 .then(response => response.ok ? response.json() : Promise.reject(response.status)) 27 .then(({ count }) => this.arrive(count)) 28 .catch(() => {}) 29 } 30 31 disconnect () { 32 // Aborting rejects the fetch, which the catch above swallows â so a response landing 33 // after the element is gone can no longer observe a detached node. 34 this.abortController.abort() 35 cancelAnimationFrame(this.frame) 36 this.observer?.disconnect() 37 this.observer = null 38 } 39 40 // Under reduced motion the number just changes â there is no animation to wait for, so 41 // there is no reason to hold it back until the row is on screen either. 42 // 43 // Read here rather than cached at connect, and no listener needed for it: a fetch and a 44 // scroll both stand between the two, so this is already the later moment. Sampling at 45 // connect meant a preference turned on in between still got the 900ms count. 46 arrive (target) { 47 if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { 48 this.write(target) 49 return 50 } 51 52 // One-shot: an element already on screen fires this immediately, which is the right 53 // behaviour for a fetch that resolved while the visitor was looking at the stamp. 54 this.observer = new IntersectionObserver(([entry]) => { 55 if (!entry.isIntersecting) return 56 this.disconnectObserver() 57 this.countUp(target) 58 }) 59 this.observer.observe(this.element) 60 } 61 62 disconnectObserver () { 63 this.observer?.disconnect() 64 this.observer = null 65 } 66 67 countUp (target) { 68 // Whatever the server rendered, minus the delimiter it rendered it with. 69 const from = Number(this.element.textContent.replace(/[^0-9.-]/g, '')) || 0 70 const start = performance.now() 71 72 const step = now => { 73 // easeOutCubic, the same curve the demo console's gauge settles on. 74 const t = Math.min((now - start) / DURATION_MS, 1) 75 const eased = 1 - Math.pow(1 - t, 3) 76 this.write(Math.round(from + (target - from) * eased)) 77 if (t < 1) this.frame = requestAnimationFrame(step) 78 } 79 80 this.frame = requestAnimationFrame(step) 81 } 82 83 // Grouped the way number_with_delimiter rendered the fallback, so the swap does not also 84 // change how the number is punctuated. 85 write (value) { 86 this.element.textContent = value.toLocaleString('en-US') 87 } 88}
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.