📚 Класс 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, и его надо вернуть на сервер в параметрах запроса:
<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() собирает по нему тот же запрос для нужной страницы. Потерялся ключ — сервер вернёт пустой ответ.
⚙️ Ручная инициализация
- Подключить необходимые скрипты:
<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 обязателен — все запросы идут через него.
- Инициализировать экземпляр вручную:
<script>
document.addEventListener('DOMContentLoaded', () => {
window.pbPagination = new pbPagination({
page: 1,
targetItems: '#blog-items',
infiniteScroll: true,
updateUrl: true
});
});
</script>Количество элементов на страницу задаёт сервер
На клиенте его нет: pbPagination присылает номер страницы, а не размер. Лимит живёт в параметре limit сниппета pbList (или в вашем контроллере).
🗂 Опции конструктора
Данные списка
| Опция | По умолчанию | Описание |
|---|---|---|
url | location.pathname | Адрес, на который уходит AJAX-запрос. По умолчанию — текущая страница. |
page | 1 | Текущая страница. Перебивается параметром из URL, если он есть. |
total | 0 | Всего элементов. Уходит на сервер и обновляется из ответа. |
last_page | 1 | Последняя страница. От неё зависят «Показать ещё» и бесконечная прокрутка. |
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 | ';' | Разделитель фильтров в ЧПУ-варианте ссылки. |
Пагинация и подгрузка
| Опция | По умолчанию | Описание |
|---|---|---|
infiniteScroll | 0 | Включает бесконечную прокрутку. |
infiniteOffsetScroll | 200 | За сколько пикселей до маячка начинать подгрузку. |
scrollToTop | true | Прокручивать к началу списка при переходе на другую страницу. |
scrollOffset | 0 | Отступ сверху при такой прокрутке — под липкую шапку. |
URL
| Опция | По умолчанию | Описание |
|---|---|---|
updateUrl | true | Обновлять адресную строку через history.pushState. |
pageName | 'page' | Имя параметра страницы. |
pageLink | '?{pageName}={page}' | Шаблон ссылки страницы. |
sortLink | '?{sortName}={sort}' | Шаблон ссылки сортировки. |
filterLink | '?{name}={value}' | Шаблон ссылки фильтра. |
fragment | '' | Якорь, который дописывается к адресу запроса. |
baseUrl | null | Базовый адрес вместо 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.
pbPagination.loadPage(2); // обычный переход
pbPagination.loadPage(2, 'page', 'beforeend'); // дописать в конец спискаМетод не возвращает промис: он запускает запрос и завершается. Всё, что делается после ответа, живёт в колбэке success.
2️⃣ next(swap = 'innerHTML')
Загружает следующую страницу. На последней странице ничего не делает.
pbPagination.next('beforeend');3️⃣ prev()
Загружает предыдущую страницу, не опускаясь ниже первой.
pbPagination.prev();4️⃣ sort(sortby, sortdir = '')
Применяет сортировку и возвращает список на первую страницу.
pbPagination.sort('price', 'asc');
pbPagination.sort('cheap'); // псевдоним из sortNamesЕсли sortdir не asc и не desc, значение первого аргумента ищется среди sortNames. Не нашлось — направление становится asc.
5️⃣ filter(name = '', value = '')
Применяет фильтры и возвращает список на первую страницу.
// Один фильтр
pbPagination.filter('category', 'books');
// Несколько значений одного фильтра
pbPagination.filter('brand', ['bosch', 'makita']); // уйдёт как bosch,makita
// Диапазон
pbPagination.filter('price', '100-500');
// Применить то, что уже накоплено в config.filters
pbPagination.filter();Пустые значения из набора выкидываются — так фильтр снимается.
🔍 Фильтры
Форму достаточно пометить атрибутом pb-filter, дальше всё делают штатные события формы: submit применяет фильтры, reset сбрасывает их и перезагружает список.
<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на форме заставляет применять фильтр по каждому изменению поля, без кнопки:
<form pb-filter pb-filter-change> … </form>Форма отправляется вместе с AJAX-запросом, поэтому серверу доступны и те поля, которые класс не разбирает отдельно.
↕️ Сортировка
<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 остаётся короткое имя:
new pbPagination({
sortNames: 'cheap==price-asc,expensive==price-desc,new==createdon-desc'
});<option value="cheap">Сначала дешёвые</option>➡ Адрес станет ?sort=cheap, а на сервер уйдут sortby=price и sortdir=asc.
♾️ Три режима подгрузки
Обычная пагинация
Ничего включать не нужно. Клик по [data-page] внутри [pb-pagination] грузит страницу и заменяет содержимое списка.
«Показать ещё»
<button type="button" pb-loadmore>Показать ещё</button>Кнопка вызывает next('beforeend') — новые элементы дописываются в конец. На время запроса ей вешается класс loader. Когда страниц больше нет, кнопка получает атрибут pb-hide и display: none.
Бесконечная прокрутка
new pbPagination({ infiniteScroll: 1, infiniteOffsetScroll: 300 });Внутрь контейнера добавляется невидимый маячок .pb-scroll-trigger, за ним следит IntersectionObserver. Когда маячок подходит на infiniteOffsetScroll пикселей, грузится следующая страница. На последней странице загрузка не запускается, а между подгрузками стоит блокировка в 200 мс, чтобы одно пересечение не вызвало два запроса.
После каждой вставки маячок переносится в конец списка — класс слушает для этого событие pb:after от pbFetch и сверяет detail.target со своим targetItems.
⬆️ Прокрутка к началу списка
При обычном переходе по страницам список меняется целиком, и человек остаётся стоять там, где нажал кнопку, — то есть в самом низу, где у нового списка уже конец. Поэтому после вставки страница подтягивается к началу списка.
new pbPagination({
scrollToTop: true, // по умолчанию включено
scrollOffset: 80 // высота липкой шапки
});Тонкости:
- прокрутка идёт только вверх — если начало списка и так видно, ничего не двигается;
- для таблицы целью становится сам
<table>, а не<tbody>, иначе шапка уезжает под верх экрана; - при
beforeend(то есть при «Показать ещё» и бесконечной прокрутке) прокрутки нет — там место чтения не сбивается.
Рабочий пример с вычислением отступа под липкое меню:
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. Пустые значения выбрасываются.
Ответ ожидается такой:
{
"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-строкой:
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 — это готовое решение для динамического управления контентом с чистой интеграцией и набором удобных методов управления.