Pagination working for columns

Bring pagination system from KIA that is compatible with page refreshes
This commit is contained in:
Jorge Manrubia
2025-09-28 14:21:16 +02:00
parent b5dd91f4a4
commit 72fb15dd0f
8 changed files with 259 additions and 37 deletions
@@ -0,0 +1,124 @@
import { Controller } from "@hotwired/stimulus"
import { createElement } from "helpers/html_helpers"
import { delay, nextEvent } from "helpers/timing_helpers"
import { keepingScrollPosition } from "helpers/scroll_helpers"
import { get } from "@rails/request.js"
const DELAY_BEFORE_OBSERVING = 400
export default class extends Controller {
static targets = [ "paginationLink" ]
static values = {
paginateOnIntersection: { type: Boolean, default: false },
discardFrame: Boolean,
manualActivation: Boolean
}
initialize() {
if (!this.manualActivation) {
this.activate()
}
}
disconnect() {
this.observer?.disconnect()
}
async activate() {
await delay(DELAY_BEFORE_OBSERVING)
if (this.paginateOnIntersectionValue) {
this.observer = new IntersectionObserver(this.#intersect, { rootMargin: "300px", threshold: 1 })
}
}
async paginationLinkTargetConnected(linkElement) {
if (this.paginateOnIntersectionValue) {
await delay(DELAY_BEFORE_OBSERVING)
this.observer?.observe(linkElement)
}
}
// Actions
loadPage({ target }) {
this.#loadPaginationLink(target)
}
// Private
#intersect = ([ entry ]) => {
if (entry?.isIntersecting && entry.intersectionRatio === 1) {
this.#loadPaginationLink(entry.target)
}
}
#loadPaginationLink(linkElement) {
this.observer?.unobserve(linkElement)
keepingScrollPosition(this.#closestSiblingTo(linkElement) || linkElement.parentNode, this.#expandPaginationLink(linkElement))
}
#closestSiblingTo(element) {
return element.nextElementSibling || element.previousElementSibling
}
async #expandPaginationLink(linkElement) {
linkElement.setAttribute("aria-busy", "true")
if (this.discardFrameValue) {
await this.#replacePaginationLinkWithFrameContents(linkElement)
} else {
await this.#replacePaginationLinkWithFrame(linkElement)
}
linkElement.removeAttribute("aria-busy")
}
async #replacePaginationLinkWithFrameContents(linkElement) {
linkElement.outerHTML = await this.#loadHtmlFrom(linkElement)
}
async #loadHtmlFrom(linkElement) {
const response = await get(linkElement.href, { responseKind: "html" })
const html = await response.text
const doc = new DOMParser().parseFromString(html, "text/html")
const element = doc.querySelector(`turbo-frame#${linkElement.dataset.frame}`)
return element ? element.innerHTML.trim() : ""
}
#replacePaginationLinkWithFrame(linkElement) {
const turboFrame = this.#buildTurboFrameFor(linkElement)
this.#insertTurboFrameAtPosition(linkElement, turboFrame)
linkElement.remove()
}
#buildTurboFrameFor(linkElement) {
const turboFrame = createElement("turbo-frame", {
id: linkElement.dataset.frame,
src: linkElement.href,
refresh: "morph",
target: "_top"
})
this.#keepScrollPositionOnFrameRender(turboFrame, linkElement)
return turboFrame
}
async #keepScrollPositionOnFrameRender(turboFrame, linkElement) {
await nextEvent(turboFrame, "turbo:before-frame-render")
keepingScrollPosition(linkElement, nextEvent(turboFrame, "turbo:frame-render"))
}
#insertTurboFrameAtPosition(linkElement, turboFrame) {
const container = linkElement.parentNode.parentNode
if (linkElement.parentNode.firstElementChild === linkElement) {
container.prepend(turboFrame)
} else {
container.append(turboFrame)
}
}
}
+9
View File
@@ -0,0 +1,9 @@
export function createElement(name, properties) {
const element = document.createElement(name)
for (var key in properties) {
element.setAttribute(key, properties[key])
}
return element
}
+71
View File
@@ -0,0 +1,71 @@
export async function keepingScrollPosition(element, promise) {
const originalPosition = element.getBoundingClientRect()
await promise
const currentPosition = element.getBoundingClientRect()
const yDiff = currentPosition.top - originalPosition.top
const xDiff = currentPosition.left - originalPosition.left
findNearestScrollableYAncestor(element).scrollTop += yDiff
findNearestScrollableXAncestor(element).scrollLeft += xDiff
}
export function isFullyVisible(element, container = document.documentElement) {
const elementRect = element.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
return elementRect.top >= containerRect.top &&
elementRect.bottom <= containerRect.bottom &&
elementRect.left >= containerRect.left &&
elementRect.right <= containerRect.right
}
export function isScrolledToBottom(element, threshold = 100) {
return (element.scrollHeight - element.scrollTop - element.clientHeight) < threshold
}
export function scrollToBottom(element) {
element.scrollTop = element.scrollHeight
}
export function scrollIntoView(element, options = { inline: "center", block: "center", behavior: "instant" }) {
element.scrollIntoView(options)
}
// Private
function findNearestScrollableYAncestor(refElement) {
return findNearest(refElement, (element) => {
const largerThanVisibleArea = element.scrollHeight > element.clientHeight
const overflowY = getComputedStyle(element).overflowY
const scrollableStyle = overflowY === "scroll" || overflowY === "auto"
return largerThanVisibleArea && scrollableStyle
})
}
function findNearestScrollableXAncestor(refElement) {
return findNearest(refElement, (element) => {
const largerThanVisibleArea = element.scrollWidth > element.clientWidth
const overflowX = getComputedStyle(element).overflowX
const scrollableStyle = overflowX === "scroll" || overflowX === "auto"
return largerThanVisibleArea && scrollableStyle
})
}
function findNearest(element, fn) {
while (element) {
if (fn(element)) {
return element
} else {
element = element.parentElement
}
}
return document.documentElement
}
+5
View File
@@ -26,6 +26,11 @@ export function onNextEventLoopTick(callback) {
setTimeout(callback, 0)
}
export function nextEvent(element, eventName) {
return new Promise(resolve => element.addEventListener(eventName, resolve, { once: true }))
}
export function nextFrame() {
return new Promise(requestAnimationFrame)
}