Skip to content

📚 Класс pbPagination: контроллер AJAX-пагинации, фильтрации и сортировки

📝 Описание

pbPagination — это универсальный JavaScript-класс для управления динамическими коллекциями контента на веб-страницах.

Он реализует: ✅ Пагинацию (обычную, «Показать ещё», бесконечную прокрутку) ✅ Многофакторную фильтрацию (селекты, чекбоксы, диапазоны) ✅ Сортировку списков/таблиц с сохранением активного состояния ✅ Обновление URL без полной перезагрузки страницы

Идеально подходит для каталогов товаров, лент новостей, блогов или любых списков с AJAX-подгрузкой.

🧱 Разметка, на которую он опирается

Класс ничего не создаёт сам — он находит готовые элементы по атрибутам. Обязателен только контейнер списка; остальное подключается по мере надобности.

СелекторРоль
#pb-itemsКонтейнер элементов списка. Меняется опцией targetItems. Без него не инициализируется ничего.
[pb-pagination]Контейнер ссылок на страницы. Его содержимое сервер перерисовывает целиком.
[data-page]Кликабельная ссылка/кнопка внутри [pb-pagination]; значение — номер страницы.
[pb-loadmore]Кнопка «Показать ещё».
[pb-total]Элемент, куда пишется общее количество (с разделением разрядов пробелом).
[pb-sort]Контейнер сортировки: select или радиокнопки с именем из sortName.
[pb-filter]Форма фильтров.
[pb-filter-change]Атрибут на форме фильтров: применять фильтр сразу при изменении поля.
.pb-scroll-triggerМаячок для бесконечной прокрутки. Создаётся сам, если его нет.

Обработчики на [pb-pagination] делегированные, поэтому перерисовка ссылок их не ломает. А вот [pb-loadmore], [pb-sort] и [pb-filter] подписываются напрямую при инициализации: эти элементы должны быть в DOM на момент создания объекта.

⚙️ Инициализация со сниппетом pbList

Штатный сценарий — вывести список через pbList и отдать классу три числа из data-атрибутов контейнера. Сниппет кладёт в #pb-items ключ конфигурации data-pb-key, и его надо вернуть на сервер в параметрах запроса:

html
<script>
    document.addEventListener('DOMContentLoaded', () => {
        const pbList = document.querySelector('#pb-items');
        window.pbPagination = new pbPagination({
            total: +(pbList?.dataset.pbTotal ?? 0),
            last_page: +(pbList?.dataset.pbLastPage ?? 1),
            queryParams: pbList?.dataset.pbKey ? { pb_key: pbList.dataset.pbKey } : {}
        });
    })
</script>

pb_key — непрозрачный ключ, под которым сервер сохранил условия выборки. Клиент не видит и не может подделать сами условия: он присылает ключ, а PbList::json() собирает по нему тот же запрос для нужной страницы. Потерялся ключ — сервер вернёт пустой ответ.

⚙️ Ручная инициализация

  1. Подключить необходимые скрипты:
html
<script src="/assets/components/pageblocks/js/web/pb.message.v300.js"></script>
<script src="/assets/components/pageblocks/js/web/pb.fetch.v300.js"></script>
<script src="/assets/components/pageblocks/js/web/pb.pagination.v300.js"></script>

pbFetch обязателен — все запросы идут через него.

  1. Инициализировать экземпляр вручную:
html
<script>
 document.addEventListener('DOMContentLoaded', () => {
   window.pbPagination = new pbPagination({
     page: 1,
     targetItems: '#blog-items',
     infiniteScroll: true,
     updateUrl: true
   });
 });
</script>

Количество элементов на страницу задаёт сервер

На клиенте его нет: pbPagination присылает номер страницы, а не размер. Лимит живёт в параметре limit сниппета pbList (или в вашем контроллере).

🗂 Опции конструктора

Данные списка

ОпцияПо умолчаниюОписание
urllocation.pathnameАдрес, на который уходит AJAX-запрос. По умолчанию — текущая страница.
page1Текущая страница. Перебивается параметром из URL, если он есть.
total0Всего элементов. Уходит на сервер и обновляется из ответа.
last_page1Последняя страница. От неё зависят «Показать ещё» и бесконечная прокрутка.
targetItems'#pb-items'Селектор контейнера элементов.
queryParams''Объект дополнительных параметров запроса (сюда кладут pb_key).

Сортировка и фильтры

ОпцияПо умолчаниюОписание
sortby'menuindex'Поле сортировки.
sortdir'asc'Направление.
sortName'sort'Имя параметра сортировки в URL и атрибута name у контролов сортировки.
sortValue''Текущее «слитное» значение вида price-asc.
sortNames''Псевдонимы: 'cheap==price-asc,new==createdon-desc'. Позволяют держать в URL человекочитаемое ?sort=cheap.
filters{}Стартовый набор фильтров. Дополняется значениями из текущего URL.
filterSeparator';'Разделитель фильтров в ЧПУ-варианте ссылки.

Пагинация и подгрузка

ОпцияПо умолчаниюОписание
infiniteScroll0Включает бесконечную прокрутку.
infiniteOffsetScroll200За сколько пикселей до маячка начинать подгрузку.
scrollToToptrueПрокручивать к началу списка при переходе на другую страницу.
scrollOffset0Отступ сверху при такой прокрутке — под липкую шапку.

URL

ОпцияПо умолчаниюОписание
updateUrltrueОбновлять адресную строку через history.pushState.
pageName'page'Имя параметра страницы.
pageLink'?{pageName}={page}'Шаблон ссылки страницы.
sortLink'?{sortName}={sort}'Шаблон ссылки сортировки.
filterLink'?{name}={value}'Шаблон ссылки фильтра.
fragment''Якорь, который дописывается к адресу запроса.
baseUrlnullБазовый адрес вместо window.location.href.

Опции offset, perPage и filterFields в конфиге присутствуют, но кодом не используются — это наследие v2, полагаться на них не надо.

🚦 Чтение состояния из URL

При создании объект разбирает текущий адрес и подстраивается под него:

  • параметр сортировки (?sort=price-asc) распадается на sortby и sortdirтолько если в значении есть дефис;
  • параметр страницы (?page=3) становится текущей страницей;
  • все прочие параметры, кроме pageName и sortName, попадают в filters и подсвечиваются в форме фильтров — но только если форма [pb-filter] на странице есть. Без неё параметры адреса фильтрами не становятся.

Поэтому страница, открытая по прямой ссылке или по кнопке «Назад», показывает те же выбранные значения, что и до перезагрузки.

🧩 Публичные методы

1️⃣ loadPage(pageNumber, type = 'page', swap = 'innerHTML')

Загружает конкретную страницу с обновлением DOM.

  • type — что писать в адресную строку: page, sort или filter. От него зависит, какой шаблон ссылки применяется.
  • swap — как вставить полученную разметку: innerHTML (по умолчанию), beforeend, afterbegin, beforebegin, afterend, outerHTML.
js
pbPagination.loadPage(2);                    // обычный переход
pbPagination.loadPage(2, 'page', 'beforeend'); // дописать в конец списка

Метод не возвращает промис: он запускает запрос и завершается. Всё, что делается после ответа, живёт в колбэке success.

2️⃣ next(swap = 'innerHTML')

Загружает следующую страницу. На последней странице ничего не делает.

js
pbPagination.next('beforeend');

3️⃣ prev()

Загружает предыдущую страницу, не опускаясь ниже первой.

js
pbPagination.prev();

4️⃣ sort(sortby, sortdir = '')

Применяет сортировку и возвращает список на первую страницу.

js
pbPagination.sort('price', 'asc');
pbPagination.sort('cheap');   // псевдоним из sortNames

Если sortdir не asc и не desc, значение первого аргумента ищется среди sortNames. Не нашлось — направление становится asc.

5️⃣ filter(name = '', value = '')

Применяет фильтры и возвращает список на первую страницу.

js
// Один фильтр
pbPagination.filter('category', 'books');

// Несколько значений одного фильтра
pbPagination.filter('brand', ['bosch', 'makita']);   // уйдёт как bosch,makita

// Диапазон
pbPagination.filter('price', '100-500');

// Применить то, что уже накоплено в config.filters
pbPagination.filter();

Пустые значения из набора выкидываются — так фильтр снимается.

🔍 Фильтры

Форму достаточно пометить атрибутом pb-filter, дальше всё делают штатные события формы: submit применяет фильтры, reset сбрасывает их и перезагружает список.

html
<form pb-filter>
  <select name="category">
    <option value="">Все</option>
    <option value="books">Книги</option>
  </select>

  <label><input type="checkbox" name="brand[]" value="bosch"> Bosch</label>
  <label><input type="checkbox" name="brand[]" value="makita"> Makita</label>

  <input type="number" name="price[min]" placeholder="от">
  <input type="number" name="price[max]" placeholder="до">

  <button type="submit">Показать</button>
  <button type="reset">Сбросить</button>
</form>
  • Чекбоксы. Имя пишется с []; при сборке скобки отбрасываются, а отмеченные значения склеиваются через запятую: brand=bosch,makita.
  • Диапазоны. Пара полей [min] и [max] схлопывается в одно значение price=100-500. Если заполнено только «от», «до» приравнивается к нему.
  • Мгновенное применение. Атрибут pb-filter-change на форме заставляет применять фильтр по каждому изменению поля, без кнопки:
html
<form pb-filter pb-filter-change> … </form>

Форма отправляется вместе с AJAX-запросом, поэтому серверу доступны и те поля, которые класс не разбирает отдельно.

↕️ Сортировка

html
<div pb-sort>
  <select name="sort">
    <option value="menuindex-asc">По порядку</option>
    <option value="price-asc">Сначала дешёвые</option>
    <option value="createdon-desc">Сначала новые</option>
  </select>
</div>

Значение — слитная пара поле-направление; при изменении оно распадается на sortby и sortdir. Работают и радиокнопки с тем же name. Активный пункт при загрузке страницы проставляется сам, по адресу.

С псевдонимами sortNames в URL остаётся короткое имя:

js
new pbPagination({
  sortNames: 'cheap==price-asc,expensive==price-desc,new==createdon-desc'
});
html
<option value="cheap">Сначала дешёвые</option>

➡ Адрес станет ?sort=cheap, а на сервер уйдут sortby=price и sortdir=asc.

♾️ Три режима подгрузки

Обычная пагинация

Ничего включать не нужно. Клик по [data-page] внутри [pb-pagination] грузит страницу и заменяет содержимое списка.

«Показать ещё»

html
<button type="button" pb-loadmore>Показать ещё</button>

Кнопка вызывает next('beforeend') — новые элементы дописываются в конец. На время запроса ей вешается класс loader. Когда страниц больше нет, кнопка получает атрибут pb-hide и display: none.

Бесконечная прокрутка

js
new pbPagination({ infiniteScroll: 1, infiniteOffsetScroll: 300 });

Внутрь контейнера добавляется невидимый маячок .pb-scroll-trigger, за ним следит IntersectionObserver. Когда маячок подходит на infiniteOffsetScroll пикселей, грузится следующая страница. На последней странице загрузка не запускается, а между подгрузками стоит блокировка в 200 мс, чтобы одно пересечение не вызвало два запроса.

После каждой вставки маячок переносится в конец списка — класс слушает для этого событие pb:after от pbFetch и сверяет detail.target со своим targetItems.

⬆️ Прокрутка к началу списка

При обычном переходе по страницам список меняется целиком, и человек остаётся стоять там, где нажал кнопку, — то есть в самом низу, где у нового списка уже конец. Поэтому после вставки страница подтягивается к началу списка.

js
new pbPagination({
  scrollToTop: true,   // по умолчанию включено
  scrollOffset: 80     // высота липкой шапки
});

Тонкости:

  • прокрутка идёт только вверх — если начало списка и так видно, ничего не двигается;
  • для таблицы целью становится сам <table>, а не <tbody>, иначе шапка уезжает под верх экрана;
  • при beforeend (то есть при «Показать ещё» и бесконечной прокрутке) прокрутки нет — там место чтения не сбивается.

Рабочий пример с вычислением отступа под липкое меню:

js
const stickyBar = document.querySelector('.submenu');
const scrollOffset = stickyBar && getComputedStyle(stickyBar).position === 'sticky'
    ? stickyBar.offsetHeight + 16
    : 0;

new pbPagination({ scrollOffset });

🔗 Запрос и ответ

Запрос уходит через pbFetch.get() с expect: 'json' на адрес url со сформированной query-строкой. В неё попадают: текущие параметры адреса, queryParams, номер страницы, total, все фильтры, а также sortby и sortdir — последние только если в адресе нет готового параметра sortName. Пустые значения выбрасываются.

Ответ ожидается такой:

json
{
  "success": true,
  "data": "<article>…</article>",
  "links": "<ul class='pagination'>…</ul>",
  "total": 137,
  "current_page": 2,
  "last_page": 14
}
ПолеКуда идёт
dataВставляется в targetItems выбранным способом.
linksЗаменяет содержимое всех [pb-pagination].
totalПишется во все [pb-total] с разбивкой разрядов: 1 234 567.
last_pageУправляет кнопкой «Показать ещё» и бесконечной прокруткой.
current_pageНе используется клиентом — номер страницы он и так знает.

Ошибка запроса пишется в консоль как pbPagination: loadPage failed; разметка при этом остаётся прежней.

🧭 Адресная строка

При updateUrl: true после каждой загрузки адрес переписывается через history.pushState по шаблону, который соответствует типу действия (pageLink, sortLink, filterLink).

Плейсхолдеры шаблонов:

ШаблонДоступные подстановки
pageLink{pageName}, {page}
sortLink{sortName}, {sort}, {sortby}, {sortdir}
filterLink{name}, {value}

Правила, которые применяются поверх шаблонов:

  • первая страница из адреса убирается — ?page=1 не появляется;
  • при смене сортировки и фильтров номер страницы сбрасывается;
  • параметр сортировки всегда переносится в конец query-строки, чтобы адреса были предсказуемыми;
  • фильтр с пустым значением удаляется из адреса.

Шаблон может быть и путевым, а не query-строкой:

js
new pbPagination({
  pageLink: '/page/{page}',
  sortLink: '/sort/{sort}',
  filterLink: '/{name}-{value}'
});

➡ Страница и сортировка дописываются к текущему пути: /catalog/page/3, /catalog/sort/price-asc. Старые сегменты страницы и сортировки при этом вырезаются, а двойные слэши схлопываются.

Путевой filterLink заменяет весь путь

Фильтры в этом режиме собираются в адрес /filter/brand-bosch;price-100-500 — от корня, а не от текущего раздела: /catalog из пути пропадёт. Годится, когда под фильтр выделен отдельный маршрут; для фильтров внутри раздела оставляйте filterLink в виде query-строки.

Кнопка «Назад» состояние не восстанавливает

pushState пишет адрес, но обработчика popstate в классе нет. Возврат назад меняет строку адреса, а список остаётся прежним — до перезагрузки страницы. Нужна честная навигация по истории — вешайте popstate сами и зовите loadPage().

✅ Ключевые преимущества

🚀 Без перезагрузки страницы 🔍 SEO-дружественные URL ⚙️ Гибкая настройка 🔧 Легко расширяется

🏁 Заключение

pbPagination — это готовое решение для динамического управления контентом с чистой интеграцией и набором удобных методов управления.

© PageBlocks 2019-present