PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 1.11.2
Subscriptions for WooCommerce with Stripe Recurring Payments v1.11.2
2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
subscription / assets / js / admin-components.js

admin-components.js in Subscriptions for WooCommerce with Stripe Recurring Payments 1.11.2, at assets/js/admin-components.js

1,200 lines 39.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * WPSubscription admin UI components.
3 *
4 * WPSubsAdvSelect — styled dropdown replacing native <select>.
5 *
6 * Usage:
7 * PHP: wpsubs_render_adv_select( $args ) — renders the HTML
8 * JS: WPSubsAdvSelect.init() — auto-inits all .wpsubs-adv-select elements
9 *
10 * Events fired on the root element (bubbles):
11 * wpsubs:select — { value, label } when user picks an item
12 */
13 (function () {
14 "use strict";
15
16 var instances = [];
17
18 /**
19 * @param {HTMLElement} el Root .wpsubs-adv-select element.
20 */
21 function WPSubsAdvSelect(el) {
22 this.el = el;
23 this.trigger = el.querySelector(".wpsubs-adv-select__trigger");
24 this.label = el.querySelector(".wpsubs-adv-select__label");
25 this.menu = el.querySelector(".wpsubs-adv-select__menu");
26 this.input = el.querySelector('input[type="hidden"]');
27 this._bind();
28 instances.push(this);
29 }
30
31 WPSubsAdvSelect.prototype.open = function () {
32 closeAll(this);
33 this.el.classList.add("wpsubs-adv-select--open");
34 if (this.trigger) this.trigger.setAttribute("aria-expanded", "true");
35 };
36
37 WPSubsAdvSelect.prototype.close = function () {
38 this.el.classList.remove("wpsubs-adv-select--open");
39 if (this.trigger) this.trigger.setAttribute("aria-expanded", "false");
40 };
41
42 WPSubsAdvSelect.prototype.isOpen = function () {
43 return this.el.classList.contains("wpsubs-adv-select--open");
44 };
45
46 /**
47 * Programmatically select a value.
48 *
49 * @param {string} value
50 * @param {string} label Display text shown in trigger. Defaults to value.
51 */
52 WPSubsAdvSelect.prototype.select = function (value, label) {
53 if (this.input) this.input.value = value;
54 if (this.label) this.label.textContent = label || value;
55 this.el.dispatchEvent(
56 new CustomEvent("wpsubs:select", {
57 bubbles: true,
58 detail: { value: value, label: label || value },
59 }),
60 );
61 };
62
63 /** Reset trigger label back to placeholder. */
64 WPSubsAdvSelect.prototype.reset = function () {
65 var placeholder = this.el.dataset.placeholder || "";
66 if (this.input) this.input.value = this.el.dataset.defaultValue || "";
67 if (this.label && placeholder) this.label.textContent = placeholder;
68 };
69
70 WPSubsAdvSelect.prototype._bind = function () {
71 var self = this;
72
73 if (self.trigger) {
74 self.trigger.addEventListener("click", function (e) {
75 e.stopPropagation();
76 self.isOpen() ? self.close() : self.open();
77 });
78 }
79
80 if (self.menu) {
81 self.menu.addEventListener("click", function (e) {
82 var item = e.target.closest(".wpsubs-adv-select__item");
83 if (!item || item.hasAttribute("data-disabled")) return;
84
85 var value = item.dataset.value !== undefined ? item.dataset.value : "";
86 var labelEl = item.querySelector(".wpsubs-adv-select__item-label");
87 var label = labelEl ? labelEl.textContent.trim() : item.textContent.trim();
88 var confirmMsg = item.dataset.confirm || "";
89
90 if (confirmMsg && !window.confirm(confirmMsg)) return;
91
92 self.close();
93 self.select(value, label);
94 });
95 }
96 };
97
98 function closeAll(except) {
99 instances.forEach(function (inst) {
100 if (inst !== except) inst.close();
101 });
102 }
103
104 /**
105 * Initialise all un-initialised .wpsubs-adv-select elements under root.
106 *
107 * @param {Document|HTMLElement} [root]
108 */
109 function init(root) {
110 (root || document).querySelectorAll(".wpsubs-adv-select:not([data-adv-init])").forEach(function (el) {
111 el.setAttribute("data-adv-init", "1");
112 new WPSubsAdvSelect(el);
113 });
114 }
115
116 // Global: outside click and Escape close all
117 document.addEventListener("click", function () {
118 closeAll();
119 });
120 document.addEventListener("keydown", function (e) {
121 if (e.key === "Escape") closeAll();
122 });
123
124 // Auto-init on DOM ready
125 if (document.readyState === "loading") {
126 document.addEventListener("DOMContentLoaded", function () {
127 init();
128 });
129 } else {
130 init();
131 }
132
133 // Public API
134 window.WPSubsAdvSelect = { init: init };
135 })();
136
137 /**
138 * WPSubsTagSelect — pill/tag input with inline filter and filterable dropdown.
139 *
140 * Usage:
141 * PHP: wpsubs_render_tag_select( $args ) — renders the HTML
142 * JS: WPSubsTagSelect.init() — auto-inits all .wpsubs-tag-select elements
143 *
144 * Events fired on the root element (bubbles):
145 * wpsubs:select — { value, label, selected } when a pill is added or removed
146 */
147 (function () {
148 "use strict";
149
150 var instances = [];
151
152 /**
153 * @param {HTMLElement} el Root .wpsubs-tag-select element.
154 */
155 function WPSubsTagSelect(el) {
156 this.el = el;
157 this.multiple = !!el.dataset.multiple;
158 this.fieldName = el.dataset.name || "";
159 this.field = el.querySelector(".wpsubs-tag-select__field");
160 this.input = el.querySelector(".wpsubs-tag-select__input");
161 this.dropdown = el.querySelector(".wpsubs-tag-select__dropdown");
162 this.list = el.querySelector(".wpsubs-tag-select__list");
163 this.emptyEl = el.querySelector(".wpsubs-tag-select__empty");
164 this._bind();
165 instances.push(this);
166 }
167
168 WPSubsTagSelect.prototype.open = function () {
169 closeAll(this);
170 this.el.classList.add("wpsubs-tag-select--open");
171 this._filterItems("");
172 if (this.input) this.input.focus();
173 };
174
175 WPSubsTagSelect.prototype.close = function () {
176 this.el.classList.remove("wpsubs-tag-select--open");
177 if (this.input) {
178 this.input.value = "";
179 this._filterItems("");
180 }
181 };
182
183 WPSubsTagSelect.prototype.isOpen = function () {
184 return this.el.classList.contains("wpsubs-tag-select--open");
185 };
186
187 /** Show/hide dropdown items based on query, always hiding selected ones. */
188 WPSubsTagSelect.prototype._filterItems = function (query) {
189 var q = query.trim().toLowerCase();
190 var items = this.list ? this.list.querySelectorAll(".wpsubs-tag-select__item") : [];
191 var visible = 0;
192 items.forEach(function (item) {
193 if (item.hasAttribute("data-selected")) {
194 item.style.display = "none";
195 return;
196 }
197 var text = item.textContent.toLowerCase();
198 var match = !q || text.indexOf(q) !== -1;
199 item.style.display = match ? "" : "none";
200 if (match) visible++;
201 });
202 if (this.emptyEl) {
203 this.emptyEl.style.display = visible === 0 ? "" : "none";
204 }
205 };
206
207 /** Add a pill for the given dropdown item. */
208 WPSubsTagSelect.prototype._addPill = function (item) {
209 var value = item.dataset.value !== undefined ? item.dataset.value : "";
210 var label = item.textContent.trim();
211
212 // For single-select, remove the existing pill first.
213 if (!this.multiple) {
214 var existing = this.el.querySelectorAll(".wpsubs-tag-select__pill");
215 var self = this;
216 existing.forEach(function (p) {
217 self._removePillEl(p, false);
218 });
219 }
220
221 // Build the pill.
222 var pill = document.createElement("span");
223 pill.className = "wpsubs-tag-select__pill";
224 pill.dataset.value = value;
225
226 var pillLabel = document.createElement("span");
227 pillLabel.className = "wpsubs-tag-select__pill-label";
228 pillLabel.textContent = label;
229
230 var removeBtn = document.createElement("button");
231 removeBtn.type = "button";
232 removeBtn.className = "wpsubs-tag-select__pill-remove";
233 removeBtn.setAttribute("aria-label", "Remove " + label);
234 removeBtn.innerHTML = "&#x2715;"; // ×
235
236 pill.appendChild(pillLabel);
237 pill.appendChild(removeBtn);
238
239 // Insert the pill before the text input.
240 if (this.input) {
241 this.field.insertBefore(pill, this.input);
242 } else {
243 this.field.appendChild(pill);
244 }
245
246 // Mark dropdown item as selected so it stays hidden.
247 item.setAttribute("data-selected", "");
248 item.style.display = "none";
249
250 this._syncHiddenInputs();
251 this._updateInputPlaceholder();
252
253 if (this.input) this.input.value = "";
254 this._filterItems("");
255
256 this.el.dispatchEvent(
257 new CustomEvent("wpsubs:select", {
258 bubbles: true,
259 detail: { value: value, label: label, selected: true },
260 }),
261 );
262 };
263
264 /** Remove a pill element. Pass sync=true to update hidden inputs (default). */
265 WPSubsTagSelect.prototype._removePillEl = function (pill, sync) {
266 var value = pill.dataset.value !== undefined ? pill.dataset.value : "";
267 var label = pill.querySelector(".wpsubs-tag-select__pill-label");
268 var labelText = label ? label.textContent.trim() : value;
269
270 pill.parentNode.removeChild(pill);
271
272 // Un-mark the corresponding dropdown item.
273 var item = this.list
274 ? this.list.querySelector('.wpsubs-tag-select__item[data-value="' + CSS.escape(value) + '"]')
275 : null;
276 if (item) {
277 item.removeAttribute("data-selected");
278 }
279
280 if (sync !== false) {
281 this._syncHiddenInputs();
282 this._updateInputPlaceholder();
283 this._filterItems(this.input ? this.input.value : "");
284
285 this.el.dispatchEvent(
286 new CustomEvent("wpsubs:select", {
287 bubbles: true,
288 detail: { value: value, label: labelText, selected: false },
289 }),
290 );
291 }
292 };
293
294 /** Rebuild hidden inputs to match current pills. */
295 WPSubsTagSelect.prototype._syncHiddenInputs = function () {
296 var existing = this.el.querySelectorAll("input[data-ts-val]");
297 var fieldName = existing.length > 0 ? existing[0].name : this.fieldName + (this.multiple ? "[]" : "");
298
299 existing.forEach(function (inp) {
300 inp.parentNode.removeChild(inp);
301 });
302
303 var pills = this.el.querySelectorAll(".wpsubs-tag-select__pill");
304 var self = this;
305
306 if (this.multiple) {
307 if (pills.length === 0) {
308 // Empty sentinel so the form field is always present on submit.
309 var sentinel = document.createElement("input");
310 sentinel.type = "hidden";
311 sentinel.name = fieldName;
312 sentinel.value = "";
313 sentinel.setAttribute("data-ts-val", "");
314 self.el.appendChild(sentinel);
315 } else {
316 pills.forEach(function (pill) {
317 var inp = document.createElement("input");
318 inp.type = "hidden";
319 inp.name = fieldName;
320 inp.value = pill.dataset.value !== undefined ? pill.dataset.value : "";
321 inp.setAttribute("data-ts-val", "");
322 self.el.appendChild(inp);
323 });
324 }
325 } else {
326 var inp = document.createElement("input");
327 inp.type = "hidden";
328 inp.name = fieldName;
329 inp.value = pills.length > 0 && pills[0].dataset.value !== undefined ? pills[0].dataset.value : "";
330 inp.setAttribute("data-ts-val", "");
331 self.el.appendChild(inp);
332 }
333 };
334
335 /** Show placeholder only when there are no pills. */
336 WPSubsTagSelect.prototype._updateInputPlaceholder = function () {
337 if (!this.input) return;
338 var pills = this.el.querySelectorAll(".wpsubs-tag-select__pill");
339 this.input.placeholder = pills.length === 0 ? this.el.dataset.placeholder || "" : "";
340 };
341
342 WPSubsTagSelect.prototype._bind = function () {
343 var self = this;
344
345 if (self.field) {
346 self.field.addEventListener("click", function (e) {
347 e.stopPropagation();
348 var removeBtn = e.target.closest(".wpsubs-tag-select__pill-remove");
349 if (removeBtn) {
350 var pill = removeBtn.closest(".wpsubs-tag-select__pill");
351 if (pill) self._removePillEl(pill);
352 return;
353 }
354 self.open();
355 });
356 }
357
358 if (self.input) {
359 self.input.addEventListener("input", function () {
360 if (!self.isOpen()) self.open();
361 self._filterItems(self.input.value);
362 });
363 }
364
365 if (self.dropdown) {
366 self.dropdown.addEventListener("click", function (e) {
367 e.stopPropagation();
368 var item = e.target.closest(".wpsubs-tag-select__item");
369 if (!item || item.hasAttribute("data-disabled")) return;
370 self._addPill(item);
371 if (!self.multiple) self.close();
372 });
373 }
374 };
375
376 function closeAll(except) {
377 instances.forEach(function (inst) {
378 if (inst !== except) inst.close();
379 });
380 }
381
382 /**
383 * Initialise all un-initialised .wpsubs-tag-select elements under root.
384 *
385 * @param {Document|HTMLElement} [root]
386 */
387 function init(root) {
388 (root || document).querySelectorAll(".wpsubs-tag-select:not([data-ts-init])").forEach(function (el) {
389 el.setAttribute("data-ts-init", "1");
390 new WPSubsTagSelect(el);
391 });
392 }
393
394 document.addEventListener("click", function () {
395 closeAll();
396 });
397 document.addEventListener("keydown", function (e) {
398 if (e.key === "Escape") closeAll();
399 });
400
401 if (document.readyState === "loading") {
402 document.addEventListener("DOMContentLoaded", function () {
403 init();
404 });
405 } else {
406 init();
407 }
408
409 // Public API
410 window.WPSubsTagSelect = { init: init };
411 })();
412
413 /**
414 * WPSubsEditList — editable ordered list of text items.
415 *
416 * A reusable admin component: renders items in a reorderable list with per-row
417 * remove/move controls, plus an inline input + add button to append new items.
418 * The ordered list is serialized as JSON ([{ key, label }]) into a hidden input
419 * so it submits with the surrounding form. `key` is a slug derived from the label.
420 *
421 * Usage:
422 * PHP: SettingsHelper::render_editlist() / any markup with the classes below
423 * JS: WPSubsEditList.init() — auto-inits all .wpsubs-editlist elements
424 *
425 * Expected structure inside .wpsubs-editlist:
426 * input[type=hidden] — JSON store
427 * .wpsubs-editlist__items > .wpsubs-editlist__item[data-key]
428 * .wpsubs-editlist__label
429 * [data-editlist-up] [data-editlist-down] [data-editlist-remove]
430 * .wpsubs-editlist__empty — shown only when empty
431 * .wpsubs-editlist__input — inline text input
432 * [data-editlist-add] — add/confirm button
433 *
434 * Events fired on the root element (bubbles):
435 * wpsubs:change — { items } after any add/remove/reorder
436 */
437 (function () {
438 "use strict";
439
440 /**
441 * Derive a slug key from a label.
442 *
443 * @param {string} label
444 * @return {string}
445 */
446 function slugify(label) {
447 return String(label)
448 .toLowerCase()
449 .replace(/[^a-z0-9]+/g, "_")
450 .replace(/^_+|_+$/g, "");
451 }
452
453 var SVG_UP =
454 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="18 15 12 9 6 15"/></svg>';
455 var SVG_DOWN =
456 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>';
457 var SVG_TRASH =
458 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
459
460 /**
461 * @param {HTMLElement} el Root .wpsubs-editlist element.
462 */
463 function WPSubsEditList(el) {
464 this.el = el;
465 this.hidden = el.querySelector('input[type="hidden"]');
466 this.list = el.querySelector(".wpsubs-editlist__items");
467 this.emptyEl = el.querySelector(".wpsubs-editlist__empty");
468 this.input = el.querySelector(".wpsubs-editlist__input");
469 // Optional live count badge (e.g. when the list lives inside a modal trigger).
470 this.countEl = el.querySelector(".wpsubs-editlist__count");
471 this._bind();
472 this._serialize();
473 }
474
475 WPSubsEditList.prototype._rows = function () {
476 return this.list ? this.list.querySelectorAll(".wpsubs-editlist__item") : [];
477 };
478
479 /** Serialize the current rows into the hidden input as JSON. */
480 WPSubsEditList.prototype._serialize = function () {
481 var items = [];
482 this._rows().forEach(function (row) {
483 var labelEl = row.querySelector(".wpsubs-editlist__label");
484 items.push({
485 key: row.getAttribute("data-key") || "",
486 label: labelEl ? labelEl.textContent : "",
487 });
488 });
489 if (this.hidden) this.hidden.value = JSON.stringify(items);
490 if (this.emptyEl) this.emptyEl.hidden = items.length > 0;
491 if (this.countEl) this.countEl.textContent = String(items.length);
492 this.el.dispatchEvent(new CustomEvent("wpsubs:change", { bubbles: true, detail: { items: items } }));
493 };
494
495 /**
496 * Append a new item row.
497 *
498 * @param {string} label
499 */
500 WPSubsEditList.prototype._addItem = function (label) {
501 var text = String(label).trim();
502 if (!text || !this.list) return;
503
504 var row = document.createElement("li");
505 row.className = "wpsubs-editlist__item";
506 row.setAttribute("data-key", slugify(text));
507
508 var handle = document.createElement("span");
509 handle.className = "wpsubs-editlist__handle";
510 handle.setAttribute("aria-hidden", "true");
511 handle.innerHTML = "&#8942;&#8942;";
512
513 var lab = document.createElement("span");
514 lab.className = "wpsubs-editlist__label";
515 lab.textContent = text;
516
517 var actions = document.createElement("span");
518 actions.className = "wpsubs-editlist__actions";
519 actions.innerHTML =
520 '<button type="button" class="wpsubs-editlist__btn" data-editlist-up>' +
521 SVG_UP +
522 "</button>" +
523 '<button type="button" class="wpsubs-editlist__btn" data-editlist-down>' +
524 SVG_DOWN +
525 "</button>" +
526 '<button type="button" class="wpsubs-editlist__btn wpsubs-editlist__btn--danger" data-editlist-remove>' +
527 SVG_TRASH +
528 "</button>";
529
530 row.appendChild(handle);
531 row.appendChild(lab);
532 row.appendChild(actions);
533 this.list.appendChild(row);
534 this._serialize();
535 };
536
537 /** Add the item currently typed in the inline input, then reset it. */
538 WPSubsEditList.prototype._commitInput = function () {
539 if (!this.input) return;
540 var val = this.input.value.trim();
541 if (!val) return;
542 this._addItem(val);
543 this.input.value = "";
544 this.input.focus();
545 };
546
547 WPSubsEditList.prototype._bind = function () {
548 var self = this;
549
550 this.el.addEventListener("click", function (e) {
551 if (e.target.closest("[data-editlist-add]")) {
552 self._commitInput();
553 return;
554 }
555 var row = e.target.closest(".wpsubs-editlist__item");
556 if (!row) return;
557
558 if (e.target.closest("[data-editlist-remove]")) {
559 row.parentNode.removeChild(row);
560 self._serialize();
561 } else if (e.target.closest("[data-editlist-up]")) {
562 if (row.previousElementSibling) {
563 row.parentNode.insertBefore(row, row.previousElementSibling);
564 self._serialize();
565 }
566 } else if (e.target.closest("[data-editlist-down]")) {
567 if (row.nextElementSibling) {
568 row.parentNode.insertBefore(row.nextElementSibling, row);
569 self._serialize();
570 }
571 }
572 });
573
574 if (this.input) {
575 this.input.addEventListener("keydown", function (e) {
576 if (e.key === "Enter") {
577 e.preventDefault();
578 self._commitInput();
579 }
580 });
581 }
582
583 // Drag to reorder — only starts from the handle.
584 this._dragRow = null;
585
586 this.el.addEventListener("mousedown", function (e) {
587 var handle = e.target.closest(".wpsubs-editlist__handle");
588 if (!handle) return;
589 var row = handle.closest(".wpsubs-editlist__item");
590 if (row) row.setAttribute("draggable", "true");
591 });
592
593 this.el.addEventListener("dragstart", function (e) {
594 var row = e.target.closest(".wpsubs-editlist__item");
595 if (!row || row.getAttribute("draggable") !== "true") return;
596 self._dragRow = row;
597 row.classList.add("wpsubs-editlist__item--dragging");
598 if (e.dataTransfer) {
599 e.dataTransfer.effectAllowed = "move";
600 try {
601 e.dataTransfer.setData("text/plain", "");
602 } catch (err) {
603 /* IE guard */
604 }
605 }
606 });
607
608 this.el.addEventListener("dragover", function (e) {
609 if (!self._dragRow || !self.list) return;
610 e.preventDefault();
611 var over = e.target.closest(".wpsubs-editlist__item");
612 if (!over || over === self._dragRow) return;
613 var rect = over.getBoundingClientRect();
614 var after = (e.clientY - rect.top) / rect.height > 0.5;
615 self.list.insertBefore(self._dragRow, after ? over.nextSibling : over);
616 });
617
618 this.el.addEventListener("drop", function (e) {
619 if (self._dragRow) e.preventDefault();
620 });
621
622 this.el.addEventListener("dragend", function () {
623 if (!self._dragRow) return;
624 self._dragRow.classList.remove("wpsubs-editlist__item--dragging");
625 self._dragRow.removeAttribute("draggable");
626 self._dragRow = null;
627 self._serialize();
628 });
629 };
630
631 /**
632 * Initialise all un-initialised .wpsubs-editlist elements under root.
633 *
634 * @param {Document|HTMLElement} [root]
635 */
636 function init(root) {
637 (root || document).querySelectorAll(".wpsubs-editlist:not([data-editlist-init])").forEach(function (el) {
638 el.setAttribute("data-editlist-init", "1");
639 new WPSubsEditList(el);
640 });
641 }
642
643 if (document.readyState === "loading") {
644 document.addEventListener("DOMContentLoaded", function () {
645 init();
646 });
647 } else {
648 init();
649 }
650
651 // Public API
652 window.WPSubsEditList = { init: init };
653 })();
654
655 /**
656 * WPSubsModal — reusable centered dialog over a dimmed backdrop.
657 *
658 * Behaviour only; the visual comes from the .wpsubs-modal CSS. A modal is a
659 * `.wpsubs-modal[hidden]` element with an id. Any control opens it via
660 * `data-wpsubs-modal-open="<modal-id>"`; any control inside closes it via
661 * `data-wpsubs-modal-close` (the backdrop and the header/footer buttons use it).
662 * Escape also closes. No per-page wiring needed.
663 *
664 * A modal with `data-wpsubs-modal-autoopen` opens automatically on load (e.g. the
665 * Pro-upgrade preview modals). Opening locks body scroll; it's restored when the
666 * last open modal closes.
667 *
668 * Usage:
669 * PHP: wpsubs_render_modal( $args ) — renders the markup
670 * JS: auto-inits; WPSubsModal.open(id) / .close(id) available programmatically
671 *
672 * Events fired on the modal element (bubbles):
673 * wpsubs:modal:open / wpsubs:modal:close
674 */
675 (function () {
676 "use strict";
677
678 /**
679 * @param {string|HTMLElement} target Modal id or element.
680 * @return {HTMLElement|null}
681 */
682 function resolve(target) {
683 if (target instanceof HTMLElement) return target;
684 return document.getElementById(String(target));
685 }
686
687 function open(target) {
688 var modal = resolve(target);
689 if (!modal) return;
690 modal.hidden = false;
691 document.body.style.overflow = "hidden";
692 modal.dispatchEvent(new CustomEvent("wpsubs:modal:open", { bubbles: true }));
693 var focusable = modal.querySelector("input, textarea, select, button:not([data-wpsubs-modal-close])");
694 if (focusable) focusable.focus();
695 }
696
697 function close(target) {
698 var modal = resolve(target);
699 if (!modal) return;
700 modal.hidden = true;
701 // Restore body scroll only when no modal remains open.
702 if (!document.querySelector(".wpsubs-modal:not([hidden])")) {
703 document.body.style.overflow = "";
704 }
705 modal.dispatchEvent(new CustomEvent("wpsubs:modal:close", { bubbles: true }));
706 }
707
708 function closeAll() {
709 document.querySelectorAll(".wpsubs-modal:not([hidden])").forEach(function (m) {
710 close(m);
711 });
712 }
713
714 // Delegated open/close — works for markup added after load too.
715 document.addEventListener("click", function (e) {
716 var opener = e.target.closest("[data-wpsubs-modal-open]");
717 if (opener) {
718 e.preventDefault();
719 open(opener.getAttribute("data-wpsubs-modal-open"));
720 return;
721 }
722 var closer = e.target.closest("[data-wpsubs-modal-close]");
723 if (closer) {
724 e.preventDefault();
725 var modal = closer.closest(".wpsubs-modal");
726 if (modal) close(modal);
727 }
728 });
729
730 document.addEventListener("keydown", function (e) {
731 if (e.key === "Escape") closeAll();
732 });
733
734 /**
735 * Open any modal flagged to auto-open on load.
736 *
737 * @param {Document|HTMLElement} [root]
738 */
739 function init(root) {
740 (root || document).querySelectorAll(".wpsubs-modal[data-wpsubs-modal-autoopen]").forEach(function (modal) {
741 open(modal);
742 });
743 }
744
745 if (document.readyState === "loading") {
746 document.addEventListener("DOMContentLoaded", function () {
747 init();
748 });
749 } else {
750 init();
751 }
752
753 // Public API
754 window.WPSubsModal = { open: open, close: close, init: init };
755 })();
756
757 /**
758 * WPSubsPager — paginator footer (prev / next / numbers / ellipsis) that
759 * controls a slice of rows inside a card. Single source of truth so the
760 * server-rendered markup from `wpsubs_render_pager()` and the client
761 * re-renderings stay byte-identical.
762 *
763 * Usage:
764 * PHP: wpsubs_render_pager( $args ) // emits a .wpsubs-pager[data-wpsubs-pager]
765 * JS: WPSubsPager.init() // auto-inits those elements
766 *
767 * Markup contract (data-* on .wpsubs-pager):
768 * data-current — current page (1-indexed, int)
769 * data-total — total pages (int)
770 * data-per-page — items per page (int)
771 * data-link-mode — 'url' (default) | 'cb' (callback / data-page buttons)
772 * data-info-format — optional sprintf string for the "Showing X–Y of Z" text
773 *
774 * Row scope:
775 * data-wpsubs-pager-scope (on the pager OR an ancestor) — a CSS selector for
776 * the container holding the rows to paginate. Defaults to the closest <table>
777 * inside the pager's card. Rows are direct children matched by `row_selector`
778 * on the scope element (default 'tbody tr'). Pro's non-table layouts can
779 * override both with data attributes.
780 *
781 * Events fired on the pager root (bubbles):
782 * wpsubs:pager:change — { page, total, pages, perPage }
783 */
784 (function () {
785 "use strict";
786
787 var MONTHS = [
788 "January",
789 "February",
790 "March",
791 "April",
792 "May",
793 "June",
794 "July",
795 "August",
796 "September",
797 "October",
798 "November",
799 "December",
800 ];
801
802 // Same algorithm as PHP wpsubs_pager_page_range(): first, last, current ± 1,
803 // gaps collapse to a single ellipsis (gap == 2 surfaces the missing page).
804 function pageRange(current, total) {
805 var first = 1;
806 var last = total;
807 current = Math.max(1, Math.min(total, current));
808 var nearStart = Math.max(2, current - 1);
809 var nearEnd = Math.min(last - 1, current + 1);
810 var nearby = [];
811 for (var i = nearStart; i <= nearEnd; i++) nearby.push(i);
812 var seen = {};
813 var parts = [];
814 function push(n) {
815 if (!seen[n]) {
816 seen[n] = true;
817 parts.push(n);
818 }
819 }
820 push(first);
821 nearby.forEach(push);
822 if (last > first) push(last);
823 var range = [];
824 for (var j = 0; j < parts.length; j++) {
825 var p = parts[j];
826 if (j > 0) {
827 var gap = p - parts[j - 1];
828 if (gap === 2) range.push(parts[j - 1] + 1);
829 else if (gap > 2) range.push(null);
830 }
831 range.push(p);
832 }
833 return range;
834 }
835
836 function el(tag, className, attrs) {
837 var node = document.createElement(tag);
838 if (className) node.className = className;
839 if (attrs) {
840 for (var k in attrs)
841 if (Object.prototype.hasOwnProperty.call(attrs, k)) {
842 if (k === "html") node.innerHTML = attrs[k];
843 else if (k === "text") node.textContent = attrs[k];
844 else if (k === "data") {
845 for (var dk in attrs.data) node.setAttribute("data-" + dk, attrs.data[dk]);
846 } else node.setAttribute(k, attrs[k]);
847 }
848 }
849 return node;
850 }
851
852 function readInt(el, name, fallback) {
853 var v = el.getAttribute(name);
854 if (v === null || v === "") return fallback;
855 var n = parseInt(v, 10);
856 return isNaN(n) ? fallback : n;
857 }
858
859 function i18nInfo(format, start, end, total) {
860 if (!format) return "";
861 if (typeof wp !== "undefined" && wp.i18n && typeof wp.i18n.__ === "function") {
862 return wp.i18n
863 .__(format, "subscription")
864 .replace("%1$s", String(start))
865 .replace("%2$s", String(end))
866 .replace("%3$s", String(total));
867 }
868 return format.replace("%1$s", String(start)).replace("%2$s", String(end)).replace("%3$s", String(total));
869 }
870
871 function formatInfo(format, start, end, total) {
872 if (typeof wp !== "undefined" && wp.i18n && typeof wp.i18n.sprintf === "function") {
873 return wp.i18n.sprintf(format, String(start), String(end), String(total));
874 }
875 return format.replace("%1$s", String(start)).replace("%2$s", String(end)).replace("%3$s", String(total));
876 }
877
878 /**
879 * @param {HTMLElement} root .wpsubs-pager[data-wpsubs-pager]
880 */
881 function WPSubsPager(root) {
882 this.root = root;
883 this.scope = root.getAttribute("data-wpsubs-pager-scope")
884 ? document.querySelector(root.getAttribute("data-wpsubs-pager-scope"))
885 : root.closest("[data-wpsubs-pager-scope]") || this.findScope();
886 this.rowSelector = root.getAttribute("data-pager-row-selector") || "tbody tr";
887 this.dateCol = readInt(root, "data-date-col", -1);
888 this.dateAdv = null;
889 this.perPageAdv = null;
890 this.linkMode = root.getAttribute("data-link-mode") || "url";
891 this.infoFormat = root.getAttribute("data-info-format") || "";
892 this.card = root.closest(".subscrpt-card, .wpsubs-table-card, [data-wpsubs-pager-card]") || root.parentElement;
893 this.rows = this.collectRows();
894 this.page = readInt(root, "data-current", 1);
895 this.total = readInt(root, "data-total", 1);
896 this.perPage = readInt(root, "data-per-page", 10);
897 this.emptyRow = null;
898 this.colSpan = 1;
899 this._bind();
900 }
901
902 WPSubsPager.prototype.findScope = function () {
903 // Default: closest <table> within the same card.
904 var table = this.root.closest(".subscrpt-card, .wpsubs-table-card, [data-wpsubs-pager-card]");
905 if (table) {
906 var t = table.querySelector("table");
907 if (t) return t;
908 }
909 return this.root.parentElement;
910 };
911
912 WPSubsPager.prototype.collectRows = function () {
913 if (!this.scope) return [];
914 return Array.prototype.slice.call(this.scope.querySelectorAll(this.rowSelector));
915 };
916
917 WPSubsPager.prototype._bind = function () {
918 var self = this;
919 if (this.card) {
920 this.dateAdv = this.card.querySelector(".subscrpt-filter-date");
921 this.perPageAdv = this.card.querySelector(".subscrpt-filter-perpage");
922 var table = this.card.querySelector("table");
923 if (table) {
924 this.colSpan = table.querySelectorAll("thead th").length || 1;
925 }
926 }
927
928 this.root.addEventListener("click", function (e) {
929 var btn = e.target.closest(".wpsubs-pagination__btn");
930 if (!btn || !self.root.contains(btn)) return;
931 if (btn.classList.contains("wpsubs-pagination__btn--ellipsis")) return;
932 if (btn.classList.contains("wpsubs-pagination__btn--disabled")) return;
933 if (btn.classList.contains("wpsubs-pagination__btn--active")) return;
934 var page = parseInt(btn.getAttribute("data-page"), 10);
935 if (!isNaN(page) && page !== self.page) {
936 self.page = page;
937 self.render();
938 }
939 });
940
941 if (this.dateAdv) {
942 this.dateAdv.addEventListener("wpsubs:select", function () {
943 self.page = 1;
944 self.render();
945 });
946 }
947 if (this.perPageAdv) {
948 this.perPageAdv.addEventListener("wpsubs:select", function () {
949 var input = self.perPageAdv.querySelector('input[type="hidden"]');
950 self.perPage = parseInt(input ? input.value : self.perPage, 10) || self.perPage;
951 self.page = 1;
952 self.render();
953 });
954 }
955 };
956
957 WPSubsPager.prototype._monthKey = function (tr) {
958 if (!tr || this.dateCol < 0) return "";
959 var cell = tr.children[this.dateCol];
960 if (!cell) return "";
961 var d = new Date(cell.textContent.replace(" - ", " ").trim());
962 if (isNaN(d.getTime())) return "";
963 return d.getFullYear() + "-" + ("0" + (d.getMonth() + 1)).slice(-2);
964 };
965
966 WPSubsPager.prototype._injectMonthOptions = function (rows) {
967 if (!this.dateAdv || this.dateCol < 0) return;
968 var menu = this.dateAdv.querySelector(".wpsubs-adv-select__menu");
969 if (!menu) return;
970 // Remove previously injected month options (any non-original item).
971 var injected = menu.querySelectorAll("[data-pager-month]");
972 injected.forEach(function (n) {
973 n.parentNode.removeChild(n);
974 });
975 var seen = {};
976 var months = [];
977 rows.forEach(function (tr) {
978 var key = this._monthKey(tr);
979 if (key && !seen[key]) {
980 seen[key] = true;
981 months.push(key);
982 }
983 }, this);
984 months
985 .sort()
986 .reverse()
987 .forEach(function (key) {
988 var parts = key.split("-");
989 var btn = document.createElement("button");
990 btn.type = "button";
991 btn.setAttribute("data-pager-month", "1");
992 btn.className = "wpsubs-adv-select__item";
993 btn.setAttribute("data-value", key);
994 btn.setAttribute("role", "option");
995 var span = document.createElement("span");
996 span.className = "wpsubs-adv-select__item-label";
997 span.textContent = MONTHS[parseInt(parts[1], 10) - 1] + " " + parts[0];
998 btn.appendChild(span);
999 menu.appendChild(btn);
1000 }, this);
1001 };
1002
1003 WPSubsPager.prototype._advValue = function (adv) {
1004 if (!adv) return "";
1005 var input = adv.querySelector('input[type="hidden"]');
1006 return input ? input.value : "";
1007 };
1008
1009 WPSubsPager.prototype._filtered = function () {
1010 var dm = this._advValue(this.dateAdv);
1011 if (!dm || this.dateCol < 0) return this.rows;
1012 return this.rows.filter(function (tr) {
1013 return this._monthKey(tr) === dm;
1014 }, this);
1015 };
1016
1017 WPSubsPager.prototype._makeBtn = function (label, target, isDisabled, isActive) {
1018 if (isDisabled) {
1019 return el("span", "wpsubs-pagination__btn wpsubs-pagination__btn--disabled", {
1020 "aria-hidden": "true",
1021 html: label,
1022 });
1023 }
1024 if (isActive) {
1025 return el("span", "wpsubs-pagination__btn wpsubs-pagination__btn--active", {
1026 "aria-current": "page",
1027 text: label,
1028 });
1029 }
1030 if (this.linkMode === "cb") {
1031 return el("button", "wpsubs-pagination__btn", {
1032 type: "button",
1033 "data-page": String(target),
1034 text: label,
1035 });
1036 }
1037 return el("a", "wpsubs-pagination__btn", { "data-page": String(target), text: label });
1038 };
1039
1040 WPSubsPager.prototype._makeEllipsis = function () {
1041 return el("span", "wpsubs-pagination__btn wpsubs-pagination__btn--ellipsis", {
1042 "aria-hidden": "true",
1043 text: "",
1044 });
1045 };
1046
1047 WPSubsPager.prototype._buildPager = function (pages) {
1048 var frag = document.createDocumentFragment();
1049 frag.appendChild(this._makeBtn("", this.page - 1, this.page <= 1, false));
1050 var range = pageRange(this.page, pages);
1051 for (var i = 0; i < range.length; i++) {
1052 var p = range[i];
1053 frag.appendChild(p === null ? this._makeEllipsis() : this._makeBtn(String(p), p, false, p === this.page));
1054 }
1055 frag.appendChild(this._makeBtn("", this.page + 1, this.page >= pages, false));
1056 return frag;
1057 };
1058
1059 WPSubsPager.prototype._updateInfo = function (start, end, total) {
1060 if (!this.infoFormat) return;
1061 var span = this.root.querySelector(".wpsubs-pagination__info");
1062 if (!span) return;
1063 span.textContent = formatInfo(this.infoFormat, total ? start + 1 : 0, Math.min(end, total), total);
1064 };
1065
1066 WPSubsPager.prototype._showEmpty = function (show) {
1067 if (!this.scope) return;
1068 if (show) {
1069 if (!this.emptyRow) {
1070 var tr = document.createElement("tr");
1071 tr.className = "subscrpt-empty-row";
1072 var td = document.createElement("td");
1073 td.colSpan = this.colSpan;
1074 td.style.textAlign = "center";
1075 td.style.padding = "18px";
1076 td.textContent =
1077 typeof wp !== "undefined" && wp.i18n && typeof wp.i18n.__ === "function"
1078 ? wp.i18n.__("No matching records.", "subscription")
1079 : "No matching records.";
1080 tr.appendChild(td);
1081 if (this.scope.tagName === "TBODY" || this.scope.tagName === "TABLE") {
1082 this.scope.appendChild(tr);
1083 } else {
1084 this.scope.appendChild(tr);
1085 }
1086 this.emptyRow = tr;
1087 }
1088 this.emptyRow.style.display = "";
1089 } else if (this.emptyRow) {
1090 this.emptyRow.style.display = "none";
1091 }
1092 };
1093
1094 WPSubsPager.prototype.render = function () {
1095 var list = this._filtered();
1096 var pages = Math.max(1, Math.ceil(list.length / this.perPage));
1097 if (this.page > pages) this.page = pages;
1098 var start = (this.page - 1) * this.perPage;
1099 var end = start + this.perPage;
1100 this.total = pages;
1101
1102 this.rows.forEach(function (tr) {
1103 tr.style.display = "none";
1104 });
1105 list.slice(start, end).forEach(function (tr) {
1106 tr.style.display = "";
1107 });
1108
1109 this._showEmpty(list.length === 0);
1110
1111 // Rebuild just the nav portion, leave the root attributes alone.
1112 var existingNav = this.root.querySelector(".wpsubs-pagination__nav");
1113 if (existingNav) existingNav.parentNode.removeChild(existingNav);
1114 var nav = el("div", "wpsubs-pagination__nav");
1115 nav.appendChild(this._buildPager(pages));
1116 this.root.appendChild(nav);
1117
1118 // Keep current/total data-* in sync so subsequent re-inits read the right state.
1119 this.root.setAttribute("data-current", String(this.page));
1120 this.root.setAttribute("data-total", String(pages));
1121
1122 this._updateInfo(start, end, list.length);
1123
1124 this.root.dispatchEvent(
1125 new CustomEvent("wpsubs:pager:change", {
1126 bubbles: true,
1127 detail: { page: this.page, total: list.length, pages: pages, perPage: this.perPage },
1128 }),
1129 );
1130 };
1131
1132 WPSubsPager.prototype.refresh = function () {
1133 // External hook: re-collect rows (Pro renders new content) and re-paginate.
1134 this.rows = this.collectRows();
1135 this._injectMonthOptions(this.rows);
1136 this.render();
1137 };
1138
1139 WPSubsPager.prototype.goTo = function (page) {
1140 page = parseInt(page, 10);
1141 if (isNaN(page)) return;
1142 var pages = Math.max(1, Math.ceil(this.rows.length / this.perPage));
1143 this.page = Math.max(1, Math.min(pages, page));
1144 this.render();
1145 };
1146
1147 /**
1148 * Initialise all un-initialised .wpsubs-pager elements under root.
1149 * Skips pagers in `link-mode="url"` — those are server-rendered (e.g. the
1150 * subscriptions list page) and own all the data they need; touching them
1151 * from JS would re-derive totals from the current page's rows and clobber
1152 * the correct pagination (e.g. show only "1" of 3 pages).
1153 *
1154 * @param {Document|HTMLElement} [root]
1155 */
1156 function init(root) {
1157 (root || document)
1158 .querySelectorAll(".wpsubs-pager[data-wpsubs-pager][data-link-mode='cb']:not([data-pager-init])")
1159 .forEach(function (el) {
1160 el.setAttribute("data-pager-init", "1");
1161 var pager = new WPSubsPager(el);
1162 pager._injectMonthOptions(pager.rows);
1163 pager.render();
1164 });
1165 }
1166
1167 if (document.readyState === "loading") {
1168 document.addEventListener("DOMContentLoaded", function () {
1169 init();
1170 });
1171 } else {
1172 init();
1173 }
1174
1175 // Public API
1176 window.WPSubsPager = {
1177 init: init,
1178 /**
1179 * Re-collect rows for every initialised cb-mode pager under root and
1180 * re-render. Useful when a callback injects new rows (e.g. Pro activities).
1181 */
1182 refresh: function (root) {
1183 (root || document).querySelectorAll(".wpsubs-pager[data-link-mode='cb'][data-pager-init]").forEach(function (el) {
1184 el.removeAttribute("data-pager-init");
1185 init(el.parentNode || document);
1186 });
1187 },
1188 /**
1189 * Force a single pager to re-collect rows (Pro hook) and re-render.
1190 * Skips url-mode pagers.
1191 */
1192 refreshOne: function (el) {
1193 if (!el) return;
1194 if (el.getAttribute("data-link-mode") !== "cb") return;
1195 el.removeAttribute("data-pager-init");
1196 init(el.parentNode || document);
1197 },
1198 };
1199 })();
1200