/** * WPSubsPager — paginator footer (prev / next / numbers / ellipsis) that * controls a slice of rows inside a card. Single source of truth so the * server-rendered markup from `wpsubs_render_pager()` and the client * re-renderings stay byte-identical. * * Usage: * PHP: wpsubs_render_pager( $args ) // emits a .wpsubs-pager[data-wpsubs-pager] * JS: WPSubsPager.init() // auto-inits those elements * * Markup contract (data-* on .wpsubs-pager): * data-current — current page (1-indexed, int) * data-total — total pages (int) * data-per-page — items per page (int) * data-link-mode — 'url' (default) | 'cb' (callback / data-page buttons) * data-info-format — optional sprintf string for the "Showing X–Y of Z" text * * Row scope: * data-wpsubs-pager-scope (on the pager OR an ancestor) — a CSS selector for * the container holding the rows to paginate. Defaults to the closest * inside the pager's card. Rows are direct children matched by `row_selector` * on the scope element (default 'tbody tr'). Pro's non-table layouts can * override both with data attributes. * * Events fired on the pager root (bubbles): * wpsubs:pager:change — { page, total, pages, perPage } */ (function () { "use strict"; var MONTHS = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; // Same algorithm as PHP wpsubs_pager_page_range(): first, last, current ± 1, // gaps collapse to a single ellipsis (gap == 2 surfaces the missing page). function pageRange(current, total) { var first = 1; var last = total; current = Math.max(1, Math.min(total, current)); var nearStart = Math.max(2, current - 1); var nearEnd = Math.min(last - 1, current + 1); var nearby = []; for (var i = nearStart; i <= nearEnd; i++) nearby.push(i); var seen = {}; var parts = []; function push(n) { if (!seen[n]) { seen[n] = true; parts.push(n); } } push(first); nearby.forEach(push); if (last > first) push(last); var range = []; for (var j = 0; j < parts.length; j++) { var p = parts[j]; if (j > 0) { var gap = p - parts[j - 1]; if (gap === 2) range.push(parts[j - 1] + 1); else if (gap > 2) range.push(null); } range.push(p); } return range; } function el(tag, className, attrs) { var node = document.createElement(tag); if (className) node.className = className; if (attrs) { for (var k in attrs) if (Object.prototype.hasOwnProperty.call(attrs, k)) { if (k === "html") node.innerHTML = attrs[k]; else if (k === "text") node.textContent = attrs[k]; else if (k === "data") { for (var dk in attrs.data) node.setAttribute("data-" + dk, attrs.data[dk]); } else node.setAttribute(k, attrs[k]); } } return node; } function readInt(el, name, fallback) { var v = el.getAttribute(name); if (v === null || v === "") return fallback; var n = parseInt(v, 10); return isNaN(n) ? fallback : n; } function i18nInfo(format, start, end, total) { if (!format) return ""; if (typeof wp !== "undefined" && wp.i18n && typeof wp.i18n.__ === "function") { return wp.i18n .__(format, "subscription") .replace("%1$s", String(start)) .replace("%2$s", String(end)) .replace("%3$s", String(total)); } return format.replace("%1$s", String(start)).replace("%2$s", String(end)).replace("%3$s", String(total)); } function formatInfo(format, start, end, total) { if (typeof wp !== "undefined" && wp.i18n && typeof wp.i18n.sprintf === "function") { return wp.i18n.sprintf(format, String(start), String(end), String(total)); } return format.replace("%1$s", String(start)).replace("%2$s", String(end)).replace("%3$s", String(total)); } /** * @param {HTMLElement} root .wpsubs-pager[data-wpsubs-pager] */ function WPSubsPager(root) { this.root = root; this.scope = root.getAttribute("data-wpsubs-pager-scope") ? document.querySelector(root.getAttribute("data-wpsubs-pager-scope")) : root.closest("[data-wpsubs-pager-scope]") || this.findScope(); this.rowSelector = root.getAttribute("data-pager-row-selector") || "tbody tr"; this.dateCol = readInt(root, "data-date-col", -1); this.dateAdv = null; this.perPageAdv = null; this.linkMode = root.getAttribute("data-link-mode") || "url"; this.infoFormat = root.getAttribute("data-info-format") || ""; this.card = root.closest(".subscrpt-card, .wpsubs-table-card, [data-wpsubs-pager-card]") || root.parentElement; this.rows = this.collectRows(); this.page = readInt(root, "data-current", 1); this.total = readInt(root, "data-total", 1); this.perPage = readInt(root, "data-per-page", 10); this.emptyRow = null; this.colSpan = 1; this._bind(); } WPSubsPager.prototype.findScope = function () { // Default: closest
within the same card. var table = this.root.closest(".subscrpt-card, .wpsubs-table-card, [data-wpsubs-pager-card]"); if (table) { var t = table.querySelector("table"); if (t) return t; } return this.root.parentElement; }; WPSubsPager.prototype.collectRows = function () { if (!this.scope) return []; return Array.prototype.slice.call(this.scope.querySelectorAll(this.rowSelector)); }; WPSubsPager.prototype._bind = function () { var self = this; if (this.card) { this.dateAdv = this.card.querySelector(".subscrpt-filter-date"); this.perPageAdv = this.card.querySelector(".subscrpt-filter-perpage"); var table = this.card.querySelector("table"); if (table) { this.colSpan = table.querySelectorAll("thead th").length || 1; } } this.root.addEventListener("click", function (e) { var btn = e.target.closest(".wpsubs-pagination__btn"); if (!btn || !self.root.contains(btn)) return; if (btn.classList.contains("wpsubs-pagination__btn--ellipsis")) return; if (btn.classList.contains("wpsubs-pagination__btn--disabled")) return; if (btn.classList.contains("wpsubs-pagination__btn--active")) return; var page = parseInt(btn.getAttribute("data-page"), 10); if (!isNaN(page) && page !== self.page) { self.page = page; self.render(); } }); if (this.dateAdv) { this.dateAdv.addEventListener("wpsubs:select", function () { self.page = 1; self.render(); }); } if (this.perPageAdv) { this.perPageAdv.addEventListener("wpsubs:select", function () { var input = self.perPageAdv.querySelector('input[type="hidden"]'); self.perPage = parseInt(input ? input.value : self.perPage, 10) || self.perPage; self.page = 1; self.render(); }); } }; WPSubsPager.prototype._monthKey = function (tr) { if (!tr || this.dateCol < 0) return ""; var cell = tr.children[this.dateCol]; if (!cell) return ""; var d = new Date(cell.textContent.replace(" - ", " ").trim()); if (isNaN(d.getTime())) return ""; return d.getFullYear() + "-" + ("0" + (d.getMonth() + 1)).slice(-2); }; WPSubsPager.prototype._injectMonthOptions = function (rows) { if (!this.dateAdv || this.dateCol < 0) return; var menu = this.dateAdv.querySelector(".wpsubs-adv-select__menu"); if (!menu) return; // Remove previously injected month options (any non-original item). var injected = menu.querySelectorAll("[data-pager-month]"); injected.forEach(function (n) { n.parentNode.removeChild(n); }); var seen = {}; var months = []; rows.forEach(function (tr) { var key = this._monthKey(tr); if (key && !seen[key]) { seen[key] = true; months.push(key); } }, this); months .sort() .reverse() .forEach(function (key) { var parts = key.split("-"); var btn = document.createElement("button"); btn.type = "button"; btn.setAttribute("data-pager-month", "1"); btn.className = "wpsubs-adv-select__item"; btn.setAttribute("data-value", key); btn.setAttribute("role", "option"); var span = document.createElement("span"); span.className = "wpsubs-adv-select__item-label"; span.textContent = MONTHS[parseInt(parts[1], 10) - 1] + " " + parts[0]; btn.appendChild(span); menu.appendChild(btn); }, this); }; WPSubsPager.prototype._advValue = function (adv) { if (!adv) return ""; var input = adv.querySelector('input[type="hidden"]'); return input ? input.value : ""; }; WPSubsPager.prototype._filtered = function () { var dm = this._advValue(this.dateAdv); if (!dm || this.dateCol < 0) return this.rows; return this.rows.filter(function (tr) { return this._monthKey(tr) === dm; }, this); }; WPSubsPager.prototype._makeBtn = function (label, target, isDisabled, isActive) { if (isDisabled) { return el("span", "wpsubs-pagination__btn wpsubs-pagination__btn--disabled", { "aria-hidden": "true", html: label, }); } if (isActive) { return el("span", "wpsubs-pagination__btn wpsubs-pagination__btn--active", { "aria-current": "page", text: label, }); } if (this.linkMode === "cb") { return el("button", "wpsubs-pagination__btn", { type: "button", "data-page": String(target), text: label, }); } return el("a", "wpsubs-pagination__btn", { "data-page": String(target), text: label }); }; WPSubsPager.prototype._makeEllipsis = function () { return el("span", "wpsubs-pagination__btn wpsubs-pagination__btn--ellipsis", { "aria-hidden": "true", text: "…", }); }; WPSubsPager.prototype._buildPager = function (pages) { var frag = document.createDocumentFragment(); frag.appendChild(this._makeBtn("‹", this.page - 1, this.page <= 1, false)); var range = pageRange(this.page, pages); for (var i = 0; i < range.length; i++) { var p = range[i]; frag.appendChild(p === null ? this._makeEllipsis() : this._makeBtn(String(p), p, false, p === this.page)); } frag.appendChild(this._makeBtn("›", this.page + 1, this.page >= pages, false)); return frag; }; WPSubsPager.prototype._updateInfo = function (start, end, total) { if (!this.infoFormat) return; var span = this.root.querySelector(".wpsubs-pagination__info"); if (!span) return; span.textContent = formatInfo(this.infoFormat, total ? start + 1 : 0, Math.min(end, total), total); }; WPSubsPager.prototype._showEmpty = function (show) { if (!this.scope) return; if (show) { if (!this.emptyRow) { var tr = document.createElement("tr"); tr.className = "subscrpt-empty-row"; var td = document.createElement("td"); td.colSpan = this.colSpan; td.style.textAlign = "center"; td.style.padding = "18px"; td.textContent = typeof wp !== "undefined" && wp.i18n && typeof wp.i18n.__ === "function" ? wp.i18n.__("No matching records.", "subscription") : "No matching records."; tr.appendChild(td); if (this.scope.tagName === "TBODY" || this.scope.tagName === "TABLE") { this.scope.appendChild(tr); } else { this.scope.appendChild(tr); } this.emptyRow = tr; } this.emptyRow.style.display = ""; } else if (this.emptyRow) { this.emptyRow.style.display = "none"; } }; WPSubsPager.prototype.render = function () { var list = this._filtered(); var pages = Math.max(1, Math.ceil(list.length / this.perPage)); if (this.page > pages) this.page = pages; var start = (this.page - 1) * this.perPage; var end = start + this.perPage; this.total = pages; this.rows.forEach(function (tr) { tr.style.display = "none"; }); list.slice(start, end).forEach(function (tr) { tr.style.display = ""; }); this._showEmpty(list.length === 0); // Rebuild just the nav portion, leave the root attributes alone. var existingNav = this.root.querySelector(".wpsubs-pagination__nav"); if (existingNav) existingNav.parentNode.removeChild(existingNav); var nav = el("div", "wpsubs-pagination__nav"); nav.appendChild(this._buildPager(pages)); this.root.appendChild(nav); // Keep current/total data-* in sync so subsequent re-inits read the right state. this.root.setAttribute("data-current", String(this.page)); this.root.setAttribute("data-total", String(pages)); this._updateInfo(start, end, list.length); this.root.dispatchEvent( new CustomEvent("wpsubs:pager:change", { bubbles: true, detail: { page: this.page, total: list.length, pages: pages, perPage: this.perPage }, }), ); }; WPSubsPager.prototype.refresh = function () { // External hook: re-collect rows (Pro renders new content) and re-paginate. this.rows = this.collectRows(); this._injectMonthOptions(this.rows); this.render(); }; WPSubsPager.prototype.goTo = function (page) { page = parseInt(page, 10); if (isNaN(page)) return; var pages = Math.max(1, Math.ceil(this.rows.length / this.perPage)); this.page = Math.max(1, Math.min(pages, page)); this.render(); }; /** * Initialise all un-initialised .wpsubs-pager elements under root. * Skips pagers in `link-mode="url"` — those are server-rendered (e.g. the * subscriptions list page) and own all the data they need; touching them * from JS would re-derive totals from the current page's rows and clobber * the correct pagination (e.g. show only "1" of 3 pages). * * @param {Document|HTMLElement} [root] */ function init(root) { (root || document) .querySelectorAll(".wpsubs-pager[data-wpsubs-pager][data-link-mode='cb']:not([data-pager-init])") .forEach(function (el) { el.setAttribute("data-pager-init", "1"); var pager = new WPSubsPager(el); pager._injectMonthOptions(pager.rows); pager.render(); }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", function () { init(); }); } else { init(); } // Public API window.WPSubsPager = { init: init, /** * Re-collect rows for every initialised cb-mode pager under root and * re-render. Useful when a callback injects new rows (e.g. Pro activities). */ refresh: function (root) { (root || document).querySelectorAll(".wpsubs-pager[data-link-mode='cb'][data-pager-init]").forEach(function (el) { el.removeAttribute("data-pager-init"); init(el.parentNode || document); }); }, /** * Force a single pager to re-collect rows (Pro hook) and re-render. * Skips url-mode pagers. */ refreshOne: function (el) { if (!el) return; if (el.getAttribute("data-link-mode") !== "cb") return; el.removeAttribute("data-pager-init"); init(el.parentNode || document); }, }; })();