Pagination working for columns
Bring pagination system from KIA that is compatible with page refreshes
This commit is contained in:
@@ -80,6 +80,7 @@
|
|||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
|
|
||||||
.blank-slate,
|
.blank-slate,
|
||||||
|
.pagination-link,
|
||||||
.card {
|
.card {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,4 @@
|
|||||||
module CardsHelper
|
module CardsHelper
|
||||||
def cards_next_page_link(target, page:, filter:, fetch_on_visible: false, data: {}, **options)
|
|
||||||
url = cards_previews_path(target: target, page: page.next_param, **filter.as_params)
|
|
||||||
|
|
||||||
if fetch_on_visible
|
|
||||||
data[:controller] = "#{data[:controller]} fetch-on-visible"
|
|
||||||
data[:fetch_on_visible_url_value] = url
|
|
||||||
end
|
|
||||||
|
|
||||||
link_to "Load more",
|
|
||||||
url,
|
|
||||||
id: "#{target}-load-page-#{page.next_param}",
|
|
||||||
data: { turbo_stream: true, **data },
|
|
||||||
class: "btn txt-small",
|
|
||||||
**options
|
|
||||||
end
|
|
||||||
|
|
||||||
def public_collection_cards_next_page_link(collection, target, fetch_on_visible: false, page:, data: {}, **options)
|
|
||||||
url = public_collection_card_previews_path(collection.publication.key, target: target, page: page.next_param)
|
|
||||||
|
|
||||||
if fetch_on_visible
|
|
||||||
data[:controller] = "#{data[:controller]} fetch-on-visible"
|
|
||||||
data[:fetch_on_visible_url_value] = url
|
|
||||||
end
|
|
||||||
|
|
||||||
link_to "Load more",
|
|
||||||
url,
|
|
||||||
id: "#{target}-load-page-#{page.next_param}",
|
|
||||||
data: { turbo_stream: true, **data },
|
|
||||||
class: "btn txt-small",
|
|
||||||
**options
|
|
||||||
end
|
|
||||||
|
|
||||||
def card_article_tag(card, id: dom_id(card, :article), **options, &block)
|
def card_article_tag(card, id: dom_id(card, :article), **options, &block)
|
||||||
classes = [
|
classes = [
|
||||||
options.delete(:class),
|
options.delete(:class),
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module PaginationHelper
|
||||||
|
def pagination_frame_tag(namespace, page, data: {}, **attributes, &)
|
||||||
|
turbo_frame_tag pagination_frame_id_for(namespace, page.number), data: { timeline_target: "frame", **data }, role: "presentation", **attributes, &
|
||||||
|
end
|
||||||
|
|
||||||
|
def link_to_next_page(namespace, page, activate_when_observed: false, label: "Load more...", data: {}, **attributes)
|
||||||
|
if page.before_last? && !params[:previous]
|
||||||
|
pagination_link(namespace, page.number + 1, label: label, activate_when_observed: activate_when_observed, data: data, class: "margin-block btn txt-small", url_params: { next: true }, **attributes)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def pagination_link(namespace, page_number, label: spinner_tag, activate_when_observed: false, url_params: {}, data: {}, **attributes)
|
||||||
|
link_to label, url_for(page: page_number, **url_params),
|
||||||
|
"aria-label": "Load page #{page_number}",
|
||||||
|
class: class_names(attributes.delete(:class), "pagination-link", { "pagination-link--active-when-observed" => activate_when_observed }),
|
||||||
|
data: {
|
||||||
|
frame: pagination_frame_id_for(namespace, page_number),
|
||||||
|
pagination_target: "paginationLink",
|
||||||
|
action: ("click->pagination#loadPage:prevent" unless activate_when_observed),
|
||||||
|
**data
|
||||||
|
},
|
||||||
|
**attributes
|
||||||
|
end
|
||||||
|
|
||||||
|
def pagination_frame_id_for(namespace, page_number)
|
||||||
|
"#{namespace}-pagination-contents-#{page_number}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_manual_pagination(name, page, **properties)
|
||||||
|
pagination_list name, **properties do
|
||||||
|
concat(pagination_frame_tag(name, page) do
|
||||||
|
yield
|
||||||
|
concat link_to_next_page(name, page)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def pagination_list(name, tag_element: :div, paginate_on_scroll: false, **properties, &block)
|
||||||
|
classes = properties.delete(:class)
|
||||||
|
tag.public_send tag_element,
|
||||||
|
class: token_list(name, "unpad", classes),
|
||||||
|
role: "list",
|
||||||
|
data: { controller: "pagination", pagination_paginate_on_intersection_value: paginate_on_scroll },
|
||||||
|
&block
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ export function onNextEventLoopTick(callback) {
|
|||||||
setTimeout(callback, 0)
|
setTimeout(callback, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function nextEvent(element, eventName) {
|
||||||
|
return new Promise(resolve => element.addEventListener(eventName, resolve, { once: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function nextFrame() {
|
export function nextFrame() {
|
||||||
return new Promise(requestAnimationFrame)
|
return new Promise(requestAnimationFrame)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
<%= turbo_frame_tag dom_id(@column, :cards) do %>
|
<%= turbo_frame_tag dom_id(@column, :cards) do %>
|
||||||
<% if @page.used? %>
|
<% if @page.used? %>
|
||||||
<%= render "collections/columns/list", cards: @page.records, draggable: true %>
|
<%= with_manual_pagination dom_id(@column, :cards), @page do %>
|
||||||
|
<%= render "collections/columns/list", cards: @page.records, draggable: true %>
|
||||||
<% unless @page.last? %>
|
|
||||||
<div class="full-width">
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="card blank-slate blank-slate--card">
|
<div class="card blank-slate blank-slate--card">
|
||||||
|
|||||||
Reference in New Issue
Block a user