PageSourceSearch

https://momentivesoftware.com/wp-content/themes/momentive/blocks/resource-filters/filters.js?ver=1790828014

js momentivesoftware.com collected 2026-10-01 12:31:47 UTC 36,182 bytes, 980 lines download raw bytes

1( function () {
2	'use strict';
3
4	const { esc, initLowerLabels, renderCategoryLink, debounce } = window.SiteUtils;
5
6	// Data-driven post-type map localized from PHP:
7	//   { slug: { label, singular, endpoint } }
8	// Read lazily (inside the helpers, not at module-parse time) so it works
9	// regardless of whether the localized global is printed before or after
10	// this script.
11	function postTypeMap() {
12		return ( window.momentiveResourceFilters && window.momentiveResourceFilters.postTypes ) || {};
13	}
14
15	function postTypeEndpoint( slug ) {
16		return postTypeMap()[ slug ]?.endpoint ?? '/wp-json/wp/v2/posts';
17	}
18
19	function postTypeSingular( slug ) {
20		return postTypeMap()[ slug ]?.singular ?? '';
21	}
22
23	function initLoadMore( grid ) {
24		const queryBlock = grid.closest( '.wp-block-query' );
25		const pagination = queryBlock?.querySelector( '.wp-block-query-pagination' );
26
27		if ( ! pagination ) return null;
28
29		const btn = document.createElement( 'div' );
30		btn.className = 'load-more-wrapper';
31		btn.innerHTML = `
32			<div class="wp-block-buttons is-content-justification-center">
33				<div class="wp-block-button">
34					<button class="wp-block-button__link wp-element-button load-more-btn" type="button">
35						Load More
36					</button>
37				</div>
38			</div>
39		`;
40
41		pagination.insertAdjacentElement( 'afterend', btn );
42		pagination.hidden = true;
43
44		return btn.querySelector( '.load-more-btn' );
45	}
46
47	function initAccordionLoadMore( accordion ) {
48		const btn = document.createElement( 'div' );
49		btn.className = 'load-more-wrapper';
50		btn.innerHTML = `
51			<div class="wp-block-buttons is-content-justification-center">
52				<div class="wp-block-button">
53					<button class="wp-block-button__link wp-element-button load-more-btn" type="button">
54						Load More
55					</button>
56				</div>
57			</div>
58		`;
59		accordion.insertAdjacentElement( 'afterend', btn );
60		return btn.querySelector( '.load-more-btn' );
61	}
62
63	function findNearestQuery( bar ) {
64		let el = bar.nextElementSibling;
65		while ( el ) {
66			const grid = el.querySelector( '.wp-block-post-template' );
67			if ( grid ) return { type: 'grid', el: grid };
68	
69			const accordion = el.querySelector( '.momentive-accordion.is-query-mode' );
70			if ( accordion ) return { type: 'accordion', el: accordion };
71	
72			el = el.nextElementSibling;
73		}
74		const fallbackGrid = bar.closest( '.wp-block-group' )
75			?.querySelector( '.wp-block-post-template' ) ?? null;
76		if ( fallbackGrid ) return { type: 'grid', el: fallbackGrid };
77	
78		const fallbackAccordion = bar.closest( '.wp-block-group' )
79			?.querySelector( '.momentive-accordion.is-query-mode' ) ?? null;
80		if ( fallbackAccordion ) return { type: 'accordion', el: fallbackAccordion };
81	
82		return null;
83	}
84
85	function initFilterBar( bar ) {
86		const queryTarget = findNearestQuery( bar );
87		if ( ! queryTarget ) return;
88		
89		const isAccordion = queryTarget.type === 'accordion';
90		const grid        = isAccordion ? null : queryTarget.el;
91		const accordion   = isAccordion ? queryTarget.el : null;
92		
93		const moreBtn = isAccordion ? initAccordionLoadMore( accordion ) : initLoadMore( grid );
94
95		// ── Read the default post type from a data attribute set by PHP ───────
96		// This drives renderCard's top-label logic when no post_type filter
97		// is actively selected by the user.
98		const defaultPostType = bar.dataset.defaultPostType || 'post';
99
100		// Custom taxonomies — read once from the data attribute set by PHP.
101		// Shape: [ { slug: 'organization_type', label: 'Organization Type' }, … ]
102		const customTaxonomies = ( () => {
103			try {
104				return JSON.parse( bar.dataset.customTaxonomies || '[]' );
105			} catch {
106				return [];
107			}
108		} )();
109
110		const state = {
111			categories:      [],
112			postTypes:       [],
113			customTaxFilters: Object.fromEntries( customTaxonomies.map( t => [ t.slug, [] ] ) ),
114			search:          '',
115			orderby:         isAccordion ? 'menu_order' : 'date',
116			order:           isAccordion ? 'asc' : 'desc',
117			page:            1,
118			totalPages:      1,
119			loading:         false,
120		};
121
122		// Seed totalPages from the data attribute PHP already wrote on the accordion wrapper,
123		// then immediately show or hide the button. Without this, the button stays hidden
124		// until the first fetch fires (which only happens on user interaction).
125		if ( isAccordion && moreBtn ) {
126			state.totalPages = parseInt( accordion.dataset.totalPages || '1', 10 );
127			moreBtn.closest( '.load-more-wrapper' ).hidden = state.totalPages <= 1;
128		}
129
130		const toggle      = bar.querySelector( '.filter-toggle' );
131		const panel       = bar.querySelector( '.filter-panel' );
132		const resetBtn    = bar.querySelector( '.filter-reset' );
133		const countBadge  = bar.querySelector( '.filter-toggle .filter-count' );
134		const activeTags  = bar.querySelector( '.filter-active-tags' );
135		const searchInput = bar.querySelector( '.filter-search' );
136		const sortSelect  = bar.querySelector( '.filter-sort' );
137
138		toggle?.addEventListener( 'click', () => {
139			const open = toggle.getAttribute( 'aria-expanded' ) === 'true';
140			toggle.setAttribute( 'aria-expanded', String( ! open ) );
141			panel?.toggleAttribute( 'hidden', open );
142		} );
143
144		// Close the panel and any open inline dropdowns when clicking outside the filter bar.
145		document.addEventListener( 'click', ( e ) => {
146			if ( bar.contains( e.target ) ) return;
147			if ( toggle?.getAttribute( 'aria-expanded' ) === 'true' ) {
148				toggle.setAttribute( 'aria-expanded', 'false' );
149				panel?.toggleAttribute( 'hidden', true );
150			}
151			closeAllDropdowns();
152		} );
153
154		bar.querySelectorAll( '.filter-group-toggle' ).forEach( legend => {
155			legend.addEventListener( 'click', () => {
156				const expanded = legend.getAttribute( 'aria-expanded' ) !== 'false';
157				legend.setAttribute( 'aria-expanded', String( ! expanded ) );
158				legend.nextElementSibling?.toggleAttribute( 'hidden', expanded );
159			} );
160		} );
161
162		// ── Inline taxonomy dropdowns ─────────────────────────────────────────
163		// Each custom taxonomy renders as its own dropdown in the filter bar top.
164		// Only one can be open at a time.
165
166		function closeAllDropdowns() {
167			bar.querySelectorAll( '.filter-dropdown-toggle[aria-expanded="true"]' ).forEach( btn => {
168				btn.setAttribute( 'aria-expanded', 'false' );
169				btn.closest( '.filter-dropdown' )
170					?.querySelector( '.filter-dropdown-panel' )
171					?.setAttribute( 'hidden', '' );
172			} );
173		}
174
175		bar.querySelectorAll( '.filter-dropdown-toggle' ).forEach( btn => {
176			btn.addEventListener( 'click', () => {
177				const isOpen = btn.getAttribute( 'aria-expanded' ) === 'true';
178				closeAllDropdowns();
179				if ( ! isOpen ) {
180					btn.setAttribute( 'aria-expanded', 'true' );
181					btn.closest( '.filter-dropdown' )
182						?.querySelector( '.filter-dropdown-panel' )
183						?.removeAttribute( 'hidden' );
184				}
185			} );
186		} );
187
188		bar.querySelectorAll( 'input[name="category"]' ).forEach( input => {
189			input.addEventListener( 'change', () => {
190				state.categories = Array.from(
191					bar.querySelectorAll( 'input[name="category"]:checked' )
192				).map( el => el.value );
193				state.page = 1;
194				syncUI();
195				fetchPosts();
196			} );
197		} );
198
199		// ── "All types" checkbox ──────────────────────────────────────────────
200		// Separate input (name="post_type_all") so it never lands in state.postTypes.
201		// Checked  = all individual types selected.
202		// Indeterminate = some selected.
203		// Unchecked = none selected.
204
205		const allTypesInput = bar.querySelector( 'input.post-type-all' );
206
207		function syncAllTypesCheckbox() {
208			if ( ! allTypesInput ) return;
209			const inputs   = Array.from( bar.querySelectorAll( 'input[name="post_type"]' ) );
210			const checked  = inputs.filter( i => i.checked ).length;
211			const total    = inputs.length;
212			if ( checked === total && total > 0 ) {
213				allTypesInput.checked       = true;
214				allTypesInput.indeterminate = false;
215			} else if ( checked === 0 ) {
216				allTypesInput.checked       = false;
217				allTypesInput.indeterminate = false;
218			} else {
219				allTypesInput.checked       = false;
220				allTypesInput.indeterminate = true;
221			}
222		}
223
224		allTypesInput?.addEventListener( 'change', () => {
225			const inputs = Array.from( bar.querySelectorAll( 'input[name="post_type"]' ) );
226			if ( allTypesInput.checked ) {
227				inputs.forEach( el => { el.checked = true; } );
228				state.postTypes = inputs.map( el => el.value );
229			} else {
230				inputs.forEach( el => { el.checked = false; } );
231				state.postTypes = [];
232			}
233			allTypesInput.indeterminate = false;
234			state.page = 1;
235			syncUI();
236			fetchPosts();
237		} );
238
239		bar.querySelectorAll( 'input[name="post_type"]' ).forEach( input => {
240			input.addEventListener( 'change', () => {
241				state.postTypes = Array.from(
242					bar.querySelectorAll( 'input[name="post_type"]:checked' )
243				).map( el => el.value );
244				state.page = 1;
245				syncAllTypesCheckbox();
246				syncUI();
247				fetchPosts();
248			} );
249		} );
250
251		// Wire custom taxonomy checkboxes (one listener per taxonomy slug).
252		customTaxonomies.forEach( tax => {
253			bar.querySelectorAll( `input[name="${ tax.slug }"]` ).forEach( input => {
254				input.addEventListener( 'change', () => {
255					state.customTaxFilters[ tax.slug ] = Array.from(
256						bar.querySelectorAll( `input[name="${ tax.slug }"]:checked` )
257					).map( el => el.value );
258					state.page = 1;
259					syncUI();
260					fetchPosts();
261				} );
262			} );
263		} );
264
265		const debouncedSearch = debounce( () => {
266			state.search = searchInput?.value.trim() ?? '';
267			state.page   = 1;
268			syncUI();
269			fetchPosts();
270		}, 350 );
271
272		searchInput?.addEventListener( 'input', debouncedSearch );
273
274		sortSelect?.addEventListener( 'change', () => {
275			const [ orderby, order ] = ( sortSelect.value || 'date-desc' ).split( '-' );
276			state.orderby = orderby;
277			state.order   = order;
278			state.page    = 1;
279			updateURL();
280			fetchPosts();
281		} );
282
283		resetBtn?.addEventListener( 'click', () => {
284			bar.querySelectorAll( 'input[type="checkbox"]' ).forEach( el => {
285				el.checked = false;
286			} );
287			if ( searchInput ) searchInput.value = '';
288			state.categories     = [];
289			state.postTypes      = [];
290			state.customTaxFilters = Object.fromEntries( customTaxonomies.map( t => [ t.slug, [] ] ) );
291			state.search         = '';
292			state.page           = 1;
293			syncUI();
294			fetchPosts();
295		} );
296
297		moreBtn?.addEventListener( 'click', () => {
298			if ( state.loading ) return;
299			state.page++;
300			fetchPosts( true );
301		} );
302
303		// ── Auto-query / deep-link boot ───────────────────────────────────────
304		// applyURLState() reads ?cat=&type=&search=&sort= from the URL, pre-
305		// checks the matching inputs, seeds state, and returns true when at
306		// least one param was found. URL params always take priority over the
307		// auto-query default so a shared link lands with the expected filters.
308		const hasURLState = applyURLState();
309
310		if ( hasURLState ) {
311			fetchPosts();
312		} else if ( bar.dataset.autoQuery === 'true' ) {
313			// No URL params: select all types (including the "All" checkbox) and
314			// fetch so the grid opens with the merged multi-type view.
315			const typeInputs = Array.from( bar.querySelectorAll( 'input[name="post_type"]' ) );
316			typeInputs.forEach( el => { el.checked = true; } );
317			state.postTypes = typeInputs.map( el => el.value );
318			if ( allTypesInput ) { allTypesInput.checked = true; allTypesInput.indeterminate = false; }
319			fetchPosts();
320		}
321
322		// Restore filter state when the user navigates back/forward.
323		window.addEventListener( 'popstate', () => {
324			bar.querySelectorAll( 'input[type="checkbox"]' ).forEach( el => { el.checked = false; } );
325			if ( allTypesInput ) { allTypesInput.checked = false; allTypesInput.indeterminate = false; }
326			if ( searchInput ) searchInput.value = '';
327			if ( sortSelect ) sortSelect.value = isAccordion ? 'menu_order-asc' : 'date-desc';
328			state.categories       = [];
329			state.postTypes        = [];
330			state.customTaxFilters = Object.fromEntries( customTaxonomies.map( t => [ t.slug, [] ] ) );
331			state.search           = '';
332			state.orderby          = isAccordion ? 'menu_order' : 'date';
333			state.order            = isAccordion ? 'asc' : 'desc';
334			state.page             = 1;
335			const urlRestored = applyURLState();
336			// If navigating back to the unfiltered state on an auto-query page,
337			// restore the "all types" selection the same way the initial boot does.
338			if ( ! urlRestored && bar.dataset.autoQuery === 'true' ) {
339				const typeInputs = Array.from( bar.querySelectorAll( 'input[name="post_type"]' ) );
340				typeInputs.forEach( el => { el.checked = true; } );
341				state.postTypes = typeInputs.map( el => el.value );
342				if ( allTypesInput ) { allTypesInput.checked = true; allTypesInput.indeterminate = false; }
343				syncUI();
344			}
345			fetchPosts();
346		} );
347
348		// ── Fetch ─────────────────────────────────────────────────────────────
349		async function fetchPosts( append = false ) {
350			if ( state.loading ) return;
351			state.loading = true;
352
353			// aria-busy on whichever container is active.
354			const busyTarget = isAccordion ? accordion : grid;
355			busyTarget.setAttribute( 'aria-busy', 'true' );
356
357			if ( moreBtn ) {
358				moreBtn.disabled    = true;
359				moreBtn.textContent = 'Loading…';
360			}
361
362			// ── Resolve which post type(s) to query ────────────────────────────
363			// Accordion mode (FAQ query mode) only ever targets a single type in
364			// practice, so it keeps the original single-endpoint behavior. Grid
365			// mode now supports true multi-type merging: when more than one
366			// post_type checkbox is active, this calls the momentive/v1/resources
367			// REST route (inc/resources.php) instead of falling back to a single
368			// type. That route runs one WP_Query across several post types, so
369			// merge, sort, and pagination stay correct — the gap this file used
370			// to document as "a separate, planned feature."
371			const isMultiType = ! isAccordion && state.postTypes.length > 1;
372
373			const activePostType = state.postTypes.length === 1
374				? state.postTypes[0]
375				: defaultPostType;
376
377			try {
378				let posts;
379
380				if ( isMultiType ) {
381					const multiParams = new URLSearchParams( {
382						per_page: 15,
383						page:     state.page,
384						orderby:  state.orderby,
385						order:    state.order,
386						post_type: state.postTypes.join( ',' ),
387					} );
388					if ( state.categories.length ) {
389						multiParams.set( 'categories', state.categories.join( ',' ) );
390					}
391					if ( state.search ) {
392						multiParams.set( 'search', state.search );
393					}
394
395					const res        = await fetch( `/wp-json/momentive/v1/resources?${ multiParams }` );
396					state.totalPages = parseInt( res.headers.get( 'X-WP-TotalPages' ) || '1', 10 );
397					posts            = await res.json();
398
399					const html = posts.map( renderMultiTypeCard ).join( '' );
400
401					if ( append ) {
402						grid.insertAdjacentHTML( 'beforeend', html );
403					} else {
404						grid.innerHTML = html;
405					}
406					initLowerLabels( grid );
407
408				} else {
409					const params = new URLSearchParams( {
410						per_page: 15,
411						page:     state.page,
412						orderby:  state.orderby,
413						order:    state.order,
414						_embed:   true,
415					} );
416
417					if ( state.categories.length ) {
418						params.set( 'categories', state.categories.join( ',' ) );
419					}
420					// Append any active custom taxonomy filters as native REST params.
421					// WP auto-generates ?{taxonomy_slug}= for show_in_rest taxonomies.
422					Object.entries( state.customTaxFilters ).forEach( ( [ slug, ids ] ) => {
423						if ( ids.length ) params.set( slug, ids.join( ',' ) );
424					} );
425					if ( state.search ) {
426						params.set( 'search', state.search );
427					}
428
429					const endpoint = postTypeEndpoint( activePostType );
430
431					// Single fetch — result used by both grid and accordion paths.
432					const res        = await fetch( `${ endpoint }?${ params }` );
433					state.totalPages = parseInt( res.headers.get( 'X-WP-TotalPages' ) || '1', 10 );
434					posts            = await res.json();
435
436					if ( isAccordion ) {
437						const items = posts.map( buildAccordionItem ).join( '' );
438
439						if ( append ) {
440							accordion.insertAdjacentHTML( 'beforeend', items );
441							accordion.querySelectorAll( '.accordion-trigger:not([data-init])' )
442								.forEach( t => { t.setAttribute( 'data-init', '' ); wireAccordionTrigger( t ); } );
443						} else {
444							accordion.innerHTML = items;
445							accordion.querySelectorAll( '.accordion-trigger' )
446								.forEach( t => { t.setAttribute( 'data-init', '' ); wireAccordionTrigger( t ); } );
447						}
448					} else {
449						const html = posts.map( post =>
450							activePostType === 'fundraiser'
451								? renderDonationExampleCard( post )
452								: renderCard( post, activePostType )
453						).join( '' );
454
455						if ( append ) {
456							grid.insertAdjacentHTML( 'beforeend', html );
457						} else {
458							grid.innerHTML = html;
459						}
460						initLowerLabels( grid );
461					}
462				}
463
464			} catch ( err ) {
465				console.error( 'Resource filter fetch error:', err );
466			} finally {
467				state.loading = false;
468				busyTarget.removeAttribute( 'aria-busy' );
469
470				if ( moreBtn ) {
471					moreBtn.disabled    = false;
472					moreBtn.textContent = 'Load More';
473					moreBtn.closest( '.load-more-wrapper' ).hidden =
474						state.page >= state.totalPages;
475				}
476			}
477		}
478
479		// ── Card renderer ─────────────────────────────────────────────────────
480		// activePostType is now passed as a parameter instead of being
481		// referenced from an outer scope — this was the source of the
482		// ReferenceError when renderCard was called via posts.map().
483
484		function renderCard( post, activePostType ) {
485			const terms   = post._embedded?.[ 'wp:term' ] ?? [];
486			const cats    = terms.find( group => group[0]?.taxonomy === 'category' ) ?? [];
487			const media   = post._embedded?.[ 'wp:featuredmedia' ]?.[0];
488			const date    = new Date( post.date ).toLocaleDateString( 'en-US', {
489				month: 'long', day: 'numeric', year: 'numeric',
490			} );
491			const excerpt = ( post.excerpt?.rendered ?? '' )
492				.replace( /<[^>]+>/g, '' )
493				.replace( /&hellip;/g, '…' )
494				.slice( 0, 140 );
495
496			const catLinks = cats.map( renderCategoryLink ).join( '<span class="wp-block-post-terms__separator"> </span>' );
497
498			// resource_card_meta is a PHP-computed REST field (inc/resource-cta.php)
499			// that exposes the type-aware top label, schedule, CTA label/URL, and
500			// whether to show the publication date. Use it when available; fall back
501			// to the previous static-label / "Read more" behaviour otherwise.
502			const cardMeta = post.resource_card_meta;
503
504			const topLabel = cardMeta
505				? cardMeta.top_label_html
506				: ( activePostType === 'press-article'
507					? ( cats[0] ? `<p class="top-label wp-block-paragraph">${ esc( cats[0].name ) }</p>` : '' )
508					: ( postTypeSingular( activePostType )
509						? `<p class="top-label wp-block-paragraph">${ esc( postTypeSingular( activePostType ) ) }</p>`
510						: '' ) );
511
512			const schedule = cardMeta?.schedule_html ?? '';
513			const ctaLabel = cardMeta?.cta_label ?? 'Read more';
514			const ctaUrl   = cardMeta?.cta_url   ?? post.link;
515			const showDate = cardMeta ? cardMeta.show_date : true;
516
517			return `<li class="wp-block-post">
518				<div class="wp-block-group story-card">
519
520					${ topLabel }
521
522					${ media ? `<figure class="wp-block-post-featured-image" style="aspect-ratio:16/9">
523						<a href="${ esc( post.link ) }" tabindex="-1" aria-hidden="true">
524							<img src="${ esc( media.source_url ) }"
525								 alt=""
526								 loading="lazy"
527								 style="width:100%;height:100%;object-fit:cover;">
528						</a>
529					</figure>` : '' }
530
531					<div class="story-content">
532
533						${ activePostType !== 'press-article' && catLinks
534							? `<div class="taxonomy-category lower-label wp-block-post-terms">
535								${ catLinks }
536							   </div>`
537							: ''
538						}
539
540						<h3 class="wp-block-post-title">
541							<a href="${ esc( post.link ) }">${ post.title.rendered }</a>
542						</h3>
543						<div class="wp-block-post-excerpt">
544							<p>${ excerpt }…</p>
545						</div>
546
547						${ schedule }
548
549						<div class="wp-block-group meta">
550							<a class="wp-block-read-more resource-cta is-${ esc( activePostType ) }" href="${ esc( ctaUrl ) }">
551								${ esc( ctaLabel ) }
552								<span class="screen-reader-text">: ${ post.title.rendered }</span>
553							</a>
554							${ showDate ? `<div class="wp-block-post-date">
555								<time datetime="${ esc( post.date ) }">${ date }</time>
556							</div>` : '' }
557						</div>
558
559					</div>
560				</div>
561			</li>`;
562		}
563
564		// ── Donation Example card renderer ───────────────────────────────────
565		// Used when activePostType === 'donation-example'. Reads custom REST
566		// fields (organization_name, campaign_link) registered in donation-examples.php,
567		// plus wp:term groups for organization_type (badge) and fundraising_features (pills).
568
569		function renderDonationExampleCard( post ) {
570			const terms        = post._embedded?.[ 'wp:term' ] ?? [];
571			const orgTypes     = terms.find( g => g[0]?.taxonomy === 'organization_type' ) ?? [];
572			const features     = terms.find( g => g[0]?.taxonomy === 'fundraising_features' ) ?? [];
573			const media        = post._embedded?.[ 'wp:featuredmedia' ]?.[0];
574			const campaignLink = post.campaign_link || post.link;
575			const orgName      = post.organization_name || '';
576			const excerpt      = ( post.excerpt?.rendered ?? '' )
577				.replace( /<[^>]+>/g, '' )
578				.replace( /&hellip;/g, '…' )
579				.slice( 0, 140 );
580
581			const orgBadge = orgTypes[0]
582				? `<span class="fundraiser-card__org-badge">${ esc( orgTypes[0].name ) }</span>`
583				: '';
584
585			const featurePills = features.map(
586				f => `<span class="fundraiser-card__feature-pill">${ esc( f.name ) }</span>`
587			).join( '' );
588
589			const imageHtml = media
590				? `<figure class="fundraiser-card__image" style="aspect-ratio:16/9;position:relative;">
591						<a href="${ esc( campaignLink ) }" tabindex="-1" aria-hidden="true" target="_blank" rel="noopener noreferrer">
592							<img src="${ esc( media.source_url ) }"
593								 alt=""
594								 loading="lazy"
595								 style="width:100%;height:100%;object-fit:cover;display:block;">
596						</a>
597						${ orgBadge }
598					</figure>`
599				: '';
600
601			return `<li class="wp-block-post">
602				<div class="story-card fundraiser-card">
603
604					${ imageHtml }
605
606					${ featurePills
607						? `<div class="fundraiser-card__features">${ featurePills }</div>`
608						: ''
609					}
610
611					<div class="story-content">
612						<h3 class="wp-block-post-title">
613							<a href="${ esc( campaignLink ) }" target="_blank" rel="noopener noreferrer">
614								${ esc( post.title?.rendered ?? '' ) }
615							</a>
616						</h3>
617						${ orgName
618							? `<p class="fundraiser-card__org-name">${ esc( orgName ) }</p>`
619							: ''
620						}
621						${ excerpt
622							? `<div class="wp-block-post-excerpt"><p>${ esc( excerpt ) }…</p></div>`
623							: ''
624						}
625					</div>
626
627				</div>
628			</li>`;
629		}
630
631		// ── Multi-type card renderer ─────────────────────────────────────────
632		// Renders an item from the momentive/v1/resources REST route (a flat
633		// shape — see momentive_resource_to_rest_item() in inc/resources.php),
634		// not a core /wp/v2/{type} response, so it can't share renderCard()
635		// above directly. Top label always shows the post type's singular
636		// label (item.type_label) rather than a category name: results here
637		// span several types, so the type is the discriminating piece of
638		// info, same reasoning momentive/solution-resources uses server-side.
639		function renderMultiTypeCard( item ) {
640			const date = new Date( item.date ).toLocaleDateString( 'en-US', {
641				month: 'long', day: 'numeric', year: 'numeric',
642			} );
643
644			const catLinks = ( item.categories || [] )
645				.map( renderCategoryLink )
646				.join( '<span class="wp-block-post-terms__separator"> </span>' );
647
648			// resource_card_meta is added to the /momentive/v1/resources response
649			// by momentive_resource_to_rest_item() (inc/resources.php). Use it for
650			// type-aware labels, schedule, CTA, and date visibility; fall back to
651			// the previous static type_label / "Read more" behaviour when absent.
652			const cardMeta = item.resource_card_meta;
653
654			const topLabel = cardMeta
655				? cardMeta.top_label_html
656				: ( item.type_label
657					? `<p class="top-label wp-block-paragraph">${ esc( item.type_label ) }</p>`
658					: '' );
659
660			const schedule = cardMeta?.schedule_html ?? '';
661			const ctaLabel = cardMeta?.cta_label ?? 'Read more';
662			const ctaUrl   = cardMeta?.cta_url   ?? item.link;
663			const showDate = cardMeta ? cardMeta.show_date : true;
664
665			return `<li class="wp-block-post">
666				<div class="wp-block-group story-card">
667
668					${ topLabel }
669
670					<figure class="wp-block-post-featured-image" style="aspect-ratio:16/9">
671						<a href="${ esc( item.link ) }" tabindex="-1" aria-hidden="true">
672							<img src="${ esc( item.featured_image || ( window.momentiveResourceFilters?.themeUri + '/assets/images/default-card-image.webp' ) ) }"
673								 alt=""
674								 loading="lazy"
675								 style="width:100%;height:100%;object-fit:cover;">
676						</a>
677					</figure>
678
679					<div class="story-content">
680
681						${ catLinks
682							? `<div class="taxonomy-category lower-label wp-block-post-terms">
683								${ catLinks }
684							   </div>`
685							: ''
686						}
687
688						<h3 class="wp-block-post-title">
689							<a href="${ esc( item.link ) }">${ esc( item.title ) }</a>
690						</h3>
691						<div class="wp-block-post-excerpt">
692							<p>${ esc( item.excerpt ) }</p>
693						</div>
694
695						${ schedule }
696
697						<div class="wp-block-group meta">
698							<a class="wp-block-read-more resource-cta" href="${ esc( ctaUrl ) }">
699								${ esc( ctaLabel ) }
700								<span class="screen-reader-text">: ${ esc( item.title ) }</span>
701							</a>
702							${ showDate ? `<div class="wp-block-post-date">
703								<time datetime="${ esc( item.date ) }">${ date }</time>
704							</div>` : '' }
705						</div>
706
707					</div>
708				</div>
709			</li>`;
710		}
711
712
713		// ── URL deep-linking ──────────────────────────────────────────────────
714		// Serializes the current filter state into URL query params and updates
715		// the browser URL via replaceState (no history entry per filter change).
716		// Param names: cat=<id,id>, type=<slug,slug>, <tax_slug>=<id,id>,
717		//              search=<term>, sort=<orderby-order>.
718		// The sort param is omitted when it matches the archive's default so
719		// links stay clean for the common case.
720
721		function updateURL() {
722			const params      = new URLSearchParams( window.location.search );
723			const managed     = [ 'cat', 'type', 'search', 'sort' ];
724			customTaxonomies.forEach( t => managed.push( t.slug ) );
725			managed.forEach( k => params.delete( k ) );
726
727			if ( state.categories.length ) params.set( 'cat', state.categories.join( ',' ) );
728			// Omit ?type= when all configured types are selected — that's the
729			// default "no filter" state and the URL should stay clean.
730			if ( state.postTypes.length && ! allTypesInput?.checked ) {
731				params.set( 'type', state.postTypes.join( ',' ) );
732			}
733			customTaxonomies.forEach( t => {
734				const ids = state.customTaxFilters[ t.slug ] || [];
735				if ( ids.length ) params.set( t.slug, ids.join( ',' ) );
736			} );
737			if ( state.search ) params.set( 'search', state.search );
738			const defaultSort = isAccordion ? 'menu_order-asc' : 'date-desc';
739			const currentSort = `${ state.orderby }-${ state.order }`;
740			if ( currentSort !== defaultSort ) params.set( 'sort', currentSort );
741
742			const qs     = params.toString();
743			const newURL = window.location.pathname + ( qs ? '?' + qs : '' ) + window.location.hash;
744			history.replaceState( null, '', newURL );
745		}
746
747		// Reads URL params, pre-checks matching inputs, seeds state.
748		// Returns true when at least one filter param was present.
749
750		function applyURLState() {
751			const params  = new URLSearchParams( window.location.search );
752			let   changed = false;
753
754			const catParam = params.get( 'cat' );
755			if ( catParam ) {
756				state.categories = catParam.split( ',' ).filter( Boolean );
757				state.categories.forEach( id => {
758					const input = bar.querySelector( `input[name="category"][value="${ id }"]` );
759					if ( input ) input.checked = true;
760				} );
761				changed = true;
762			}
763
764			const typeParam = params.get( 'type' );
765			if ( typeParam ) {
766				state.postTypes = typeParam.split( ',' ).filter( Boolean );
767				state.postTypes.forEach( slug => {
768					const input = bar.querySelector( `input[name="post_type"][value="${ slug }"]` );
769					if ( input ) input.checked = true;
770				} );
771				changed = true;
772			}
773
774			customTaxonomies.forEach( tax => {
775				const taxParam = params.get( tax.slug );
776				if ( taxParam ) {
777					state.customTaxFilters[ tax.slug ] = taxParam.split( ',' ).filter( Boolean );
778					state.customTaxFilters[ tax.slug ].forEach( id => {
779						const input = bar.querySelector( `input[name="${ tax.slug }"][value="${ id }"]` );
780						if ( input ) input.checked = true;
781					} );
782					changed = true;
783				}
784			} );
785
786			const searchParam = params.get( 'search' );
787			if ( searchParam ) {
788				state.search = searchParam;
789				if ( searchInput ) searchInput.value = searchParam;
790				changed = true;
791			}
792
793			const sortParam = params.get( 'sort' );
794			if ( sortParam ) {
795				const parts = sortParam.match( /^(.+)-(asc|desc)$/ );
796				if ( parts ) {
797					state.orderby = parts[1];
798					state.order   = parts[2];
799					if ( sortSelect ) sortSelect.value = sortParam;
800					changed = true;
801				}
802			}
803
804			if ( changed ) syncUI();
805			return changed;
806		}
807
808		// ── UI sync ───────────────────────────────────────────────────────────
809
810		function syncUI() {
811			const customTaxActive  = Object.values( state.customTaxFilters ).reduce( ( n, ids ) => n + ids.length, 0 );
812			// When "All types" is checked, post-type selection isn't a real filter.
813			const postTypesActive  = allTypesInput?.checked ? 0 : state.postTypes.length;
814			const totalActive = state.categories.length + postTypesActive +
815								customTaxActive + ( state.search ? 1 : 0 );
816			const hasActive   = totalActive > 0;
817
818			if ( countBadge ) {
819				countBadge.textContent = totalActive;
820				countBadge.hidden      = ! hasActive;
821			}
822
823			// Per-dropdown badges — post_type, category, and custom taxonomies.
824			bar.querySelectorAll( '.filter-dropdown' ).forEach( dropdown => {
825				const taxSlug = dropdown.dataset.tax;
826				let count;
827				if ( taxSlug === 'post_type' ) {
828					count = allTypesInput?.checked ? 0 : state.postTypes.length;
829				} else if ( taxSlug === 'category' ) {
830					count = state.categories.length;
831				} else {
832					count = ( state.customTaxFilters[ taxSlug ] || [] ).length;
833				}
834				const badge = dropdown.querySelector( '.filter-count' );
835				if ( badge ) {
836					badge.textContent = String( count );
837					badge.hidden      = count === 0;
838				}
839			} );
840
841			if ( resetBtn ) resetBtn.hidden = ! hasActive;
842
843			renderActiveTags();
844			updateURL();
845		}
846
847		function renderActiveTags() {
848			if ( ! activeTags ) return;
849
850			const tags = [];
851
852			bar.querySelectorAll( 'input[name="category"]:checked' ).forEach( el => {
853				tags.push( {
854					label: el.closest( 'label' )?.querySelector( '.filter-item-label' )?.textContent?.trim(),
855					value: el.value,
856					name:  'category',
857				} );
858			} );
859
860			bar.querySelectorAll( 'input[name="post_type"]:checked' ).forEach( el => {
861				tags.push( {
862					label: el.closest( 'label' )?.querySelector( '.filter-item-label' )?.textContent?.trim(),
863					value: el.value,
864					name:  'post_type',
865				} );
866			} );
867
868			customTaxonomies.forEach( tax => {
869				bar.querySelectorAll( `input[name="${ tax.slug }"]:checked` ).forEach( el => {
870					tags.push( {
871						label: el.closest( 'label' )?.querySelector( '.filter-item-label' )?.textContent?.trim(),
872						value: el.value,
873						name:  tax.slug,
874					} );
875				} );
876			} );
877
878			if ( state.search ) {
879				tags.push( { label: `"${ state.search }"`, value: '__search__', name: 'search' } );
880			}
881
882			activeTags.innerHTML = tags.map( tag => `
883				<span class="active-tag">
884					${ esc( tag.label ) }
885					<button type="button"
886							data-name="${ esc( tag.name ) }"
887							data-value="${ esc( tag.value ) }"
888							aria-label="Remove filter: ${ esc( tag.label ) }">×</button>
889				</span>
890			` ).join( '' );
891
892			activeTags.querySelectorAll( 'button' ).forEach( btn => {
893				btn.addEventListener( 'click', () => {
894					const { name, value } = btn.dataset;
895					if ( name === 'search' ) {
896						if ( searchInput ) searchInput.value = '';
897						state.search = '';
898					} else {
899						const input = bar.querySelector(
900							`input[name="${ name }"][value="${ value }"]`
901						);
902						if ( input ) {
903							input.checked = false;
904							input.dispatchEvent( new Event( 'change', { bubbles: true } ) );
905							return;
906						}
907					}
908					state.page = 1;
909					syncUI();
910					fetchPosts();
911				} );
912			} );
913		}
914
915		function buildAccordionItem( post ) {
916			const itemId  = 'accordion-item-' + post.id;
917			const panelId = itemId + '-panel';
918
919			// Pull category name from embedded terms (requires _embed=true in the fetch).
920			const terms   = post._embedded?.[ 'wp:term' ] ?? [];
921			const cats    = terms.find( group => group[0]?.taxonomy === 'category' ) ?? [];
922			const catName = cats[0]?.name ?? '';
923			const catSlug = catName ? catName.toLowerCase().replace( /\s+/g, '-' ) : '';
924			const color   = cats[0]?.tag_color ?? '';
925
926			const catHtml = catName
927				? `<span class="accordion-category" data-category="${ esc( catSlug ) }">${ esc( catName ) }</span>`
928				: '';
929
930			return `<div class="accordion-item"${ color ? ` style="--category-color: ${ esc( color ) }"` : '' }>
931				<button class="accordion-trigger" type="button"
932					aria-expanded="false"
933					aria-controls="${ esc( panelId ) }"
934					id="${ esc( itemId ) }" data-init>
935					<span class="accordion-question">${ post.title?.rendered ?? '' }</span>
936					${ catHtml }
937					<span class="accordion-chevron" aria-hidden="true">
938						<svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
939							<path d="M1.5 4L6 8L10.5 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/>
940						</svg>
941					</span>
942				</button>
943				<div class="accordion-panel" id="${ esc( panelId ) }"
944					role="region" aria-labelledby="${ esc( itemId ) }" hidden>
945					<div class="accordion-panel-inner">${ post.content?.rendered ?? '' }</div>
946				</div>
947			</div>`;
948		}
949		
950		function openItem( trigger ) {
951			const panel = document.getElementById( trigger.getAttribute( 'aria-controls' ) );
952			trigger.setAttribute( 'aria-expanded', 'true' );
953			trigger.closest( '.accordion-item' )?.classList.add( 'is-open' );
954			if ( panel ) panel.removeAttribute( 'hidden' );
955		}
956
957		function closeItem( trigger ) {
958			const panel = document.getElementById( trigger.getAttribute( 'aria-controls' ) );
959			trigger.setAttribute( 'aria-expanded', 'false' );
960			trigger.closest( '.accordion-item' )?.classList.remove( 'is-open' );
961			if ( panel ) panel.setAttribute( 'hidden', '' );
962		}
963
964		function wireAccordionTrigger( trigger ) {
965			trigger.addEventListener( 'click', function () {
966				const isOpen = trigger.getAttribute( 'aria-expanded' ) === 'true';
967				isOpen ? closeItem( trigger ) : openItem( trigger );
968			} );
969		}
970
971	}
972
973	// ── Boot ──────────────────────────────────────────────────────────────────
974
975	document.addEventListener( 'DOMContentLoaded', () => {
976		document.querySelectorAll( '.resource-filter-bar' ).forEach( initFilterBar );
977		initLowerLabels( document );
978	} );
979
980} () );

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.