1/** 2 * Query Filter Block - Interactivity API Store 3 * 4 * Provides live search and taxonomy filtering for posts 5 */ 6 7import { store, getContext, getElement } from '@wordpress/interactivity'; 8 9// Debounce utility 10let debounceTimer = null; 11const debounce = (callback, delay = 300) => { 12 clearTimeout(debounceTimer); 13 debounceTimer = setTimeout(callback, delay); 14}; 15 16// Decode HTML entities (for REST API responses that contain entities like ’) 17const decodeHtmlEntities = (str) => { 18 if (!str) return ''; 19 const txt = document.createElement('textarea'); 20 txt.innerHTML = str; 21 return txt.value; 22}; 23 24// Simple HTML escaping 25const escapeHtml = (str) => { 26 if (!str) return ''; 27 const div = document.createElement('div'); 28 div.textContent = str; 29 return div.innerHTML; 30}; 31 32// Decode entities then escape for safe HTML output 33const safeText = (str) => { 34 return escapeHtml(decodeHtmlEntities(str)); 35}; 36 37// Strip HTML tags and decode entities 38const stripHtml = (html) => { 39 const tmp = document.createElement('div'); 40 tmp.innerHTML = html; 41 return tmp.textContent || tmp.innerText || ''; 42}; 43 44// Format date 45const formatDate = (dateStr) => { 46 const date = new Date(dateStr); 47 return date.toLocaleDateString('en-US', { 48 year: 'numeric', 49 month: 'long', 50 day: 'numeric', 51 }); 52}; 53 54// Card rendering function 55const renderCard = (post) => { 56 const termsHtml = post.terms?.length 57 ? `<div class="query-filter__card-terms"> 58 ${post.terms.map((term) => `<span class="oni-pill">${safeText(term.name)}</span>`).join('')} 59 </div>` 60 : ''; 61 62 const imageHtml = post.featuredImage 63 ? `<a href="${post.link}" class="query-filter__card-image blog-archive-card__image"> 64 <img src="${post.featuredImage}" alt="" loading="lazy" /> 65 </a>` 66 : '<div class="query-filter__card-placeholder blog-archive-card__placeholder"></div>'; 67 68 const authorHtml = post.author 69 ? `<p class="query-filter__card-author">${safeText(post.authorPrefix || 'By')} ${safeText(post.author)}</p>` 70 : ''; 71 72 return ` 73 <article class="query-filter__card blog-archive-card"> 74 ${imageHtml} 75 ${termsHtml} 76 <h3 class="query-filter__card-title"> 77 <a href="${post.link}">${safeText(post.title)}</a> 78 </h3> 79 ${authorHtml} 80 <time class="query-filter__card-date" datetime="${post.dateISO}"> 81 ${safeText(post.date)} 82 </time> 83 <p class="query-filter__card-excerpt">${safeText(post.excerpt)}</p> 84 <a href="${post.link}" class="query-filter__card-link blog-archive-card__link"> 85 View article 86 </a> 87 </article> 88 `; 89}; 90 91// Map REST API response to our post format 92const mapRestPost = (post) => { 93 const featuredMedia = post._embedded?.['wp:featuredmedia']?.[0]; 94 const terms = post._embedded?.['wp:term']?.[0] || []; 95 96 return { 97 id: post.id, 98 title: post.title?.rendered || '', 99 link: post.link, 100 excerpt: stripHtml(post.excerpt?.rendered || '').substring(0, 200) + '...', 101 date: post.event_date_display || formatDate(post.date), 102 dateISO: post.event_date_iso || post.date, 103 featuredImage: featuredMedia?.source_url || '', 104 terms: terms.map((term) => ({ 105 id: term.id, 106 name: term.name, 107 link: term.link, 108 })), 109 author: post.acf_speaker_name || post.acf_author_name || post._embedded?.author?.[0]?.name || '', 110 authorPrefix: post.acf_speaker_name ? 'With' : 'By', 111 }; 112}; 113 114// Update the DOM grid with new posts 115const updateGrid = (block, posts) => { 116 const grid = block.querySelector('.query-filter__grid'); 117 const noResults = block.querySelector('.query-filter__no-results'); 118 119 if (!grid) return; 120 121 // Clear existing cards 122 grid.innerHTML = ''; 123 124 // Render new cards 125 posts.forEach((post) => { 126 grid.insertAdjacentHTML('beforeend', renderCard(post)); 127 }); 128 129 // Update no results visibility 130 if (noResults) { 131 if (posts.length === 0) { 132 noResults.removeAttribute('hidden'); 133 noResults.classList.add('query-filter__no-results--visible'); 134 } else { 135 noResults.setAttribute('hidden', ''); 136 noResults.classList.remove('query-filter__no-results--visible'); 137 } 138 } 139}; 140 141// Update term button states 142const updateTermButtons = (block, selectedTerms) => { 143 const allButton = block.querySelector('.query-filter__term-btn[data-wp-on--click="actions.clearTerms"]'); 144 const termButtons = block.querySelectorAll('.query-filter__term-btn[data-term-id]'); 145 146 if (allButton) { 147 const isAllActive = !selectedTerms || selectedTerms.length === 0; 148 allButton.classList.toggle('query-filter__term-btn--active', isAllActive); 149 allButton.setAttribute('aria-pressed', isAllActive ? 'true' : 'false'); 150 } 151 152 termButtons.forEach((btn) => { 153 const termId = parseInt(btn.getAttribute('data-term-id'), 10); 154 const isSelected = selectedTerms && selectedTerms.includes(termId); 155 btn.classList.toggle('query-filter__term-btn--active', isSelected); 156 btn.setAttribute('aria-pressed', isSelected ? 'true' : 'false'); 157 }); 158}; 159 160// Set loading state in DOM
161const setLoadingState = (block, isLoading) => { 162 if (!block) return; 163 const grid = block.querySelector('.query-filter__grid'); 164 const loading = block.querySelector('.query-filter__loading'); 165 if (grid) grid.classList.toggle('query-filter__grid--loading', isLoading); 166 if (loading) loading.classList.toggle('query-filter__loading--visible', isLoading); 167}; 168 169// Map taxonomy to REST API parameter name 170const getTaxonomyRestParam = (taxonomy) => { 171 // WordPress REST API uses plural forms for built-in taxonomies 172 const taxonomyMap = { 173 category: 'categories', 174 post_tag: 'tags', 175 }; 176 return taxonomyMap[taxonomy] || taxonomy; 177}; 178 179// Perform the fetch operation 180const doFetch = async (contextData) => { 181 const params = new URLSearchParams(); 182 183 if (contextData.searchQuery) { 184 params.set('search', contextData.searchQuery); 185 } 186 187 if (contextData.selectedTerms && contextData.selectedTerms.length > 0) { 188 const restParam = getTaxonomyRestParam(contextData.taxonomy); 189 params.set(restParam, contextData.selectedTerms.join(',')); 190 } 191 192 params.set('per_page', String(contextData.postsPerPage)); 193 params.set('page', String(contextData.currentPage || 1)); 194 params.set('_embed', 'true'); 195 196 try { 197 const response = await fetch( 198 `${contextData.restUrl}${contextData.restBase}?${params}` 199 ); 200 const totalPages = parseInt(response.headers.get('X-WP-TotalPages') || '1', 10); 201 const totalPosts = parseInt(response.headers.get('X-WP-Total') || '0', 10); 202 const data = await response.json(); 203 return { 204 posts: data.map(mapRestPost), 205 totalPages, 206 totalPosts, 207 }; 208 } catch (error) { 209 console.error('Query Filter: Error fetching posts', error); 210 return { posts: [], totalPages: 0, totalPosts: 0 }; 211 } 212}; 213 214// Update URL with current filter state 215const updateUrlState = (searchQuery, selectedTerms, page = 1, { replace = false } = {}) => { 216 const url = new URL(window.location); 217 218 if (searchQuery) { 219 url.searchParams.set('s', searchQuery); 220 } else { 221 url.searchParams.delete('s'); 222 } 223 224 if (selectedTerms && selectedTerms.length > 0) { 225 url.searchParams.set('tax', selectedTerms.join(',')); 226 } else { 227 url.searchParams.delete('tax'); 228 } 229 230 if (page > 1) { 231 url.searchParams.set('pg', String(page)); 232 } else { 233 url.searchParams.delete('pg'); 234 } 235 236 if (replace) { 237 window.history.replaceState({}, '', url); 238 } else { 239 window.history.pushState({}, '', url); 240 } 241}; 242 243// Generate pagination numbers 244const generatePaginationPages = (currentPage, totalPages) => { 245 const pages = []; 246 const showEllipsis = totalPages > 7; 247 248 if (!showEllipsis) { 249 for (let i = 1; i <= totalPages; i++) { 250 pages.push(i); 251 } 252 } else { 253 // Always show first 3 pages 254 pages.push(1, 2, 3); 255 256 if (currentPage > 4) { 257 pages.push('...'); 258 } 259 260 // Show current page and neighbors if not already included 261 if (currentPage > 3 && currentPage < totalPages - 2) { 262 if (!pages.includes(currentPage)) { 263 pages.push(currentPage); 264 } 265 } 266 267 if (currentPage < totalPages - 3) { 268 pages.push('...'); 269 } 270 271 // Always show last 3 pages 272 if (!pages.includes(totalPages - 2)) pages.push(totalPages - 2); 273 if (!pages.includes(totalPages - 1)) pages.push(totalPages - 1); 274 if (!pages.includes(totalPages)) pages.push(totalPages); 275 } 276 277 return pages; 278}; 279 280// Update pagination UI 281const updatePagination = (block, currentPage, totalPages) => { 282 const paginationNav = block.querySelector('.query-filter__pagination'); 283 const pagesContainer = block.querySelector('.query-filter__pagination-pages'); 284 const prevBtn = block.querySelector('.query-filter__pagination-btn--prev'); 285 const nextBtn = block.querySelector('.query-filter__pagination-btn--next'); 286 287 if (!paginationNav) return; 288 289 // Show/hide pagination 290 if (totalPages <= 1) { 291 paginationNav.setAttribute('hidden', ''); 292 return; 293 } else { 294 paginationNav.removeAttribute('hidden'); 295 } 296 297 // Update prev/next button states 298 if (prevBtn) prevBtn.disabled = currentPage <= 1; 299 if (nextBtn) nextBtn.disabled = currentPage >= totalPages; 300 301 // Generate page numbers 302 if (pagesContainer) { 303 const pages = generatePaginationPages(currentPage, totalPages); 304 pagesContainer.innerHTML = pages 305 .map((page) => { 306 if (page === '...') { 307 return '<span class="query-filter__pagination-ellipsis">...</span>'; 308 } 309 const isActive = page === currentPage; 310 return `<button type="button" class="query-filter__pagination-page${isActive ? ' query-filter__pagination-page--active' : ''}" data-page="${page}"${isActive ? ' aria-current="page"' : ''}>${page}</button>`; 311 }) 312 .join(''); 313 314 // Add click handlers for page buttons 315 pagesContainer.querySelectorAll('.query-filter__pagination-page').forEach((btn) => { 316 btn.addEventListener('click', (e) => { 317 const page = parseInt(e.target.dataset.page, 10); 318 if (page && !isNaN(page)) { 319 window.dispatchEvent(new CustomEvent('query-filter-go-to-page', { detail: { page, block } })); 320 } 321 }); 322 }); 323 } 324}; 325 326// Store a reference to pending fetch data for debounced calls 327let pendingFetchData = null; 328let pendingBlock = null; 329let pendingContext = null; 330 331const executeFetch = async (resetPage = false) => { 332 if (!pendingFetchData || !pendingBlock) return; 333 334 const contextData = { ...pendingFetchData }; 335 if (resetPage) { 336 contextData.currentPage = 1; 337 } 338 const block = pendingBlock; 339 340 setLoadingState(block, true); 341 342 const result = await doFetch(contextData); 343 updateGrid(block, result.posts); 344 updatePagination(block, contextData.currentPage, result.totalPages); 345 updateUrlState(contextData.searchQuery, contextData.selectedTerms, contextData.currentPage); 346 347 // Update context values 348 if (pendingContext) { 349 pendingContext.currentPage = contextData.currentPage; 350 pendingContext.totalPages = result.totalPages; 351 pendingContext.totalPosts = result.totalPosts; 352 } 353 354 setLoadingState(block, false); 355}; 356 357const { actions } = store('old-north/query-filter', { 358 state: { 359 get hasNoTerms() { 360 const context = getContext(); 361 return !context.selectedTerms || context.selectedTerms.length === 0; 362 }, 363 get hasResults() { 364 const context = getContext(); 365 return context.posts && context.posts.length > 0; 366 }, 367 }, 368 369 actions: { 370 handleSearchInput(event) { 371 const context = getContext(); 372 const { ref } = getElement(); 373 context.searchQuery = event.target.value; 374 context.currentPage = 1; 375 376 // Find the block element
377 const block = ref.closest('[data-wp-interactive="old-north/query-filter"]'); 378 379 // Store context data for debounced fetch 380 pendingFetchData = { 381 searchQuery: context.searchQuery, 382 selectedTerms: context.selectedTerms, 383 taxonomy: context.taxonomy, 384 postsPerPage: context.postsPerPage, 385 restBase: context.restBase, 386 restUrl: context.restUrl, 387 currentPage: 1, 388 }; 389 pendingBlock = block; 390 pendingContext = context; 391 392 debounce(() => executeFetch(true), 300); 393 }, 394 395 toggleTerm(event) { 396 const context = getContext(); 397 const { ref } = getElement(); 398 const termId = context.termId; 399 400 if (!termId) return; 401 402 if (!context.selectedTerms) { 403 context.selectedTerms = []; 404 } 405 406 const index = context.selectedTerms.indexOf(termId); 407 if (index === -1) { 408 context.selectedTerms = [...context.selectedTerms, termId]; 409 } else { 410 context.selectedTerms = context.selectedTerms.filter( 411 (id) => id !== termId 412 ); 413 } 414 415 context.currentPage = 1; 416 417 // Find the block element 418 const block = ref.closest('[data-wp-interactive="old-north/query-filter"]'); 419 420 // Update button states immediately 421 if (block) { 422 updateTermButtons(block, context.selectedTerms); 423 } 424 425 // Store context data and execute fetch immediately (no debounce for button clicks) 426 pendingFetchData = { 427 searchQuery: context.searchQuery, 428 selectedTerms: context.selectedTerms, 429 taxonomy: context.taxonomy, 430 postsPerPage: context.postsPerPage, 431 restBase: context.restBase, 432 restUrl: context.restUrl, 433 currentPage: 1, 434 }; 435 pendingBlock = block; 436 pendingContext = context; 437 438 executeFetch(true); 439 }, 440 441 clearTerms(event) { 442 const context = getContext(); 443 const { ref } = getElement(); 444 context.selectedTerms = []; 445 context.currentPage = 1; 446 447 // Find the block element 448 const block = ref.closest('[data-wp-interactive="old-north/query-filter"]'); 449 450 // Update button states immediately 451 if (block) { 452 updateTermButtons(block, []); 453 } 454 455 // Store context data and execute fetch immediately 456 pendingFetchData = { 457 searchQuery: context.searchQuery, 458 selectedTerms: [], 459 taxonomy: context.taxonomy, 460 postsPerPage: context.postsPerPage, 461 restBase: context.restBase, 462 restUrl: context.restUrl, 463 currentPage: 1, 464 }; 465 pendingBlock = block; 466 pendingContext = context; 467 468 executeFetch(true); 469 }, 470 471 prevPage() { 472 const context = getContext(); 473 const { ref } = getElement(); 474 475 if (context.currentPage <= 1) return; 476 477 context.currentPage = context.currentPage - 1; 478 const block = ref.closest('[data-wp-interactive="old-north/query-filter"]'); 479 480 pendingFetchData = { 481 searchQuery: context.searchQuery, 482 selectedTerms: context.selectedTerms, 483 taxonomy: context.taxonomy, 484 postsPerPage: context.postsPerPage, 485 restBase: context.restBase, 486 restUrl: context.restUrl, 487 currentPage: context.currentPage, 488 }; 489 pendingBlock = block; 490 pendingContext = context; 491 492 executeFetch(); 493 }, 494 495 nextPage() { 496 const context = getContext(); 497 const { ref } = getElement(); 498 499 if (context.currentPage >= context.totalPages) return; 500 501 context.currentPage = context.currentPage + 1; 502 const block = ref.closest('[data-wp-interactive="old-north/query-filter"]'); 503 504 pendingFetchData = { 505 searchQuery: context.searchQuery, 506 selectedTerms: context.selectedTerms, 507 taxonomy: context.taxonomy, 508 postsPerPage: context.postsPerPage, 509 restBase: context.restBase, 510 restUrl: context.restUrl, 511 currentPage: context.currentPage, 512 }; 513 pendingBlock = block; 514 pendingContext = context; 515 516 executeFetch(); 517 }, 518 519 goToPage(page) { 520 const context = getContext(); 521 const { ref } = getElement(); 522 523 if (page < 1 || page > context.totalPages || page === context.currentPage) return; 524 525 context.currentPage = page; 526 const block = ref.closest('[data-wp-interactive="old-north/query-filter"]'); 527 528 pendingFetchData = { 529 searchQuery: context.searchQuery, 530 selectedTerms: context.selectedTerms, 531 taxonomy: context.taxonomy, 532 postsPerPage: context.postsPerPage, 533 restBase: context.restBase, 534 restUrl: context.restUrl, 535 currentPage: context.currentPage, 536 }; 537 pendingBlock = block; 538 pendingContext = context; 539 540 executeFetch(); 541 }, 542 }, 543 544 callbacks: { 545 hasNoTerms() { 546 const context = getContext(); 547 return !context.selectedTerms || context.selectedTerms.length === 0; 548 }, 549 550 isTermSelected() { 551 const context = getContext(); 552 if (!context.selectedTerms || !context.termId) return false;
553 return context.selectedTerms.includes(context.termId); 554 }, 555 556 hasNoResults() { 557 const context = getContext(); 558 return !context.isLoading && (!context.posts || context.posts.length === 0); 559 }, 560 561 isFirstPage() { 562 const context = getContext(); 563 return context.currentPage <= 1; 564 }, 565 566 isLastPage() { 567 const context = getContext(); 568 return context.currentPage >= context.totalPages; 569 }, 570 571 initPagination() { 572 const context = getContext(); 573 const { ref } = getElement(); 574 const block = ref.closest('[data-wp-interactive="old-north/query-filter"]'); 575 576 if (block && context.totalPages > 1) { 577 updatePagination(block, context.currentPage, context.totalPages); 578 } 579 580 // Set initial history state with replaceState so back button works correctly 581 updateUrlState(context.searchQuery, context.selectedTerms, context.currentPage, { replace: true }); 582 }, 583 }, 584}); 585 586// Restore filter state when navigating browser history (back/forward) 587window.addEventListener('popstate', async () => { 588 const block = document.querySelector('[data-wp-interactive="old-north/query-filter"]'); 589 if (!block) return; 590 591 const url = new URL(window.location); 592 const searchQuery = url.searchParams.get('s') || ''; 593 const taxParam = url.searchParams.get('tax') || ''; 594 const selectedTerms = taxParam ? taxParam.split(',').map(Number).filter(Boolean) : []; 595 const page = parseInt(url.searchParams.get('pg') || '1', 10); 596 597 // Read block context for REST config 598 const contextStr = block.getAttribute('data-wp-context'); 599 if (!contextStr) return; 600 601 let context; 602 try { 603 context = JSON.parse(contextStr); 604 } catch { 605 return; 606 } 607 608 const fetchData = { 609 searchQuery, 610 selectedTerms, 611 taxonomy: context.taxonomy, 612 postsPerPage: context.postsPerPage, 613 restBase: context.restBase, 614 restUrl: context.restUrl, 615 currentPage: page, 616 }; 617 618 setLoadingState(block, true); 619 620 const result = await doFetch(fetchData); 621 updateGrid(block, result.posts); 622 updatePagination(block, page, result.totalPages); 623 updateTermButtons(block, selectedTerms); 624 625 // Update search input 626 const searchInput = block.querySelector('.query-filter__search-input'); 627 if (searchInput) searchInput.value = searchQuery; 628 629 setLoadingState(block, false); 630}); 631 632// Listen for page click events from dynamically generated buttons 633window.addEventListener('query-filter-go-to-page', (e) => { 634 const { page, block } = e.detail; 635 if (!block) return; 636 637 // Use the last known pending fetch data if available (has current filter state) 638 // Otherwise fall back to parsing the DOM context 639 if (pendingFetchData) { 640 pendingFetchData.currentPage = page; 641 pendingBlock = block; 642 executeFetch(); 643 } else { 644 // Get context from block as fallback 645 const contextStr = block.getAttribute('data-wp-context'); 646 if (!contextStr) return; 647 648 try { 649 const context = JSON.parse(contextStr); 650 651 pendingFetchData = { 652 searchQuery: context.searchQuery, 653 selectedTerms: context.selectedTerms, 654 taxonomy: context.taxonomy, 655 postsPerPage: context.postsPerPage, 656 restBase: context.restBase, 657 restUrl: context.restUrl, 658 currentPage: page, 659 }; 660 pendingBlock = block; 661 662 executeFetch(); 663 } catch (err) { 664 console.error('Query Filter: Error parsing context', err); 665 } 666 } 667});
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.