1/** 2 * BM Load More JavaScript 3 * 4 * Handles load more functionality with accessibility and URL management. 5 * 6 * @package BMLoadMore 7 */ 8 9(function() { 10 'use strict'; 11 12 // Check if required data is available. 13 if (typeof bmLoadMore === 'undefined') { 14 return; 15 } 16 17 const LoadMore = { 18 container: null, 19 button: null, 20 currentPage: parseInt(bmLoadMore.page, 10) || 1, 21 maxPages: 0, 22 isLoading: false, 23 context: bmLoadMore.context, 24 endpoint: bmLoadMore.endpoint, 25 nonce: bmLoadMore.nonce, 26 i18n: bmLoadMore.i18n, 27 28 /** 29 * Initialize load more functionality. 30 */ 31 init: function() { 32 // Check if we should auto-inject button (homepage/frontpage or archive). 33 const shouldAutoInject = this.shouldAutoInjectButton(); 34 35 if (shouldAutoInject) { 36 // Auto-inject button into #content container. 37 this.autoInjectButton(); 38 } 39 40 // Find existing button or injected button. 41 this.container = document.querySelector('.bm-load-more-container'); 42 this.button = document.querySelector('.bm-load-more-button'); 43 44 if (!this.button) { 45 return; 46 } 47 48 // Get max pages from localized script data. 49 if (bmLoadMore.maxPages && bmLoadMore.maxPages > 0) { 50 this.maxPages = parseInt(bmLoadMore.maxPages, 10); 51 } 52 53 // Also check data attribute on posts container as fallback. 54 const postsContainer = document.querySelector('.posts-container, .archive-posts, main .posts, #main .posts'); 55 if (postsContainer && !this.maxPages) { 56 const maxPagesAttr = postsContainer.getAttribute('data-max-pages'); 57 if (maxPagesAttr) { 58 this.maxPages = parseInt(maxPagesAttr, 10); 59 } 60 } 61 62 // Bind events. 63 this.button.addEventListener('click', this.handleClick.bind(this)); 64 65 // Keyboard support. 66 this.button.addEventListener('keydown', this.handleKeydown.bind(this)); 67 68 // Update initial state. 69 this.updateButtonState(); 70 }, 71 72 /** 73 * Check if button should be auto-injected. 74 * Only on homepage/frontpage or archive pages. 75 * 76 * @return {boolean} True if should auto-inject. 77 */ 78 shouldAutoInjectButton: function() { 79 // Check context type from localized data. 80 if (bmLoadMore.context && bmLoadMore.context.type) { 81 const contextType = bmLoadMore.context.type; 82 // Homepage, frontpage, or any archive type. 83 return contextType === 'home' || 84 contextType === 'author' || 85 contextType === 'category' || 86 contextType === 'tag' || 87 contextType === 'taxonomy' || 88 contextType === 'post_type_archive' || 89 contextType === 'date' || 90 contextType === 'search'; 91 } 92 return false; 93 }, 94 95 /** 96 * Auto-inject button into #content container. 97 */ 98 autoInjectButton: function() { 99 // Find #content container. 100 const contentContainer = document.getElementById('content'); 101 if (!contentContainer) { 102 return; 103 } 104 105 // Check if button already exists. 106 if (document.querySelector('.bm-load-more-container')) { 107 return; 108 } 109 110 // Check if there are more pages available. 111 const maxPages = bmLoadMore.maxPages || 0; 112 const currentPage = bmLoadMore.page || 1; 113 const postsPerPage = bmLoadMore.postsPerPage || 10; 114 const currentPostCount = bmLoadMore.currentPostCount || 0; 115 116 // If maxPages is 0 (no_found_rows is true), check if we have a full page of results. 117 if (Number(maxPages) === 0) { 118 // Show button if we have a full page of results (suggests more might exist). compare integer values 119 if ( Number(currentPostCount) < Number(postsPerPage) ) { 120 // Less than full page, no more results. 121 return; 122 } 123 // Full page of results, show button (we'll hide it if next load returns fewer). 124 } else { 125 // Normal pagination - check max pages. 126 if ( Number(currentPage) >= Number(maxPages) ) { 127 // No more pages, don't inject button. 128 return; 129 } 130 } 131 132 // Create button markup. 133 const buttonMarkup = this.getButtonMarkup(); 134 135 // Insert at the end of #content. 136 contentContainer.insertAdjacentHTML('beforeend', buttonMarkup); 137 }, 138 139 /** 140 * Get button markup HTML. 141 * 142 * @return {string} Button HTML markup. 143 */ 144 getButtonMarkup: function() {
145 const text = bmLoadMore.i18n.loadMore || 'Load More'; 146 const loadingText = bmLoadMore.i18n.loading || 'Loading...'; 147 148 return '<div class="bm-load-more-container">' + 149 '<button type="button" class="bm-load-more-button" aria-label="' + this.escapeHtml(text) + '" aria-busy="false">' + 150 '<span class="button-text">' + this.escapeHtml(text) + '</span>' + 151 '<span class="button-loading" aria-hidden="true" style="display: none;">' + this.escapeHtml(loadingText) + '</span>' + 152 '</button>' + 153 '</div>'; 154 }, 155 156 /** 157 * Escape HTML to prevent XSS. 158 * 159 * @param {string} text Text to escape. 160 * @return {string} Escaped text. 161 */ 162 escapeHtml: function(text) { 163 const div = document.createElement('div'); 164 div.textContent = text; 165 return div.innerHTML; 166 }, 167 168 /** 169 * Handle button click. 170 * 171 * @param {Event} e Click event. 172 */ 173 handleClick: function(e) { 174 e.preventDefault(); 175 176 if (this.isLoading || !this.hasMore()) { 177 return; 178 } 179 180 this.loadMore(); 181 }, 182 183 /** 184 * Handle keyboard events. 185 * 186 * @param {Event} e Keyboard event. 187 */ 188 handleKeydown: function(e) { 189 // Enter or Space key. 190 if (e.key === 'Enter' || e.key === ' ') { 191 if (e.key === ' ') { 192 e.preventDefault(); 193 } 194 this.handleClick(e); 195 } 196 }, 197 198 /** 199 * Check if there are more pages to load. 200 * 201 * @return {boolean} True if more pages available. 202 */ 203 hasMore: function() { 204 if (this.maxPages > 0) { 205 return this.currentPage < this.maxPages; 206 } 207 // If max pages is 0 (no_found_rows), we'll determine based on results. 208 // This will be handled in handleSuccess when we see how many posts were returned. 209 return true; 210 }, 211 212 /** 213 * Load more posts. 214 */ 215 loadMore: function() { 216 if (this.isLoading) { 217 return; 218 } 219 220 this.isLoading = true; 221 this.currentPage += 1; 222 223 // Clear any existing sessionStorage for this page (in case of retry). 224 const urlKey = 'bm_load_more_url_updated_' + this.currentPage; 225 sessionStorage.removeItem(urlKey); 226 227 // Update button state. 228 this.setLoadingState(true); 229 230 // Prepare form data (POST to current page like Jetpack). 231 const formData = new FormData(); 232 formData.append('bm_load_more', '1'); 233 formData.append('nonce', this.nonce); 234 formData.append('context', JSON.stringify(this.context)); 235 formData.append('page', this.currentPage); 236 237 // Debug logging. // @todo remove after testing on staging 238 console.log('BM Load More - Endpoint:', this.endpoint); 239 console.log('BM Load More - Context:', this.context); 240 console.log('BM Load More - Page:', this.currentPage); 241 242 // Make POST request to current page. 243 fetch(this.endpoint, { 244 method: 'POST', 245 body: formData, 246 credentials: 'same-origin', 247 }) 248 .then(response => { 249 if (!response.ok) { 250 throw new Error('Network response was not ok'); 251 } 252 return response.json(); 253 }) 254 .then(response => { 255 // Handle wp_send_json_success format: {success: true, data: {...}} 256 if (response && response.success && response.data) { 257 this.handleSuccess(response.data); 258 } else if (response && !response.success) { 259 // Handle wp_send_json_error format: {success: false, data: {...}} 260 const errorMessage = (response.data && response.data.message) 261 ? response.data.message 262 : 'Error loading posts. Please try again.'; 263 throw new Error(errorMessage); 264 } else { 265 // Fallback: assume response is data directly. 266 this.handleSuccess(response); 267 } 268 }) 269 .catch(error => { 270 this.handleError(error); 271 }); 272 }, 273 274 /** 275 * Handle successful response. 276 * 277 * @param {Object} data Response data. 278 */ 279 handleSuccess: function(data) { 280 this.isLoading = false; 281 this.setLoadingState(false); 282 283 // Check if we have HTML content. 284 if (data.html && data.html.trim() !== '') { 285 // Insert HTML markup. 286 const insertedElements = this.insertHTML(data.html); 287 288 // Update max pages from response. 289 if (data.max_pages !== undefined) { 290 this.maxPages = parseInt(data.max_pages, 10); 291 } 292 293 // Count posts in the HTML for announcement and to determine if more exist. 294 const tempDiv = document.createElement('div'); 295 tempDiv.innerHTML = data.html; 296 const postCount = tempDiv.querySelectorAll('article, .post, [class*="post-"]').length; 297 const postsPerPage = bmLoadMore.postsPerPage || 10; 298 299 // If maxPages is 0 (no_found_rows), check if we got a full page of results. 300 // If we got fewer than posts_per_page, we've reached the end. 301 if (this.maxPages === 0 && postCount < postsPerPage) { 302 // Fewer results than posts_per_page means no more results. 303 // Set maxPages to current page to prevent further loads. 304 this.maxPages = this.currentPage; 305 } 306 307 // Set up scroll-based URL update (like Jetpack). 308 // URL will update when user scrolls to the new content. 309 // Use the wrapper's unique ID for reliable detection. 310 const wrapperId = 'archive__content-' + this.currentPage; 311 setTimeout(() => { 312 const wrapperElement = document.getElementById(wrapperId); 313 if (wrapperElement) { 314 this.setupScrollBasedURLUpdate(wrapperElement, this.currentPage); 315 } 316 }, 100); 317 318 // Announce to screen readers. 319 this.announceToScreenReader(
320 this.i18n.loadMore + ': ' + postCount + ' ' + 321 (postCount === 1 ? 'post loaded' : 'posts loaded') 322 ); 323 } else { 324 // No HTML content - definitely no more results. 325 // Update max pages if provided, otherwise set to current page. 326 if (data.max_pages !== undefined) { 327 this.maxPages = parseInt(data.max_pages, 10); 328 } else { 329 // No content returned, we've reached the end. 330 this.maxPages = this.currentPage; 331 } 332 } 333 334 // Update button state. 335 this.updateButtonState(); 336 337 // Trigger custom event. 338 const event = new CustomEvent('bmLoadMoreLoaded', { 339 detail: { 340 page: this.currentPage, 341 html: data.html || '', 342 hasMore: data.has_more || false, 343 }, 344 }); 345 document.dispatchEvent(event); 346 }, 347 348 /** 349 * Handle error. 350 * 351 * @param {Error} error Error object. 352 */ 353 handleError: function(error) { 354 this.isLoading = false; 355 this.setLoadingState(false); 356 this.currentPage -= 1; // Revert page increment. 357 358 // Show error message. 359 this.showError(this.i18n.error); 360 361 // Announce to screen readers. 362 this.announceToScreenReader(this.i18n.error); 363 364 // Trigger custom event. 365 const event = new CustomEvent('bmLoadMoreError', { 366 detail: { 367 error: error, 368 }, 369 }); 370 document.dispatchEvent(event); 371 }, 372 373 /** 374 * Insert HTML markup into the page. 375 * 376 * @param {string} html HTML markup string. 377 * @return {Array} Array of inserted elements. 378 */ 379 insertHTML: function(html) { 380 // Find the bm-load-more-container to insert before it.
381 const loadMoreContainer = document.querySelector('.bm-load-more-container'); 382 383 if (!loadMoreContainer) { 384 console.warn('BM Load More: Load more container not found'); 385 return null; 386 } 387 388 // Create temporary container for parsing HTML. 389 const tempDiv = document.createElement('div'); 390 tempDiv.innerHTML = html; 391 392 // Track inserted elements. 393 const insertedElements = []; 394 395 // Insert all content from tempDiv just before the load more container. 396 while (tempDiv.firstChild) { 397 const insertedElement = loadMoreContainer.parentNode.insertBefore(tempDiv.firstChild, loadMoreContainer); 398 insertedElements.push(insertedElement); 399 } 400 401 // Trigger lazy loading if available. 402 if (typeof window.lazyLoadInstance !== 'undefined') { 403 window.lazyLoadInstance.update(); 404 } 405 406 return insertedElements; 407 }, 408 409 /** 410 * Set up scroll-based URL update (like Jetpack). 411 * URL will update when user scrolls to the new content. 412 * 413 * @param {Element} targetElement First element of newly loaded content. 414 * @param {number} page Page number to update URL to. 415 */ 416 setupScrollBasedURLUpdate: function(targetElement, page) { 417 if (!targetElement) { 418 console.warn('BM Load More: No target element for URL update'); 419 return; 420 } 421 422 // Check if URL was already updated for this page. 423 const urlKey = 'bm_load_more_url_updated_' + page; 424 if (sessionStorage.getItem(urlKey)) { 425 console.log('BM Load More: URL already updated for page', page); 426 return; // Already updated for this page. 427 } 428 429 // Check if element is already visible when inserted. 430 const checkIfVisible = () => { 431 const rect = targetElement.getBoundingClientRect(); 432 const viewportHeight = window.innerHeight || document.documentElement.clientHeight; 433 // Element is visible if it's within the viewport (with some margin). 434 const isVisible = rect.top < viewportHeight && rect.bottom > 0; 435 return isVisible; 436 }; 437 438 // Function to update URL and mark as done. 439 const performURLUpdate = () => { 440 if (!sessionStorage.getItem(urlKey)) { 441 console.log('BM Load More: Attempting URL update to page', page); 442 // Update URL first, then mark as done only if successful. 443 const currentUrl = window.location.href; 444 this.updateURL(page); 445 // Check if URL actually changed (give it a moment). 446 setTimeout(() => { 447 const newUrl = window.location.href; 448 if (newUrl !== currentUrl) { 449 console.log('BM Load More: URL successfully updated to:', newUrl); 450 sessionStorage.setItem(urlKey, 'true'); 451 } else { 452 console.warn('BM Load More: URL update may have failed. Current URL:', currentUrl); 453 } 454 }, 50); 455 return true; 456 } else { 457 console.log('BM Load More: URL update already attempted for page', page); 458 return false; 459 } 460 }; 461 462 // If already visible, update URL immediately. 463 if (checkIfVisible()) { 464 console.log('BM Load More: Element already visible, updating URL immediately'); 465 performURLUpdate(); 466 return; 467 } 468 469 // Store observer and scroll handler references for cleanup. 470 let observer = null; 471 let scrollHandler = null; 472 473 // Fallback: If user scrolls manually, check on scroll. 474 scrollHandler = () => { 475 if (checkIfVisible()) { 476 if (performURLUpdate()) { 477 // Clean up after successful update. 478 window.removeEventListener('scroll', scrollHandler); 479 if (observer) { 480 observer.disconnect(); 481 } 482 } 483 } 484 }; 485 486 // Use Intersection Observer to detect when element comes into view. 487 if ('IntersectionObserver' in window) { 488 observer = new IntersectionObserver( 489 (entries) => { 490 entries.forEach((entry) => { 491 if (entry.isIntersecting) { 492 // Element is now visible, update URL. 493 if (performURLUpdate()) { 494 // Stop observing once URL is updated. 495 observer.unobserve(entry.target); 496 window.removeEventListener('scroll', scrollHandler); 497 observer.disconnect(); 498 } 499 } 500 }); 501 }, 502 { 503 // Trigger when any part of element is visible. 504 threshold: 0, 505 // Start checking when element is 200px from viewport. 506 rootMargin: '200px 0px', 507 } 508 ); 509 510 // Start observing the wrapper element. 511 observer.observe(targetElement); 512 } 513 514 // Always add scroll listener as fallback (in addition to IntersectionObserver). 515 window.addEventListener('scroll', scrollHandler, { passive: true }); 516 }, 517 518 /** 519 * Update URL without page reload. 520 * Handles WordPress permalink structure (/page/2/) and query parameters. 521 * 522 * @param {number} page Page number. 523 */ 524 updateURL: function(page) { 525 try { 526 const currentUrl = window.location.href; 527 const url = new URL(currentUrl); 528 const pathname = url.pathname; 529 530 if (page <= 1) { 531 // For page 1, remove pagination from URL. 532 // Remove /page/X/ from pathname. 533 const newPathname = pathname.replace(/\/page\/\d+\/?$/, ''); 534 url.pathname = newPathname || '/'; 535 536 // Also remove paged from query params if present. 537 url.searchParams.delete('paged'); 538 539 const newUrl = url.toString(); 540 console.log('BM Load More: Updating URL to page 1'); 541 console.log('BM Load More: From:', currentUrl); 542 console.log('BM Load More: To:', newUrl); 543 544 window.history.replaceState( 545 { page: 1 }, 546 '', 547 newUrl 548 ); 549 return; 550 } 551 552 // Check if URL already has /page/X/ pattern. 553 const pagePattern = /\/page\/\d+\/?$/; 554 let newPathname; 555 556 if (pagePattern.test(pathname)) { 557 // Replace existing /page/X/ with new page number. 558 newPathname = pathname.replace(pagePattern, '/page/' + page + '/'); 559 } else { 560 // Add /page/X/ to pathname. 561 // Ensure pathname ends with / before adding page. 562 const cleanPath = pathname.endsWith('/') ? pathname : pathname + '/'; 563 newPathname = cleanPath + 'page/' + page + '/'; 564 } 565 566 url.pathname = newPathname; 567 568 // Keep existing query parameters (they're preserved automatically in URL object). 569 // Remove paged from query params if it exists (we use pathname instead). 570 url.searchParams.delete('paged'); 571 572 const newUrl = url.toString(); 573 console.log('BM Load More: Updating URL to page', page); 574 console.log('BM Load More: From:', currentUrl); 575 console.log('BM Load More: To:', newUrl); 576 console.log('BM Load More: Pathname changed from', pathname, 'to', newPathname); 577 578 // Update URL without reload. 579 window.history.pushState( 580 { page: page }, 581 '', 582 newUrl 583 ); 584 585 // Verify the URL was actually updated. 586 const verifyUrl = window.location.href; 587 if (verifyUrl === newUrl) { 588 console.log('BM Load More: URL successfully updated'); 589 } else { 590 console.warn('BM Load More: URL may not have updated. Expected:', newUrl, 'Got:', verifyUrl); 591 } 592 } catch (error) { 593 console.error('BM Load More: Error updating URL:', error); 594 } 595 }, 596 597 /** 598 * Set loading state. 599 * 600 * @param {boolean} loading Whether loading. 601 */ 602 setLoadingState: function(loading) { 603 if (!this.button) { 604 return; 605 } 606 607 const buttonText = this.button.querySelector('.button-text'); 608 const buttonLoading = this.button.querySelector('.button-loading'); 609 610 if (loading) { 611 this.button.setAttribute('aria-busy', 'true'); 612 this.button.disabled = true; 613 if (buttonText) buttonText.style.display = 'none'; 614 if (buttonLoading) buttonLoading.style.display = 'inline'; 615 } else { 616 this.button.setAttribute('aria-busy', 'false'); 617 this.button.disabled = false;
618 if (buttonText) buttonText.style.display = 'inline'; 619 if (buttonLoading) buttonLoading.style.display = 'none'; 620 } 621 }, 622 623 /** 624 * Update button state based on available pages. 625 */ 626 updateButtonState: function() { 627 if (!this.button || !this.hasMore()) { 628 if (this.button) { 629 this.button.style.display = 'none'; 630 } 631 } 632 }, 633 634 /** 635 * Show error message. 636 * 637 * @param {string} message Error message. 638 */ 639 showError: function(message) { 640 if (this.container) { 641 // Remove existing error message. 642 const existingError = this.container.querySelector('.bm-load-more-error'); 643 if (existingError) { 644 existingError.remove(); 645 } 646 647 const errorMsg = document.createElement('p'); 648 errorMsg.className = 'bm-load-more-error'; 649 errorMsg.textContent = message; 650 errorMsg.setAttribute('role', 'alert'); 651 errorMsg.setAttribute('aria-live', 'assertive'); 652 this.container.appendChild(errorMsg); 653 654 // Remove error message after 5 seconds. 655 setTimeout(() => { 656 if (errorMsg.parentNode) { 657 errorMsg.remove(); 658 } 659 }, 5000); 660 } 661 }, 662 663 /** 664 * Announce to screen readers. 665 * 666 * @param {string} message Message to announce. 667 */ 668 announceToScreenReader: function(message) { 669 // Create or get live region. 670 let liveRegion = document.getElementById('bm-load-more-announce'); 671 if (!liveRegion) { 672 liveRegion = document.createElement('div'); 673 liveRegion.id = 'bm-load-more-announce'; 674 liveRegion.setAttribute('role', 'status'); 675 liveRegion.setAttribute('aria-live', 'polite'); 676 liveRegion.setAttribute('aria-atomic', 'true'); 677 liveRegion.className = 'screen-reader-text'; 678 document.body.appendChild(liveRegion); 679 } 680 681 liveRegion.textContent = message; 682 683 // Clear after announcement. 684 setTimeout(() => { 685 liveRegion.textContent = ''; 686 }, 1000); 687 }, 688 }; 689 690 // Initialize when DOM is ready. 691 if (document.readyState === 'loading') { 692 document.addEventListener('DOMContentLoaded', function() {
693 LoadMore.init(); 694 }); 695 } else { 696 LoadMore.init(); 697 } 698})(); 699
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.