From 72fb15dd0f913e698b52ea928d960c06478b09fe Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 28 Sep 2025 14:21:16 +0200 Subject: [PATCH] Pagination working for columns Bring pagination system from KIA that is compatible with page refreshes --- app/assets/stylesheets/card-columns.css | 1 + app/helpers/cards_helper.rb | 32 ----- app/helpers/pagination_helper.rb | 47 +++++++ .../controllers/pagination_controller.js | 124 ++++++++++++++++++ app/javascript/helpers/html_helpers.js | 9 ++ app/javascript/helpers/scroll_helpers.js | 71 ++++++++++ app/javascript/helpers/timing_helpers.js | 5 + app/views/collections/columns/show.html.erb | 7 +- 8 files changed, 259 insertions(+), 37 deletions(-) create mode 100644 app/helpers/pagination_helper.rb create mode 100644 app/javascript/controllers/pagination_controller.js create mode 100644 app/javascript/helpers/html_helpers.js create mode 100644 app/javascript/helpers/scroll_helpers.js diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 36d4748a9..846008bfe 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -80,6 +80,7 @@ min-inline-size: 0; .blank-slate, + .pagination-link, .card { display: none; } diff --git a/app/helpers/cards_helper.rb b/app/helpers/cards_helper.rb index a44570dfe..dac427eb8 100644 --- a/app/helpers/cards_helper.rb +++ b/app/helpers/cards_helper.rb @@ -1,36 +1,4 @@ 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) classes = [ options.delete(:class), diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb new file mode 100644 index 000000000..3e6edaf3f --- /dev/null +++ b/app/helpers/pagination_helper.rb @@ -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 diff --git a/app/javascript/controllers/pagination_controller.js b/app/javascript/controllers/pagination_controller.js new file mode 100644 index 000000000..01d93d909 --- /dev/null +++ b/app/javascript/controllers/pagination_controller.js @@ -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) + } + } +} diff --git a/app/javascript/helpers/html_helpers.js b/app/javascript/helpers/html_helpers.js new file mode 100644 index 000000000..609594b88 --- /dev/null +++ b/app/javascript/helpers/html_helpers.js @@ -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 +} diff --git a/app/javascript/helpers/scroll_helpers.js b/app/javascript/helpers/scroll_helpers.js new file mode 100644 index 000000000..603b1e3f2 --- /dev/null +++ b/app/javascript/helpers/scroll_helpers.js @@ -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 +} diff --git a/app/javascript/helpers/timing_helpers.js b/app/javascript/helpers/timing_helpers.js index f83e6719c..7a9701a53 100644 --- a/app/javascript/helpers/timing_helpers.js +++ b/app/javascript/helpers/timing_helpers.js @@ -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) } diff --git a/app/views/collections/columns/show.html.erb b/app/views/collections/columns/show.html.erb index 4545fa2d6..49724acc3 100644 --- a/app/views/collections/columns/show.html.erb +++ b/app/views/collections/columns/show.html.erb @@ -1,10 +1,7 @@ <%= turbo_frame_tag dom_id(@column, :cards) do %> <% if @page.used? %> - <%= render "collections/columns/list", cards: @page.records, draggable: true %> - - <% unless @page.last? %> -
-
+ <%= with_manual_pagination dom_id(@column, :cards), @page do %> + <%= render "collections/columns/list", cards: @page.records, draggable: true %> <% end %> <% else %>