Series: 11ty Theme
tl;dr: Nested pagination was definitely the most annoying thing about setting up this eleventy site, but I eventually got there.
Series Description: However many years later, I have actually done something resembling completing the design of this site using eleventy aka 11ty! I'm sure there are still more things I'll decide to change later, but the general idea I'm pretty happy with. This series describes some of the main challenges I faced. You can navigate other posts in the series using the list of links in the sidebar.
This was easily the most time-consuming of the challenges I faced in setting up this 11ty site. I realize I have absolutely no consistency in whether I write it as 11ty or as eleventy. Anyway, I tried to solve this particular issue about 5 times over the span of a couple of years. Even when I finally got it, it took several variations and some help from Google Gemini Pro.
Up to this point, I could generate each posts list, but some of those posts lists are getting generated using the eleventy pagination functionality, with it paginating to create each tag. That leaves a problem: how do I now have pagination for a lot of posts within each tag or year? That is shockingly hard to do with eleventy. Since there is only one layer of pagination possible out of the box from eleventy, I needed to figure out my own.
Create the Collections
I also covered this function in another post, but here's what is creating the array of data to handle pagination for years:
eleventyConfig.addCollection("postsByYear", collectionApi => {
return createPagedCollection(collectionApi, {
grouperFn: (post) => post.date.getFullYear(),
pageSize: 10,
keySort: 'desc',
permalink: (key, pageNumber) => {
if (pageNumber === 1) {
return `/${key}/`;
}
return `/${key}/page${pageNumber}/`;
}
});
});
The version for tags is essentially the same, so I'm not going to keep describing each version separately.
eleventyConfig.addCollection("postsByTag", function (collectionApi) {
const filterTagList = (tags) =>
(tags || []).filter((tag) => ["all", "posts"].indexOf(tag) === -1);
return createPagedCollection(collectionApi, {
grouperFn: (post) => filterTagList(post.data.tags),
pageSize: 10,
keySort: 'asc',
permalink: (key, pageNumber) => {
const keySlug = eleventyConfig.getFilter("slugify")(key);
if (pageNumber === 1) {
return `/tags/${keySlug}/`;
}
return `/tags/${keySlug}/page${pageNumber}/`;
}
});
});
This returns a paged collection, grouped by the year, where the page size is set at 10, sorting descending, and with a permalink for each page that is either simply the year on the first page or the year/page followed by the number on other pages. For tags, the permalink is the same except that it starts with /tags, which is also a page that shows all the tags.
Create the Pages
This function can then get utilized in the posts by year sidebar list, which I already covered and doesn't have pagination. It also gets used on the pages generated for each year, which is what required the more complicated setup. This is what the year-page.njk file for each site looks like:
---
pagination:
data: collections.postsByYear.pages
size: 1
alias: yearPage
permalink: /{{ yearPage.key }}/{% if yearPage.pageNumber > 0 %}page{{ yearPage.pageNumber + 1 }}/{% endif %}
eleventyComputed:
title: 'Posts from {{ yearPage.key }}{% if yearPage.pageNumber > 0 %} (Page {{ yearPage.pageNumber + 1 }}){% endif %}'
---
<p>Looking for all posts published in {{ yearPage.key }}? You've come to the right place.</p>
{% set postList = yearPage.posts %}
{% set pagination = yearPage %}
{% include "post-list-pager.njk" %}
The default eleventy functionality handles the top-level pagination, creating one page for each year. Then, it sets the postList value to yearPage.posts and the pagination value to yearPage (or tagPage), for the posts within that year which are also paginated thanks to that other custom collection.
The post-list.njk template from the shared theme generates the posts, while pager.njk generates the pagination links, after we have told it that it should use yearPage or tagPage (that's one pagination grouping of the year/tag) as its value for pagination.
{# Pagination navigation #}
{% set totalPages = pagination.pages | length %}
{% if pagination and totalPages > 1 %}
{%- css %}{% include "11ty-theme/css/pager.css" %}{% endcss %}
<script src="/js/pager.js"></script>
<nav aria-labelledby="posts-pagination-heading">
<h2 id="posts-pagination-heading" class="posts-pagination visually-hidden">Pagination Links to More Posts</h2>
<ul class="pager">
{%- set currentIndex = pagination.pageNumber %}
{%- if currentIndex > 0 %}
<li><a href="{{ pagination.href.first or pagination.hrefs[0] }}" class="icon-before pager-first">First</a></li>
<li><a href="{{ pagination.href.previous }}" class="icon-before pager-prev">Prev</a></li>
{% endif %}
{%- if totalPages <= 7 %}
{%- for i in range(0, totalPages) %}
<li>
<a href="{{ pagination.hrefs[i] }}"{% if currentIndex == i %} aria-current="page"{% endif %}>{{ i + 1 }}</a>
</li>
{%- endfor %}
{%- else %}
{%- set startIndex = currentIndex - 2 %}
{%- set endIndex = currentIndex + 2 %}
{%- for i in range(0, totalPages) %}
{%- if i >= startIndex and i <= endIndex %}
<li>
<a href="{{ pagination.hrefs[i] }}"{% if currentIndex == i %} aria-current="page"{% endif %}>{{ i + 1 }}</a>
</li>
{%- endif %}
{%- endfor %}
{%- endif %}
{%- if currentIndex < totalPages - 1 %}
<li><a href="{{ pagination.href.next }}" class="icon-after pager-next">Next</a></li>
<li><a href="{{ pagination.href.last or pagination.hrefs[pagination.hrefs.length - 1] }}" class="icon-after pager-last">Last ({{ totalPages }})</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
Dynamic Resizing
Finally, this is a different problem, but I wanted the pagination links to shrink on smaller screens, instead of wrapping around to a lower level. I did this with the pager.js file that gets included here.
/**
* @file
* Keeps pagers on one line and dynamically shows as many numeric
* neighbors around the current page as will fit (symmetrically).
*
* This is adapted from a Drupal script to work with a generic HTML pager.
*
* Behavior:
* - First and Last links are shown if they fit at the end.
* - Prev and Next links are always shown if they exist.
* - The current page number is always visible.
* - It grows page numbers symmetrically around the current page, stopping
* right before the items would wrap to a new line.
*
* Requirements:
* - A CSS rule like `.is-hidden { display: none; }` must be available.
* - The pager should be a `<ul>` with the page links as `<li>` children.
*/
(function () {
const HIDE_CLASS = "is-hidden";
// Selectors adapted for a simple Eleventy/Nunjucks pagination structure.
const SELECTORS = {
container: ".pager",
row: ".pager", // The <ul> itself is the row.
item: "li",
first: "li:has(a.pager-first)",
last: "li:has(a.pager-last)",
prev: "li:has(a.pager-prev)",
next: "li:has(a.pager-next)",
// Number items are those that are NOT first, last, prev, or next.
number: "li:not(:has(a.pager-first)):not(:has(a.pager-last)):not(:has(a.pager-prev)):not(:has(a.pager-next))",
current: 'li:has(a[aria-current="page"])',
};
// Utilities.
const qOne = (el, sel) => el.querySelector(sel);
const qAll = (el, sel) => Array.from(el.querySelectorAll(sel));
const addHide = (el) => el && el.classList.add(HIDE_CLASS);
const removeHide = (el) => el && el.classList.remove(HIDE_CLASS);
function getRow(pager) {
return qOne(pager, SELECTORS.row) || pager;
}
// If any visible item has a different offsetTop, it has wrapped.
function isWrapped(row) {
const items = qAll(row, `${SELECTORS.item}:not(.${HIDE_CLASS})`);
if (items.length <= 1) return false;
const top0 = items[0].offsetTop;
for (let i = 1; i < items.length; i++) {
if (items[i].offsetTop !== top0) {
return true;
}
}
return false;
}
function resetAll(pager) {
qAll(pager, `.${HIDE_CLASS}`).forEach(removeHide);
}
function getParts(pager) {
const row = getRow(pager);
const first = qOne(row, SELECTORS.first);
const last = qOne(row, SELECTORS.last);
const prev = qOne(row, SELECTORS.prev);
const next = qOne(row, SELECTORS.next);
const numbers = qAll(row, SELECTORS.number);
const currentEl = qOne(row, SELECTORS.current);
const currentIndex = numbers.indexOf(currentEl);
return { row, first, last, prev, next, numbers, currentEl, currentIndex };
}
// Grow outward from current as long as it doesn't wrap.
function growNumbers(parts) {
const { row, numbers, currentIndex } = parts;
if (!numbers.length || currentIndex < 0) return;
let L = currentIndex - 1;
let R = currentIndex + 1;
let safety = numbers.length * 2; // Loop safety break.
while (safety > 0) {
const left = L >= 0 ? numbers[L] : null;
const right = R < numbers.length ? numbers[R] : null;
if (!left && !right) break; // No more numbers to show.
// Try to add a pair of numbers.
if (left && right) {
removeHide(left);
removeHide(right);
if (isWrapped(row)) {
addHide(left);
addHide(right);
break; // Wrapped, so undo and stop.
}
L -= 1;
R += 1;
} else {
// Add a single number if no pair is available.
const one = left || right;
removeHide(one);
if (isWrapped(row)) {
addHide(one);
break; // Wrapped, so undo and stop.
}
if (one === left) L -= 1;
else R += 1;
}
safety -= 1;
}
}
// Add First/Last if they fit. Treat as a pair if both exist.
function tryAddEnds(parts) {
const { row, first, last } = parts;
if (first && last) {
// Hide them before measuring to test their addition.
addHide(first);
addHide(last);
removeHide(first);
removeHide(last);
if (isWrapped(row)) {
addHide(first);
addHide(last);
}
return;
}
// If only one exists, try adding it.
const single = first || last;
if (single) {
addHide(single);
removeHide(single);
if (isWrapped(row)) {
addHide(single);
}
}
}
function updatePager(pager) {
if (!pager || pager.__destroyed) return;
resetAll(pager);
const parts = getParts(pager);
// If there's no current item or no numbers, there's nothing to adjust.
if (!parts.currentEl || parts.numbers.length === 0) {
return;
}
// Start minimal: show Prev/Next and current, hide First/Last and all other numbers.
addHide(parts.first);
addHide(parts.last);
parts.numbers.forEach(n => addHide(n));
// Always keep Prev, Next and Current element visible
removeHide(parts.prev);
removeHide(parts.next);
removeHide(parts.currentEl);
// Symmetrically grow numbers outward from the current page.
growNumbers(parts);
// Finally, try to add the First/Last links if they fit.
tryAddEnds(parts);
}
function attachObservers(pager) {
// Debounce updates to one-per-frame for performance.
let rafId = 0;
const scheduleUpdate = () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => updatePager(pager));
};
const row = getRow(pager);
// Update when the pager's size changes.
const ro = new ResizeObserver(scheduleUpdate);
ro.observe(row);
pager.__pagerRO = ro;
// Update when the DOM inside the pager changes.
const mo = new MutationObserver(scheduleUpdate);
mo.observe(row, {
childList: true,
subtree: true,
attributes: false,
});
pager.__pagerMO = mo;
// Fonts loading can change element widths, so re-run after they're ready.
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(scheduleUpdate).catch(() => {});
}
// Initial run.
scheduleUpdate();
}
function initPagers() {
const pagers = document.querySelectorAll(SELECTORS.container);
pagers.forEach((pager) => {
// Prevent attaching observers multiple times.
if (pager.dataset.pagerAdjusted) {
return;
}
pager.dataset.pagerAdjusted = "true";
attachObservers(pager);
});
}
// Run the script after the DOM is fully loaded.
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initPagers);
} else {
initPagers();
}
})();
It's long, but really it's just cutting off links symmetrically until it fits in the size of the window it has to work with.
Previous: Sidebars