, outside every landmark
and visible. Relocate it into main and hide it visually — it still
announces from there. */
const srStatus = document.getElementById('searchAccessibilityContainer');
const mainEl = document.querySelector('main');
if (srStatus && mainEl) {
srStatus.classList.add('sr-only');
if (srStatus.parentElement !== mainEl) mainEl.appendChild(srStatus);
}
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-facet-toggle]');
if (!btn) return;
const group = btn.closest('[data-facet-group]')?.dataset.facetGroup;
if (!group) return;
expandedFacetGroups[group] = !expandedFacetGroups[group];
syncFacetGroups();
});
/* ----------------------------------------------------------------
Mobile filter toggle
---------------------------------------------------------------- */
document.addEventListener('click', (e) => {
const filterBtn = e.target.closest('#mobile-filter-toggle');
if (!filterBtn) return;
const container = document.getElementById('searchstax-facets-container');
if (!container) return;
const isOpen = container.classList.toggle('filters-open');
filterBtn.setAttribute('aria-expanded', String(isOpen));
filterBtn.classList.toggle('collapsed', !isOpen);
});
/* ----------------------------------------------------------------
Move the facets container between the sidebar (desktop) and
below the Filter button (mobile). The widget re-renders into the
element by ID, so relocating the element itself is safe.
---------------------------------------------------------------- */
const facetsContainer = document.getElementById('searchstax-facets-container');
const facetsMobileSlot = document.getElementById('facets-mobile-slot');
const facetsDesktopSlot = document.getElementById('facets-desktop-slot');
const isDesktop = window.matchMedia('(min-width: 992px)');
function placeFacets(mq) {
const target = mq.matches ? facetsDesktopSlot : facetsMobileSlot;
if (facetsContainer && target && facetsContainer.parentElement !== target) {
target.appendChild(facetsContainer);
}
}
placeFacets(isDesktop);
isDesktop.addEventListener('change', placeFacets);
/* Facet markup is replaced on every search, and the re-rendered panel has no
`show` class. Restore state synchronously — debouncing this paints the
collapsed state first. Scoped to the facets container so it isn't competing
with the results burst. */
const facetsObserver = new MutationObserver(() => syncFacetGroups(true));
if (facetsContainer) {
facetsObserver.observe(facetsContainer, {childList: true, subtree: true});
}
/* ----------------------------------------------------------------
Which tab is showing.
The "all results" tab is synthetic, so the widget never checks or
marks it. The URL is the source of truth, and the body class it
sets drives both the tab's active state and hiding the Filter
button (there are no facets to filter on the all-results view).
---------------------------------------------------------------- */
function activeTabFromUrl() {
const params = new URLSearchParams(window.location.search);
for (const [key, value] of params) {
if (key.startsWith('searchstax[facets]') && value.includes('sectionType_s')) {
return decodeHtmlEntities(value.split(':').pop()).toLowerCase();
}
}
return '';
}
function syncTabView() {
const active = activeTabFromUrl() || 'all results';
document.body.classList.toggle('all-results-view', active === 'all results');
document.body.classList.toggle('tabbed-view', active !== 'all results');
document.querySelectorAll('#searchstax-tabs-container .searchstax-facet-input').forEach(item => {
const cb = item.querySelector('.searchstax-facet-input-checkbox');
const link = item.querySelector('.searchstax-facet-value-label');
if (!cb || !link) return;
const isActive = (cb.getAttribute('aria-label') || '').toLowerCase() === active;
cb.checked = isActive;
link.classList.toggle('active', isActive);
link.setAttribute('aria-pressed', String(isActive));
});
}
// Landing page: no search has run yet, so no facets exist to filter.
syncTabView();
/* ----------------------------------------------------------------
Applied filters row
Rendered from the facet checkbox state rather than the widget's
own pill markup, so it can live in the refine bar. Removal is
delegated back to the widget by clicking its checkboxes.
---------------------------------------------------------------- */
const appliedRoot = document.getElementById('applied-filters');
let appliedFilters = [];
const escapeHtml = (s) => s.replace(/[&<>"']/g, c => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[c]));
function readAppliedFilters() {
const boxes = document.querySelectorAll(
'#searchstax-facets-container .searchstax-facet-input-checkbox:checked'
);
const seen = new Set();
const out = [];
Array.from(boxes).forEach(cb => {
const group = cb.closest('fieldset')?.querySelector('legend')?.textContent.trim() || '';
if (/section type/i.test(group)) return; // owned by the tabs, not a user filter
const label = cb.closest('.form-check')?.querySelector('label');
let text = cb.getAttribute('aria-label') || '';
if (label) {
const clone = label.cloneNode(true);
clone.querySelectorAll('span').forEach(s => s.remove()); // drop the "(18)"
text = clone.textContent.trim();
}
const key = `${group}|${text}`;
if (seen.has(key)) return; // desktop + mobile markup both hold checkboxes
seen.add(key);
out.push({checkbox: cb, label: text, key});
});
return out;
}
function renderAppliedFilters() {
if (!appliedRoot) return;
appliedFilters = readAppliedFilters();
const signature = appliedFilters.map(f => f.key).join('|');
if (signature === appliedRoot.dataset.signature) return; // no change, skip
appliedRoot.dataset.signature = signature;
if (!appliedFilters.length) {
appliedRoot.hidden = true;
appliedRoot.innerHTML = '';
return;
}
appliedRoot.hidden = false;
appliedRoot.innerHTML = `
Applied filters:
${appliedFilters.map((f, i) => `
`).join('')}
`;
}
function clearAppliedFilters() {
if (!appliedRoot) return;
appliedFilters = [];
appliedRoot.dataset.signature = '';
appliedRoot.innerHTML = '';
appliedRoot.hidden = true;
}
// Fallback only: used if the widget's clear-all button is absent from
// the DOM. Fires one search per filter, so the clearAll path wins.
function clearFiltersSequentially(guard = 0) {
if (guard > 20) return; // safety valve
const remaining = readAppliedFilters(); // re-read after every re-render
if (!remaining.length) return;
remaining[0].checkbox.click();
setTimeout(() => clearFiltersSequentially(guard + 1), 400);
}
appliedRoot?.addEventListener('click', (e) => {
const pill = e.target.closest('.applied-filters__pill');
if (pill) {
appliedFilters[Number(pill.dataset.filterIndex)]?.checkbox.click();
return;
}
if (e.target.closest('.applied-filters__clear')) {
const clearAll = document.querySelector(
'#searchstax-facets-container .searchstax-facets-pill-clear-all'
);
clearAppliedFilters(); // hide immediately
if (clearAll) {
clearAll.click();
} else {
clearFiltersSequentially();
}
setTimeout(renderAppliedFilters, 300); // reconcile once the widget settles
}
});
/* ----------------------------------------------------------------
Re-apply our DOM tweaks after every widget re-render.
Debounced: the widget fires a burst of mutations while rendering
results, and every pass here is idempotent anyway.
---------------------------------------------------------------- */
let syncTimer = null;
const observer = new MutationObserver(() => {
clearTimeout(syncTimer);
syncTimer = setTimeout(() => {
const relevanceOption = document.querySelector('#searchstax-search-order-select option[value=""]');
if (relevanceOption && !relevanceOption.dataset.translated) {
relevanceOption.textContent = 'Relevance';
relevanceOption.dataset.translated = "true";
}
syncTabView();
renderAppliedFilters();
}, 50);
});
observer.observe(document.body, {
childList: true,
subtree: true
});
};
document.head.appendChild(script);
};