From cdd62c29b5fedfdb33f57ad465871c76574aac24 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 2 Jun 2026 12:34:34 +0200 Subject: [PATCH] feat(ui): use infinite scrolling on all sections (no need anymore to to click on "load more") --- static/js/app/filesView.js | 3 + static/js/utils/infiniteScroll.js | 60 +++++++++++++++++++ static/js/views/favorites/favoritesView.js | 3 + static/js/views/myShares/mySharesView.js | 3 + static/js/views/recent/recentView.js | 3 + .../js/views/sharedWithMe/sharedWithMeView.js | 3 + static/js/views/trash/trashView.js | 3 + 7 files changed, 78 insertions(+) create mode 100644 static/js/utils/infiniteScroll.js diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index 39cafa5f..d25d4d13 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -24,6 +24,7 @@ import { inlineViewer } from '../features/files/inlineViewer.js'; import { favorites } from '../features/library/favorites.js'; import { fetchResourcesPage, rebuildBreadCrumb } from '../model/filesModel.js'; import { grants } from '../model/grants.js'; +import { attachInfiniteScroll } from '../utils/infiniteScroll.js'; import { resolveHomeFolder } from './authSession.js'; import { updateHistory } from './main.js'; import { app } from './state.js'; @@ -289,6 +290,8 @@ function _ensureLoadMoreButton() { wrapper.appendChild(btn); filesContainer.after(wrapper); + + attachInfiniteScroll(wrapper, () => _loadPage({ isFirstPage: false })); } /** diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js new file mode 100644 index 00000000..9cdc6337 --- /dev/null +++ b/static/js/utils/infiniteScroll.js @@ -0,0 +1,60 @@ +// @ts-check + +/** + * Infinite-scroll for cursor-paginated views. + * + * Each section that paginates (Files, Favorites, Recent, Trash, + * SharedWithMe, MyShares) injects a "Load more" button wrapped in a + * `
` below `.files-container`. The wrapper toggles `.hidden` to + * reflect whether a next page exists. + * + * This utility wires an [`IntersectionObserver`] onto the same wrapper + * so it auto-fires the load-more action when the user scrolls near it. + * The visible button stays as a fallback (accessibility, browsers + * without IntersectionObserver support, the user's reflex from + * before infinite-scroll landed). + * + * Wire it once per wrapper at creation time. The observer is idempotent + * across re-renders because the wrapper itself is re-used; appended + * items push it down the document and trigger fresh intersection + * events when the user scrolls again. + * + * Re-entrancy: every existing `_loadPage` implementation has its own + * in-flight guard (a `_loading` flag returning early on re-entry), so + * this utility doesn't add another. If a future view doesn't, add a + * `_loading` field on the view object before adopting infinite-scroll. + */ + +/** + * Auto-fire `onLoadMore()` when `wrapper` scrolls within + * `rootMargin` of the viewport AND is visible (the `.hidden` class is + * the cursor-exhausted signal — see `_setLoadMoreVisible` in each view). + * + * @param {HTMLElement} wrapper The `.swm-load-more-wrapper` element. + * @param {() => void} onLoadMore Called on each intersection-enter. + * @param {{rootMargin?: string}} [opts] + * @returns {() => void} Teardown — disconnects the observer. + */ +export function attachInfiniteScroll(wrapper, onLoadMore, opts = {}) { + if (!('IntersectionObserver' in window)) { + // Older browser — degrade to the button-only experience. + return () => {}; + } + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + // No next page → wrapper hidden → skip. + if (wrapper.classList.contains('hidden')) continue; + onLoadMore(); + } + }, + // Trigger a bit before the wrapper actually enters the viewport + // so the next page is on its way by the time the user reaches + // the bottom — smoother than a perceptible "click… wait…" beat. + { rootMargin: opts.rootMargin ?? '200px' } + ); + observer.observe(wrapper); + return () => observer.disconnect(); +} diff --git a/static/js/views/favorites/favoritesView.js b/static/js/views/favorites/favoritesView.js index a9bee282..79d82b0a 100644 --- a/static/js/views/favorites/favoritesView.js +++ b/static/js/views/favorites/favoritesView.js @@ -27,6 +27,7 @@ import * as itemTooltip from '../../features/itemTooltip.js'; import { favorites } from '../../features/library/favorites.js'; import { fetchFavoritesPage } from '../../model/favoritesModel.js'; import { systemUsers } from '../../model/systemUsers.js'; +import { attachInfiniteScroll } from '../../utils/infiniteScroll.js'; /** @import {FavoritesResourceItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */ @@ -429,6 +430,8 @@ const favoritesView = { wrapper.appendChild(btn); filesContainer.after(wrapper); + + attachInfiniteScroll(wrapper, () => this._loadPage()); }, /** diff --git a/static/js/views/myShares/mySharesView.js b/static/js/views/myShares/mySharesView.js index 37089c15..7bd1f0de 100644 --- a/static/js/views/myShares/mySharesView.js +++ b/static/js/views/myShares/mySharesView.js @@ -19,6 +19,7 @@ import * as viewPrefs from '../../core/viewPrefs.js'; import * as itemTooltip from '../../features/itemTooltip.js'; import { grants } from '../../model/grants.js'; import { groups } from '../../model/groups.js'; +import { attachInfiniteScroll } from '../../utils/infiniteScroll.js'; /** @import {FileItem, FolderItem} from '../../core/types.js' */ @@ -256,6 +257,8 @@ const mySharesView = { wrapper.appendChild(btn); filesContainer.after(wrapper); + + attachInfiniteScroll(wrapper, () => this._loadPage()); }, /** @param {boolean} visible */ diff --git a/static/js/views/recent/recentView.js b/static/js/views/recent/recentView.js index b68a8ca2..44fa0446 100644 --- a/static/js/views/recent/recentView.js +++ b/static/js/views/recent/recentView.js @@ -28,6 +28,7 @@ import * as itemTooltip from '../../features/itemTooltip.js'; import { favorites } from '../../features/library/favorites.js'; import { fetchRecentPage } from '../../model/recentModel.js'; import { systemUsers } from '../../model/systemUsers.js'; +import { attachInfiniteScroll } from '../../utils/infiniteScroll.js'; /** @import {FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */ @@ -436,6 +437,8 @@ const recentView = { wrapper.appendChild(btn); filesContainer.after(wrapper); + + attachInfiniteScroll(wrapper, () => this._loadPage()); }, /** diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index 8fd2d030..6bdacdcc 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -21,6 +21,7 @@ import * as itemTooltip from '../../features/itemTooltip.js'; import { favorites } from '../../features/library/favorites.js'; import { grants } from '../../model/grants.js'; import { systemUsers } from '../../model/systemUsers.js'; +import { attachInfiniteScroll } from '../../utils/infiniteScroll.js'; /** @import {SharedWithMeItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */ @@ -438,6 +439,8 @@ const sharedWithMeView = { wrapper.appendChild(btn); filesContainer.after(wrapper); + + attachInfiniteScroll(wrapper, () => this._loadPage()); }, /** diff --git a/static/js/views/trash/trashView.js b/static/js/views/trash/trashView.js index a4b0f79a..476843b3 100644 --- a/static/js/views/trash/trashView.js +++ b/static/js/views/trash/trashView.js @@ -24,6 +24,7 @@ import * as viewPrefs from '../../core/viewPrefs.js'; import { fileOps } from '../../features/files/fileOperations.js'; import * as itemTooltip from '../../features/itemTooltip.js'; import { fetchTrashPage } from '../../model/trashModel.js'; +import { attachInfiniteScroll } from '../../utils/infiniteScroll.js'; /** @import {FileItem, FolderItem, ResourceTypeEnum, TrashResourceItem} from '../../core/types.js' */ @@ -449,6 +450,8 @@ const trashView = { wrapper.appendChild(btn); filesContainer.after(wrapper); + + attachInfiniteScroll(wrapper, () => this._loadPage()); }, /**