Skip to content

Instantly share code, notes, and snippets.

@dhh
Last active April 24, 2024 10:53
Show Gist options
  • Star 38 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save dhh/f459dfc3455d2376ce3a7ecb026e6fdf to your computer and use it in GitHub Desktop.
Save dhh/f459dfc3455d2376ce3a7ecb026e6fdf to your computer and use it in GitHub Desktop.
HEY's Stimulus Pagination Controller
/*
ERB template chunk from The Feed's display of emails:
<section class="postings postings--feed-style" id="postings"
data-controller="pagination" data-pagination-root-margin-value="40px">
<%= render partial: "postings/snippet", collection: @page.records, as: :posting, cached: true %>
<%= link_to(spinner_tag, url_for(page: @page.next_param),
class: "pagination-link", data: { pagination_target: "nextPageLink", preload: @page.first? }) unless @page.last? %>
</section>
*/
import { delay, nextFrame, request } from "helpers"
export default class extends ApplicationController {
static targets = [ "nextPageLink" ]
static values = { manualLoad: Boolean, rootMargin: String }
initialize() {
this.observeNextPageLink()
}
async loadNextPage(event) {
event?.preventDefault()
this.element.setAttribute("aria-busy", "true")
const html = await this.loadNextPageHTML()
await nextFrame()
this.element.setAttribute("aria-busy", "false")
this.nextPageLink.outerHTML = html
await delay(500)
this.observeNextPageLink()
}
// Private
async observeNextPageLink() {
if (this.manualLoadValue) return
await nextFrame()
const { nextPageLink, intersectionOptions } = this
if (!nextPageLink) return
if (nextPageLink.dataset.preload == "true") {
this.loadNextPage()
} else {
await nextIntersection(nextPageLink, intersectionOptions)
this.loadNextPage()
}
}
async loadNextPageHTML() {
const html = await request.get(this.nextPageLink.href)
const doc = new DOMParser().parseFromString(html, "text/html")
const element = doc.querySelector(`[data-controller~="${this.identifier}"]`)
return element ? element.innerHTML.trim() : ""
}
get nextPageLink() {
const links = this.nextPageLinkTargets
if (links.length > 1) console.warn("Multiple next page links", links)
return links[links.length - 1]
}
get intersectionOptions() {
const options = {
root: this.scrollableOffsetParent,
rootMargin: this.rootMarginValue
}
for (const [ key, value ] of Object.entries(options)) {
if (value) continue
delete options[key]
}
return options
}
get scrollableOffsetParent() {
const root = this.element.offsetParent
return root && root.scrollHeight > root.clientHeight ? root : null
}
}
function nextIntersection(element, options = {}) {
return new Promise(resolve => {
new IntersectionObserver(([ entry ], observer) => {
if (!entry.isIntersecting) return
observer.disconnect()
resolve()
}, options).observe(element)
})
}
@VividVisions
Copy link

Out of curiosity: Why isn't the next page loaded using a Turbo Stream append action?

@northeastprince
Copy link

Out of curiosity: Why isn't the next page loaded using a Turbo Stream append action?

I'm curious about this too.

@chloerei
Copy link

chloerei commented Mar 9, 2023

@VividVisions @northeastprince

Pagination is a common pattern, extracting it as a stimulus controller can reduce a lot of repetitive turbo_stream templates.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment