feat(freelancer-directory): nine cards per page with a pager

Cap the directory at 9 cards per page (3 x 3 at desktop) and add pagination.

- The grid rule is untouched (auto-fill minmax(300px,1fr), max-width 1000px):
  measured 3 columns at 1280, 1 at 375. The cap does not change column behaviour.
- A <nav id="dirPager"> ships `hidden` and is un-hidden by script ONLY when the
  filtered list needs more than one page — so with 7 records no pager appears, the
  same rule the facets follow.
- Previous/Next plus one button per page; the current page carries aria-current,
  a filled pill and the live "Page N of M" line — never colour alone. Focus is kept
  on the pager across a rebuild rather than dropped to the document.
- A filter or search resets to page 1; a page left out of range falls back to 1
  instead of rendering an empty page.
- Pagination applies to the filtered set, not the raw list.
- No new URLs: the pager holds no href and touches no history, so there is nothing
  to deep-link into a broken state.
- With JavaScript off the pager never appears and every build-time card shows; the
  #directory anchor is unaffected.
This commit is contained in:
webmaster committed 2026-11-03 17:13:47 +00:00
1 parent 53e9ea999d
commit f0a2c8d745
1 file changed
+152 -7
+152 -7
View File
@@ -324,6 +324,64 @@
outline-offset: 2px; outline-offset: 2px;
} }
/* Pagination — nine cards per page. Script-dependent BY DESIGN: with JavaScript
off the element keeps its `hidden` attribute and every build-time card shows.
`[hidden]` needs its own rule because this class sets `display`. */
.dir-pager {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
max-width: 1000px;
margin: 1.75rem auto 0;
}
.dir-pager[hidden] {
display: none;
}
.dir-pager-pages {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.dir-page {
font: inherit;
font-size: 0.9rem;
min-width: 2.4rem;
padding: 0.4rem 0.7rem;
color: #fff;
background: transparent;
border: 1px solid var(--line-brass);
border-radius: var(--radius);
cursor: pointer;
}
.dir-page:hover:not([disabled]) {
border-color: var(--brass-bright);
}
.dir-page:focus-visible {
outline: 3px solid var(--brass-bright);
outline-offset: 2px;
}
/* The current page is carried three ways — a filled pill, `aria-current`, and the
"Page N of M" line — so it is never signalled by colour alone. */
.dir-page[aria-current="page"] {
background: var(--brass);
border-color: var(--brass-deep);
color: var(--midnight);
font-weight: 600;
}
.dir-page[disabled] {
opacity: 0.45;
cursor: default;
}
.dir-pager-status {
flex-basis: 100%;
margin: 0;
text-align: center;
font-size: 0.82rem;
color: rgba(255, 255, 255, 0.72);
}
/* The grid of freelancer cards */ /* The grid of freelancer cards */
.dir-grid { .dir-grid {
display: grid; display: grid;
@@ -1216,6 +1274,18 @@
</article> </article>
<!-- DIRECTORY-LIST-END --> <!-- DIRECTORY-LIST-END -->
</div> </div>
<!-- Pagination — nine cards per page. Ships `hidden`: it is un-hidden by script
only when the filtered list needs more than one page, so with JavaScript off
it never appears and every build-time card stays visible. No page → no URL:
it holds no href and touches no history, so there is nothing to deep-link. -->
<nav class="dir-pager" id="dirPager" hidden aria-label="Listings pages">
<button type="button" class="dir-page" id="dirPrev" data-page="prev">Previous</button>
<span class="dir-pager-pages" id="dirPages"></span>
<button type="button" class="dir-page" id="dirNext" data-page="next">Next</button>
<p class="dir-pager-status" id="dirPagerStatus" role="status" aria-live="polite"></p>
</nav>
<p class="dir-empty" id="dirEmpty" hidden>Nothing matches. Widen your search, or <button type="button" class="dir-empty-reset" id="dirEmptyReset">set the filters back to All</button>.</p> <p class="dir-empty" id="dirEmpty" hidden>Nothing matches. Widen your search, or <button type="button" class="dir-empty-reset" id="dirEmptyReset">set the filters back to All</button>.</p>
<div class="dir-note" id="listing-note"> <div class="dir-note" id="listing-note">
@@ -2545,6 +2615,7 @@
records.forEach(function (rec) { grid.appendChild(liveCard(rec, active)); }); records.forEach(function (rec) { grid.appendChild(liveCard(rec, active)); });
// The button is added after the cards exist — only while the route is set. // The button is added after the cards exist — only while the route is set.
Array.prototype.forEach.call(grid.querySelectorAll(".dev-card"), enhanceAsk); Array.prototype.forEach.call(grid.querySelectorAll(".dev-card"), enhanceAsk);
dirPage = 1;
applyFilters(); applyFilters();
}) })
.catch(function () { /* the build-time list already on the page stands */ }); .catch(function () { /* the build-time list already on the page stands */ });
@@ -2554,11 +2625,13 @@
function initFilters() { function initFilters() {
var bar = document.getElementById("dirFilters"); var bar = document.getElementById("dirFilters");
if (!bar) return; if (!bar) return;
// A new filter or query starts at page 1 — staying on page 3 after narrowing to
// two matches is the classic pagination bug.
bar.addEventListener("change", function (event) { bar.addEventListener("change", function (event) {
if (event.target && event.target.tagName === "SELECT") applyFilters(); if (event.target && event.target.tagName === "SELECT") { dirPage = 1; applyFilters(); }
}); });
bar.addEventListener("input", function (event) { bar.addEventListener("input", function (event) {
if (event.target && event.target.hasAttribute("data-search-box")) applyFilters(); if (event.target && event.target.hasAttribute("data-search-box")) { dirPage = 1; applyFilters(); }
}); });
var emptyReset = document.getElementById("dirEmptyReset"); var emptyReset = document.getElementById("dirEmptyReset");
if (emptyReset) { if (emptyReset) {
@@ -2566,12 +2639,75 @@
var box = bar.querySelector("[data-search-box]"); var box = bar.querySelector("[data-search-box]");
if (box) box.value = ""; if (box) box.value = "";
Array.prototype.forEach.call(bar.querySelectorAll("select"), function (s) { s.value = ""; }); Array.prototype.forEach.call(bar.querySelectorAll("select"), function (s) { s.value = ""; });
dirPage = 1;
applyFilters(); applyFilters();
}); });
} }
applyFilters(); applyFilters();
} }
/* ---- The pager: nine cards per page ---------------------------------------
It renders ONLY when the filtered list needs more than one page — the same rule
the facets follow. With a short list it stays hidden, so no pager appears. */
var PER_PAGE = 9;
var dirPage = 1;
function renderPager(total) {
var pager = document.getElementById("dirPager");
var pages = document.getElementById("dirPages");
var prev = document.getElementById("dirPrev");
var next = document.getElementById("dirNext");
var status = document.getElementById("dirPagerStatus");
if (!pager || !pages) return;
var pageCount = Math.ceil(total / PER_PAGE);
if (pageCount <= 1) {
pager.hidden = true;
pages.textContent = "";
return;
}
var active = document.activeElement;
var keepFocus = pager.contains(active);
pager.hidden = false;
pages.textContent = "";
for (var p = 1; p <= pageCount; p++) {
var b = document.createElement("button");
b.type = "button";
b.className = "dir-page";
b.textContent = String(p);
b.setAttribute("data-page", String(p));
b.setAttribute("aria-label", "Page " + p + " of " + pageCount);
if (p === dirPage) b.setAttribute("aria-current", "page");
pages.appendChild(b);
}
if (prev) prev.disabled = dirPage <= 1;
if (next) next.disabled = dirPage >= pageCount;
if (status) status.textContent = "Page " + dirPage + " of " + pageCount;
// Keep the keyboard where it was. Previous/Next survive the rebuild, so focus
// stays on them; a number button is replaced, so focus moves to the current page
// — never dropped to the top of the document.
if (keepFocus) {
if (active && document.contains(active) && !active.disabled) active.focus();
else {
var current = pages.querySelector('[aria-current="page"]');
if (current) current.focus();
}
}
}
function initPager() {
var pager = document.getElementById("dirPager");
if (!pager) return;
pager.addEventListener("click", function (event) {
var btn = event.target.closest ? event.target.closest(".dir-page") : null;
if (!btn || btn.disabled) return;
var target = btn.getAttribute("data-page");
if (target === "prev") dirPage -= 1;
else if (target === "next") dirPage += 1;
else dirPage = parseInt(target, 10);
applyFilters();
});
}
function applyFilters() { function applyFilters() {
var bar = document.getElementById("dirFilters"); var bar = document.getElementById("dirFilters");
var grid = document.getElementById("dirGrid"); var grid = document.getElementById("dirGrid");
@@ -2588,7 +2724,7 @@
bar.hidden = nothing; bar.hidden = nothing;
if (count) count.hidden = nothing; if (count) count.hidden = nothing;
var shown = 0; var matched = [];
cards.forEach(function (card) { cards.forEach(function (card) {
var ok = true; var ok = true;
if (query) ok = (card.getAttribute("data-search") || "").indexOf(query) !== -1; if (query) ok = (card.getAttribute("data-search") || "").indexOf(query) !== -1;
@@ -2603,11 +2739,19 @@
if (have.split("|").indexOf(want) === -1) ok = false; if (have.split("|").indexOf(want) === -1) ok = false;
}); });
} }
card.hidden = !ok; if (ok) matched.push(card);
if (ok) shown++;
}); });
if (count && !count.hidden) count.textContent = "Showing " + shown + " of " + cards.length;
if (empty) empty.hidden = nothing ? true : shown !== 0; // Pagination applies to the FILTERED set. A page left out of range by a filter
// change falls back to page 1 rather than rendering an empty page.
var pageCount = Math.ceil(matched.length / PER_PAGE) || 1;
if (dirPage < 1 || dirPage > pageCount) dirPage = 1;
var page = matched.slice((dirPage - 1) * PER_PAGE, dirPage * PER_PAGE);
cards.forEach(function (card) { card.hidden = page.indexOf(card) === -1; });
if (count && !count.hidden) count.textContent = "Showing " + matched.length + " of " + cards.length;
if (empty) empty.hidden = nothing ? true : matched.length !== 0;
renderPager(matched.length);
} }
/* ---- Form 1: freelancer application (create — no id) ---- */ /* ---- Form 1: freelancer application (create — no id) ---- */
@@ -2739,6 +2883,7 @@
initFocusCounter(document.getElementById("applyForm"), "apDescription", "apDescriptionCount", DESC_MAX_CHARS); initFocusCounter(document.getElementById("applyForm"), "apDescription", "apDescriptionCount", DESC_MAX_CHARS);
initAskModal(); initAskModal();
Array.prototype.forEach.call(document.querySelectorAll(".dev-card"), enhanceAsk); Array.prototype.forEach.call(document.querySelectorAll(".dev-card"), enhanceAsk);
initPager();
initFilters(); initFilters();
initLiveList(); initLiveList();
initApplyForm(); initApplyForm();