diff --git a/index.html b/index.html
index 84aa957..186be8c 100644
--- a/index.html
+++ b/index.html
@@ -20,6 +20,7 @@
Regular pages
Contextual search page
Search without Query Suggestions (QS)
Search with custom QS configuration
+ Search page with facets
Page de recherche (base)
Page de recherche (avancée)
Page de recherche (contextuelle)
diff --git a/src/connector.css b/src/connector.css
index c9339c1..03f59a0 100644
--- a/src/connector.css
+++ b/src/connector.css
@@ -50,3 +50,36 @@
width: 100%;
}
}
+
+/*
+ * Facet sidebar layout
+ */
+.gc-facet-toggle .glyphicon-chevron-left {
+ display: inline-block;
+ transition: transform 0.2s ease;
+}
+
+/* Rotate chevron to point right when the panel is collapsed */
+.gc-facet-toggle[aria-expanded="false"] .glyphicon-chevron-left {
+ transform: rotate(180deg);
+}
+
+.gc-facet-values li {
+ overflow-wrap: break-word;
+ position: relative;
+}
+
+/* Stretch the link click area to the full row without affecting its visual appearance */
+.gc-facet-values a::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+}
+
+.gc-date-pickers .form-control,
+.gc-facet-search {
+ width: 100%;
+}
diff --git a/src/connector.js b/src/connector.js
index d4928b4..fbde4b6 100644
--- a/src/connector.js
+++ b/src/connector.js
@@ -11,6 +11,11 @@ import {
buildContext,
buildInteractiveResult,
buildNotifyTrigger,
+ buildFacet,
+ buildDateFacet,
+ buildDateFilter,
+ buildDateRange,
+ buildBreadcrumbManager,
loadAdvancedSearchQueryActions,
loadSortCriteriaActions,
HighlightUtils,
@@ -41,7 +46,8 @@ const defaults = {
"originLevel3": originPath,
"pipeline": "",
"automaticallyCorrectQuery": false,
- "numberOfPages": 9
+ "numberOfPages": 9,
+ "facets": []
};
let lang = document.querySelector( "html" )?.lang;
let paramsOverride = baseElement ? JSON.parse( baseElement.dataset.gcSearch ) : {};
@@ -69,6 +75,17 @@ let unsubscribeQuerySummaryController;
let unsubscribeDidYouMeanController;
let unsubscribePagerController;
let unsubscribeNotifyTriggerController;
+let breadcrumbManagerController;
+let unsubscribeBreadcrumbManagerController;
+
+let dateFilterControllers = [];
+let dateFilterStates = [];
+let facetControllers = [];
+let facetNormalizedConfigs = [];
+let facetSearchTimers = [];
+let facetStates = [];
+let unsubscribeDateFilterControllers = [];
+let unsubscribeFacetControllers = [];
// UI states
let updateSearchBoxFromState = false;
@@ -82,6 +99,30 @@ let lastCharKeyUp;
let activeSuggestion = 0;
let pagerManuallyCleared = false;
+const localizedStrings = {
+ en: new Map(),
+ fr: new Map()
+};
+localizedStrings.en.set( "facets.showMore", "Show more" );
+localizedStrings.en.set( "breadbox.filters", "Filters:" );
+localizedStrings.fr.set( "breadbox.filters", "Filtres\u00a0:" );
+localizedStrings.en.set( "breadbox.clear", "Clear" );
+localizedStrings.fr.set( "breadbox.clear", "Effacer" );
+localizedStrings.en.set( "date-ranges.past-1-day|now", "Past day" );
+localizedStrings.fr.set( "date-ranges.past-1-day|now", "Derni\u00e8re journ\u00e9e" );
+localizedStrings.en.set( "date-ranges.past-1-week|now", "Past week" );
+localizedStrings.fr.set( "date-ranges.past-1-week|now", "Derni\u00e8re semaine" );
+localizedStrings.en.set( "date-ranges.past-1-month|now", "Past month" );
+localizedStrings.fr.set( "date-ranges.past-1-month|now", "Dernier mois" );
+localizedStrings.en.set( "date-ranges.past-1-year|now", "Past year" );
+localizedStrings.fr.set( "date-ranges.past-1-year|now", "Derni\u00e8re ann\u00e9e" );
+localizedStrings.en.set( "date-ranges.past-100-year|past-1-year", "Older" );
+localizedStrings.fr.set( "date-ranges.past-100-year|past-1-year", "Plus ancien" );
+localizedStrings.en.set( "date-ranges.before", "Before {{date}}" );
+localizedStrings.fr.set( "date-ranges.before", "Avant le {{date}}" );
+localizedStrings.en.set( "date-ranges.after", "After {{date}}" );
+localizedStrings.fr.set( "date-ranges.after", "Apr\u00e8s le {{date}}" );
+
// Firefox patch
let isFirefox = navigator.userAgent.indexOf( "Firefox" ) !== -1;
let waitForkeyUp = false;
@@ -97,6 +138,9 @@ let notificationTriggerElement = document.querySelector( '#notification-trigger'
let pagerElement = document.querySelector( '#pager' );
let suggestionsElement = document.querySelector( '#suggestions' );
let didYouMeanElement = document.querySelector( '#did-you-mean' );
+let breadcrumbElement = document.querySelector( '#breadcrumb-manager' );
+let facetSidebarElement = document.querySelector( '#gc-facet-sidebar' );
+let facetPanelElement = document.querySelector( '#gc-facet-panel' );
// UI templates
let resultTemplateHTML = document.getElementById( 'sr-single' )?.innerHTML;
@@ -111,6 +155,18 @@ let pageTemplateHTML = document.getElementById( 'sr-pager-page' )?.innerHTML;
let nextPageTemplateHTML = document.getElementById( 'sr-pager-next' )?.innerHTML;
let pagerContainerTemplateHTML = document.getElementById( 'sr-pager-container' )?.innerHTML;
let qsA11yHintHTML = document.getElementById( 'sr-qs-hint' )?.innerHTML;
+let facetSummaryTemplateHTML = document.getElementById( 'sr-facet-summary' )?.innerHTML;
+let facetClearFilterTemplateHTML = document.getElementById( 'sr-facet-clear-filter' )?.innerHTML;
+let facetItemTemplateHTML = document.getElementById( 'sr-facet-item' )?.innerHTML;
+let facetSearchInputTemplateHTML = document.getElementById( 'sr-facet-search-input' )?.innerHTML;
+let facetShowMoreTemplateHTML = document.getElementById( 'sr-facet-show-more' )?.innerHTML;
+let facetShowLessTemplateHTML = document.getElementById( 'sr-facet-show-less' )?.innerHTML;
+let facetDatePickerTemplateHTML = document.getElementById( 'sr-facet-date-picker' )?.innerHTML;
+let facetToggleTemplateHTML = document.getElementById( 'sr-facet-toggle' )?.innerHTML;
+let facetPanelItemTemplateHTML = document.getElementById( 'sr-facet-panel-item' )?.innerHTML;
+let facetLayoutTemplateHTML = document.getElementById( 'sr-facet-layout' )?.innerHTML;
+let breadcrumbItemTemplateHTML = document.getElementById( 'sr-breadcrumb-item' )?.innerHTML;
+let breadcrumbListTemplateHTML = document.getElementById( 'sr-breadcrumb-list' )?.innerHTML;
// Init parameters and UI
function initSearchUI() {
@@ -379,13 +435,164 @@ function initTpl() {
else {
qsA11yHintHTML =
`Press the up and down arrow keys to move through the search suggestions. Press Enter on a suggestion once to select it and start the search.
`;
- }
+ }
+ }
+
+ if ( !facetSummaryTemplateHTML ) {
+ facetSummaryTemplateHTML =
+ `%[label]%[clearBtn]`;
+ }
+
+ if ( !facetClearFilterTemplateHTML ) {
+ if ( lang === 'fr' ) {
+ facetClearFilterTemplateHTML =
+ ``;
+ } else {
+ facetClearFilterTemplateHTML =
+ ``;
+ }
+ }
+
+ if ( !facetItemTemplateHTML ) {
+ if ( lang === 'fr' ) {
+ facetItemTemplateHTML =
+ ``;
+ } else {
+ facetItemTemplateHTML =
+ ``;
+ }
+ }
+
+ if ( !facetSearchInputTemplateHTML ) {
+ if ( lang === 'fr' ) {
+ facetSearchInputTemplateHTML =
+ ``;
+ } else {
+ facetSearchInputTemplateHTML =
+ ``;
+ }
+ }
+
+ if ( !facetShowMoreTemplateHTML ) {
+ if ( lang === 'fr' ) {
+ facetShowMoreTemplateHTML =
+ ``;
+ } else {
+ facetShowMoreTemplateHTML =
+ ``;
+ }
+ }
+
+ if ( !facetShowLessTemplateHTML ) {
+ if ( lang === 'fr' ) {
+ facetShowLessTemplateHTML =
+ ``;
+ } else {
+ facetShowLessTemplateHTML =
+ ``;
+ }
+ }
+
+ if ( !facetDatePickerTemplateHTML ) {
+ if ( lang === 'fr' ) {
+ facetDatePickerTemplateHTML =
+ `
+
+
+
+
+
`;
+ } else {
+ facetDatePickerTemplateHTML =
+ ``;
+ }
}
- // auto-create results
+ if ( !facetToggleTemplateHTML ) {
+ if ( lang === 'fr' ) {
+ facetToggleTemplateHTML =
+ ``;
+ } else {
+ facetToggleTemplateHTML =
+ ``;
+ }
+ }
+
+ if ( !facetPanelItemTemplateHTML ) {
+ facetPanelItemTemplateHTML =
+ ` `;
+ }
+
+ if ( !facetLayoutTemplateHTML ) {
+ if ( lang === 'fr' ) {
+ facetLayoutTemplateHTML =
+ ``;
+ } else {
+ facetLayoutTemplateHTML =
+ ``;
+ }
+ }
+
+ if ( !breadcrumbItemTemplateHTML ) {
+ breadcrumbItemTemplateHTML =
+ ``;
+ }
+
+ if ( !breadcrumbListTemplateHTML ) {
+ breadcrumbListTemplateHTML =
+ `- %[filtersLabel]
%[items]
`;
+ }
+
+ // auto-create results section (facet layout provides it when configured; otherwise create standalone)
if ( !resultsSection ) {
resultsSection = document.createElement( "section" );
resultsSection.id = resultSectionID;
+ baseElement.append( resultsSection );
}
// auto-create notification trigger element
@@ -404,6 +611,15 @@ function initTpl() {
resultsSection.append( querySummaryElement );
}
+ // auto-create breadcrumb element (after query-summary, before did-you-mean)
+ if ( !breadcrumbElement && params.facets?.length ) {
+ breadcrumbElement = document.createElement( "div" );
+ breadcrumbElement.id = "breadcrumb-manager";
+ breadcrumbElement.hidden = true;
+
+ resultsSection.append( breadcrumbElement );
+ }
+
// auto-create did you mean element
if ( !didYouMeanElement ) {
didYouMeanElement = document.createElement( "div" );
@@ -423,11 +639,9 @@ function initTpl() {
// auto-create pager
if ( !pagerElement ) {
- let newPagerElement = document.createElement( "div" );
- newPagerElement.innerHTML = pagerContainerTemplateHTML;
-
- resultsSection.append( newPagerElement );
- pagerElement = newPagerElement;
+ pagerElement = document.createElement( "div" );
+ pagerElement.innerHTML = pagerContainerTemplateHTML;
+ resultsSection.append( pagerElement );
}
// initialize the search box
@@ -465,6 +679,41 @@ function initTpl() {
} );
}
}
+
+ // initialize facets
+ if ( params.facets?.length ) {
+ const facetConfigMap = new Map();
+ params.facets.forEach( ( raw ) => {
+ const config = normalizeFacetConfig( raw );
+ if ( config ) facetConfigMap.set( config.facetId, config );
+ } );
+ facetNormalizedConfigs = [ ...facetConfigMap.values() ];
+
+ if ( facetNormalizedConfigs.length > 0 && !facetPanelElement ) {
+ const facetItems = facetNormalizedConfigs.map( ( config, index ) => {
+ const item = facetPanelItemTemplateHTML.replace( '%[facetId]', config.facetId );
+ return index > 0 ? item.replace( 'class="gc-facet"', 'class="gc-facet mrgn-tp-md"' ) : item;
+ } ).join( '' );
+
+ baseElement.insertAdjacentHTML( 'beforeend',
+ facetToggleTemplateHTML +
+ facetLayoutTemplateHTML.replace( '%[facetItems]', facetItems )
+ );
+
+ // Store references and attach event handlers after insertion
+ facetSidebarElement = document.getElementById( 'gc-facet-sidebar' );
+ facetPanelElement = document.getElementById( 'gc-facet-panel' );
+ document.getElementById( 'gc-results-col' ).append( resultsSection );
+ document.getElementById( 'gc-facet-toggle' ).onclick = toggleFacetSidebar;
+ document.querySelector( '#gc-facet-clear-all-container .btn-link' ).onclick = () => {
+ facetControllers.forEach( ( c ) => c?.deselectAll() );
+ dateFilterControllers.forEach( ( c ) => c?.clear() );
+ };
+
+ // Apply mobile defaults (sidebar hidden, facets collapsed) and restore any persisted state
+ applyFacetUIDefaults();
+ }
+ }
}
// Detect if localStorage is available
@@ -476,6 +725,111 @@ function hasLocalStorage() {
}
}
+// Detect if sessionStorage is available
+function hasSessionStorage() {
+ try {
+ sessionStorage.setItem( '__test', '1' );
+ sessionStorage.removeItem( '__test' );
+ return true;
+ } catch ( error ) {
+ return false;
+ }
+}
+
+// Returns true if the viewport is mobile (below Bootstrap's col-md breakpoint)
+function isMobileView() {
+ return window.innerWidth < 992;
+}
+
+// Session storage key for facet UI state
+const FACET_UI_STATE_KEY = 'gc-facet-ui-state';
+
+// Load persisted facet UI state from sessionStorage
+function loadFacetUIState() {
+ if ( !hasSessionStorage() ) { return null; }
+ try {
+ const raw = sessionStorage.getItem( FACET_UI_STATE_KEY );
+ return raw ? JSON.parse( raw ) : null;
+ } catch ( error ) {
+ return null;
+ }
+}
+
+// Apply default facet UI state based on viewport, then overlay any persisted sessionStorage state.
+// Desktop defaults: sidebar visible, facets open.
+// Mobile defaults: sidebar hidden, facets collapsed.
+function applyFacetUIDefaults() {
+ const mobile = isMobileView();
+ const toggleBtn = document.getElementById( 'gc-facet-toggle' );
+ const resultsCol = document.getElementById( 'gc-results-col' );
+ const saved = loadFacetUIState();
+
+ // Determine sidebar visibility: prefer saved value, else use viewport default
+ const sidebarVisible = saved?.sidebarVisible !== undefined ? saved.sidebarVisible : !mobile;
+ if ( toggleBtn ) {
+ toggleBtn.setAttribute( 'aria-expanded', String( sidebarVisible ) );
+ }
+ if ( facetSidebarElement ) {
+ facetSidebarElement.hidden = !sidebarVisible;
+ }
+ if ( resultsCol ) {
+ resultsCol.classList.toggle( 'col-md-8', sidebarVisible );
+ resultsCol.classList.toggle( 'col-md-12', !sidebarVisible );
+ }
+
+ // Determine facet open state: default is open on desktop, closed on mobile
+ const defaultFacetsOpen = !mobile;
+ document.querySelectorAll( '.gc-facet' ).forEach( ( el ) => {
+ const savedOpen = saved?.facetsOpen?.[ el.id ];
+ el.open = savedOpen !== undefined ? savedOpen : defaultFacetsOpen;
+
+ // Persist state whenever the user manually toggles a facet
+ el.addEventListener( 'toggle', saveFacetUIState );
+ } );
+}
+
+// Save facet UI state to sessionStorage, only persisting values that differ from the defaults
+// Desktop defaults: sidebar visible, all facets open
+// Mobile defaults: sidebar hidden, all facets closed
+function saveFacetUIState() {
+ if ( !hasSessionStorage() ) { return; }
+
+ const mobile = isMobileView();
+ const defaultSidebarVisible = !mobile;
+ const defaultFacetsOpen = !mobile;
+
+ const toggleBtn = document.getElementById( 'gc-facet-toggle' );
+ const currentSidebarVisible = toggleBtn?.getAttribute( 'aria-expanded' ) === 'true';
+
+ const state = {};
+
+ // Only save sidebar visibility if it differs from the default
+ if ( currentSidebarVisible !== defaultSidebarVisible ) {
+ state.sidebarVisible = currentSidebarVisible;
+ }
+
+ // Only save facet open/closed states that differ from the default
+ const facetEls = document.querySelectorAll( '.gc-facet' );
+ const facetOpenOverrides = {};
+ let hasFacetOverrides = false;
+ facetEls.forEach( ( el ) => {
+ if ( el.open !== defaultFacetsOpen ) {
+ facetOpenOverrides[ el.id ] = el.open;
+ hasFacetOverrides = true;
+ }
+ } );
+ if ( hasFacetOverrides ) {
+ state.facetsOpen = facetOpenOverrides;
+ }
+
+ // If everything matches defaults, clear any saved state
+ if ( Object.keys( state ).length === 0 ) {
+ sessionStorage.removeItem( FACET_UI_STATE_KEY );
+ } else {
+ sessionStorage.setItem( FACET_UI_STATE_KEY, JSON.stringify( state ) );
+ }
+}
+
// Limit actions history array to items newer than 7 days
function limitCoveoAnalyticsHistory( actionsHistory ) {
const now = new Date();
@@ -511,6 +865,122 @@ function sanitizeQuery(q) {
return q.replace(/<[^>]*>?/gm, '');
}
+// Normalize a single raw facet config entry from the HTML attribute.
+// Returns a clean config object, or null if the entry is invalid.
+function normalizeFacetConfig(raw) {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ return null;
+ }
+
+ const field = raw.field?.trim();
+ if (!field) {
+ return null;
+ }
+
+ const facetType = raw.facetType === 'dateRange' ? 'dateRange' : 'regular';
+
+ const defaults =
+ facetType === 'dateRange' ? {
+ withDatePicker: true,
+ withDateRanges: true,
+ } : {
+ numberOfValues: 8,
+ sortCriteria: 'occurrences',
+ facetSearch: true,
+ };
+
+ const normalizedFields = {
+ field,
+ facetType,
+ label: raw.label?.trim() || raw.title?.trim() || field,
+ facetId: raw.facetId?.trim() || field,
+ filterFacetCount: raw.filterFacetCount ?? true,
+ };
+
+ return {
+ ...defaults,
+ ...raw,
+ ...normalizedFields,
+ };
+}
+
+// Convert YYYY-MM-DD (date input value) to Coveo date string
+function inputDateToCoveoDate( dateStr, endOfDay ) {
+ if ( !dateStr ) { return ''; }
+ return dateStr.replace( /-/g, '/' ) + ( endOfDay ? '@23:59:59' : '@00:00:00' );
+}
+
+// Convert a Coveo date string to YYYY-MM-DD for a date input
+function coveoDateToInputDate( coveoDate ) {
+ if ( !coveoDate ) { return ''; }
+ return String( coveoDate ).slice( 0, 10 ).replace( /\//g, '-' );
+}
+
+// Resolve a Coveo range endpoint (string or relative object) to a YYYY-MM-DD input date string
+function resolveRangeEndpointToInputDate( endpoint ) {
+ if ( typeof endpoint === 'string' ) {
+ return coveoDateToInputDate( endpoint );
+ }
+ if ( endpoint && endpoint.period === 'now' ) {
+ return '';
+ }
+ if ( endpoint && endpoint.period === 'past' ) {
+ const d = new Date();
+ if ( endpoint.unit === 'day' ) { d.setDate( d.getDate() - endpoint.amount ); }
+ else if ( endpoint.unit === 'week' ) { d.setDate( d.getDate() - endpoint.amount * 7 ); }
+ else if ( endpoint.unit === 'month' ) { d.setMonth( d.getMonth() - endpoint.amount ); }
+ else if ( endpoint.unit === 'year' ) { d.setFullYear( d.getFullYear() - endpoint.amount ); }
+ return d.toISOString().slice( 0, 10 );
+ }
+ return '';
+}
+
+// Predefined relative date periods for the date facet (start is relative, end is now)
+function getDateFacetFields () {
+ return [
+ {
+ labelKey: "date-ranges.past-1-day|now",
+ range: buildDateRange({
+ start: { period: "past", unit: "day", amount: 1 },
+ end: { period: 'now' },
+ endInclusive: true,
+ }),
+ },
+ {
+ labelKey: "date-ranges.past-1-week|now",
+ range: buildDateRange({
+ start: { period: "past", unit: "week", amount: 1 },
+ end: { period: 'now' },
+ endInclusive: true,
+ }),
+ },
+ {
+ labelKey: "date-ranges.past-1-month|now",
+ range: buildDateRange({
+ start: { period: "past", unit: "month", amount: 1 },
+ end: { period: 'now' },
+ endInclusive: true,
+ }),
+ },
+ {
+ labelKey: "date-ranges.past-1-year|now",
+ range: buildDateRange({
+ start: { period: "past", unit: "year", amount: 1 },
+ end: { period: 'now' },
+ endInclusive: true,
+ }),
+ },
+ {
+ labelKey: "date-ranges.past-100-year|past-1-year",
+ range: buildDateRange({
+ start: { period: "past", unit: "year", amount: 100 },
+ end: { period: "past", unit: "year", amount: 1 },
+ endInclusive: false,
+ }),
+ },
+ ];
+}
+
// rebuild a clean query string out of a JSON object
function buildCleanQueryString( paramsObject ) {
let urlParam = "";
@@ -701,6 +1171,56 @@ function initEngine() {
statusController = buildSearchStatus( headlessEngine );
notifyTriggerController = buildNotifyTrigger( headlessEngine );
+ if( params.facets?.length ) {
+
+ // Build a facet controller for each normalized facet config
+ facetNormalizedConfigs.forEach( ( config, index ) => {
+ if ( config.facetType === 'dateRange' ) {
+ const dateFacetController = buildDateFacet( headlessEngine, {
+ options: {
+ field: config.field,
+ facetId: config.facetId,
+ currentValues: getDateFacetFields().map( ( p ) => p.range ),
+ generateAutomaticRanges: false,
+ }
+ } );
+ const dateFilterController = buildDateFilter( headlessEngine, {
+ options: {
+ field: config.field,
+ facetId: config.facetId + '__filter',
+ }
+ } );
+ facetControllers[ index ] = dateFacetController;
+ dateFilterControllers[ index ] = dateFilterController;
+ facetStates[ index ] = dateFacetController.state;
+ dateFilterStates[ index ] = dateFilterController.state;
+ unsubscribeFacetControllers[ index ] = dateFacetController.subscribe(
+ () => updateDateFacetState( index, dateFacetController.state, dateFilterController.state )
+ );
+ unsubscribeDateFilterControllers[ index ] = dateFilterController.subscribe(
+ () => updateDateFacetState( index, dateFacetController.state, dateFilterController.state )
+ );
+ } else {
+ const controller = buildFacet( headlessEngine, {
+ options: {
+ field: config.field,
+ facetId: config.facetId,
+ numberOfValues: config.numberOfValues,
+ sortCriteria: config.sortCriteria,
+ }
+ } );
+ facetControllers[ index ] = controller;
+ facetStates[ index ] = controller.state;
+ unsubscribeFacetControllers[ index ] = controller.subscribe(
+ () => updateFacetState( index, controller.state )
+ );
+ }
+ } );
+
+ breadcrumbManagerController = buildBreadcrumbManager( headlessEngine );
+
+ }
+
// Refine search based on URL parameters for filters, mostly used in Advanced Search to trigger only one search per page load
if ( urlParams.allq || urlParams.exctq || urlParams.anyq || urlParams.noneq || urlParams.fqupdate || urlParams.dmn || urlParams.fqocct || urlParams.elctn_cat || urlParams.filetype || urlParams.site || urlParams.year || urlParams.declaredtype || urlParams.startdate || urlParams.enddate || urlParams.dprtmnt ) {
let q = [];
@@ -935,6 +1455,9 @@ function initEngine() {
unsubscribeDidYouMeanController = didYouMeanController.subscribe( () => updateDidYouMeanState( didYouMeanController.state ) );
unsubscribePagerController = pagerController.subscribe( () => updatePagerState( pagerController.state ) );
unsubscribeNotifyTriggerController = notifyTriggerController.subscribe( () => updateNotifyTriggerState( notifyTriggerController.state ) );
+ if( params.facets?.length ) {
+ unsubscribeBreadcrumbManagerController = breadcrumbManagerController.subscribe( () => updateBreadcrumbState( breadcrumbManagerController.state ) );
+ }
// Clear event tracking, for legacy browsers
const onUnload = () => {
@@ -946,6 +1469,11 @@ function initEngine() {
unsubscribeDidYouMeanController?.();
unsubscribePagerController?.();
unsubscribeNotifyTriggerController?.();
+ if( params.facets?.length ) {
+ unsubscribeFacetControllers.forEach( ( unsub ) => unsub?.() );
+ unsubscribeDateFilterControllers.forEach( ( unsub ) => unsub?.() );
+ unsubscribeBreadcrumbManagerController?.();
+ }
};
// Listen to URL change (hash)
@@ -1034,6 +1562,7 @@ function initEngine() {
didYouMeanElement.textContent = "";
pagerElement.textContent = "";
pagerManuallyCleared = true;
+ updateFacetLayoutVisibility(true);
// Show no results message in Query Summary if no query entered
querySummaryElement.innerHTML = noResultTemplateHTML;
@@ -1102,7 +1631,7 @@ function openSuggestionsBox() {
searchBoxElement.setAttribute( 'aria-expanded', 'true' );
}
-// close the suggestions box
+// close the suggestions box
function closeSuggestionsBox() {
if( !suggestionsElement ) {
return;
@@ -1113,6 +1642,31 @@ function closeSuggestionsBox() {
searchBoxElement.removeAttribute( 'aria-activedescendant' );
}
+// Toggle the facet sidebar between expanded and collapsed
+function toggleFacetSidebar() {
+ if ( !facetSidebarElement || !facetPanelElement ) {
+ return;
+ }
+
+ const toggleBtn = document.getElementById( 'gc-facet-toggle' );
+ const resultsCol = document.getElementById( 'gc-results-col' );
+ const isExpanded = toggleBtn?.getAttribute( 'aria-expanded' ) === 'true';
+
+ if ( isExpanded ) {
+ facetSidebarElement.hidden = true;
+ toggleBtn?.setAttribute( 'aria-expanded', 'false' );
+ resultsCol?.classList.remove( 'col-md-8' );
+ resultsCol?.classList.add( 'col-md-12' );
+ } else {
+ facetSidebarElement.hidden = false;
+ toggleBtn?.setAttribute( 'aria-expanded', 'true' );
+ resultsCol?.classList.remove( 'col-md-12' );
+ resultsCol?.classList.add( 'col-md-8' );
+ }
+
+ saveFacetUIState();
+}
+
// Update the visual selection of the active suggestion
function updateSuggestionSelection() {
// clear current suggestion
@@ -1360,6 +1914,68 @@ function updateQuerySummaryState( newState ) {
}
}
+function formatBreadcrumbLabel( breadcrumb ) {
+ const { start, end, value } = breadcrumb.value ?? {};
+ const formatCoveoDate = ( coveoDate ) => coveoDate ? coveoDate.split( '@' )[ 0 ].replace( /\//g, '-' ) : "";
+
+ if ( start !== undefined && end !== undefined ) {
+ const rangeLabel = localizedStrings[ params.lang ].get( `date-ranges.${ start }|${ end }` );
+ if ( rangeLabel ) {
+ return rangeLabel;
+ } else if ( start === 'past-100-year' ) {
+ return localizedStrings[ params.lang ].get( "date-ranges.before" ).replace( '{{date}}', formatCoveoDate( end ) );
+ } else if ( end === 'now' ) {
+ return localizedStrings[ params.lang ].get( "date-ranges.after" ).replace( '{{date}}', formatCoveoDate( start ) );
+ } else {
+ return `${ formatCoveoDate( start ) } - ${ formatCoveoDate( end ) }`;
+ }
+ }
+ return value ?? "";
+}
+
+function renderBreadcrumbItemHTML( facetLabel, breadcrumb ) {
+ const displayValue = formatBreadcrumbLabel( breadcrumb );
+ const label = `${ facetLabel }: ${ displayValue }`;
+ return breadcrumbItemTemplateHTML
+ .replace( '%[ariaLabel]', label )
+ .replace( '%[label]', label );
+}
+
+// Update breadcrumb (active filter) display
+function updateBreadcrumbState( newState ) {
+ if ( !breadcrumbElement ) return;
+
+ const facetBreadcrumbs = newState.facetBreadcrumbs || [];
+ const dateFacetBreadcrumbs = newState.dateFacetBreadcrumbs || [];
+ const allBreadcrumbs = [ ...facetBreadcrumbs, ...dateFacetBreadcrumbs ];
+
+ if ( allBreadcrumbs.length === 0 ) {
+ breadcrumbElement.hidden = true;
+ breadcrumbElement.textContent = "";
+ return;
+ }
+
+ const itemsHTML = allBreadcrumbs.map( ( facet ) => {
+ const configMatch = facetNormalizedConfigs.find( ( c ) => c.facetId === facet.facetId || c.field === facet.field );
+ const facetLabel = configMatch?.label || facet.facetDisplayName || facet.field;
+ return facet.values.map( ( breadcrumb ) => renderBreadcrumbItemHTML( facetLabel, breadcrumb ) ).join( '' );
+ } ).join( '' );
+
+ breadcrumbElement.hidden = false;
+ breadcrumbElement.innerHTML = breadcrumbListTemplateHTML
+ .replace( '%[filtersLabel]', localizedStrings[ params.lang ].get( 'breadbox.filters' ) )
+ .replace( '%[items]', itemsHTML )
+ .replace( '%[clearLabel]', localizedStrings[ params.lang ].get( 'breadbox.clear' ) );
+
+ // Attach deselect handlers to each breadcrumb button by index
+ const allValues = allBreadcrumbs.flatMap( ( facet ) => facet.values );
+ breadcrumbElement.querySelectorAll( '.btn-default' ).forEach( ( btn, i ) => {
+ btn.onclick = () => { allValues[ i ].deselect(); };
+ } );
+
+ breadcrumbElement.querySelector( '.btn-link' ).onclick = () => { breadcrumbManagerController.deselectAll(); };
+}
+
// update "Did you mean" recommendation
function updateDidYouMeanState( newState ) {
didYouMeanState = newState;
@@ -1462,6 +2078,327 @@ function updatePagerState( newState ) {
pagerComponentElement.appendChild( nextLiNode );
}
+// Rebuild a single facet's DOM inside the facet panel
+function announceFacetChange( message ) {
+ const liveEl = document.getElementById( 'gc-facet-live' );
+ if ( !liveEl ) { return; }
+ liveEl.textContent = '';
+ // Brief timeout ensures screen readers detect the content change
+ setTimeout( () => { liveEl.textContent = message; }, 50 );
+}
+
+function renderFacetSummaryHTML( label, hasActive ) {
+ return facetSummaryTemplateHTML
+ .replace( '%[labelId]', label.toLowerCase().replace( /\s+/g, '-' ) )
+ .replace( '%[label]', label )
+ .replace( '%[clearBtn]', hasActive ? facetClearFilterTemplateHTML : '' );
+}
+
+// Returns HTML string for a single facet value .
+function renderFacetItemHTML( label, count, isSelected ) {
+ return facetItemTemplateHTML
+ .replace( '%[checked]', isSelected ? 'checked' : '' )
+ .replace( '%[label]', label )
+ .replace( '%[count]', count.toLocaleString( lang ) );
+}
+
+function updateFacetState( index, newState ) {
+ facetStates[ index ] = newState;
+
+ if ( !facetPanelElement || newState.isLoading ) {
+ return;
+ }
+
+ const config = facetNormalizedConfigs[ index ];
+ const facetEl = document.getElementById( 'gc-facet-' + config.facetId );
+
+ if ( !facetEl ) {
+ return;
+ }
+
+ facetEl.hidden = newState.values.length === 0;
+ if ( facetEl.hidden ) {
+ updateFacetLayoutVisibility();
+ return;
+ }
+
+ // Preserve search focus and open/closed state across re-renders
+ const searchInputId = 'gc-facet-search-' + index;
+ const wasSearchFocused = document.activeElement?.id === searchInputId;
+ const wasOpen = facetEl.open;
+
+ // Facet search input (only if the controller exposes facetSearch)
+ // facetSearch methods live on the sub-controller; state is nested in newState.facetSearch
+ const facetSearch = facetControllers[ index ].facetSearch;
+ const facetSearchState = newState.facetSearch;
+ const isSearching = ( facetSearchState?.query?.length ?? 0 ) > 0;
+
+ const listId = 'gc-facet-values-' + index;
+ const labelId = 'gc-facet-label-' + config.label.toLowerCase().replace( /\s+/g, '-' );
+ const isFr = lang === 'fr';
+
+ // Values list — show facet search results when a query is active, otherwise regular values
+ const itemsHTML = isSearching ?
+ facetSearchState.values.map( ( r ) => renderFacetItemHTML( stripHtml( r.displayValue ), r.count, false ) ).join( '' ) :
+ newState.values.map( ( v ) => renderFacetItemHTML( stripHtml( v.value ), v.numberOfResults, v.state === 'selected' ) ).join( '' );
+
+ // When the user is actively typing in the search box, only patch the values list
+ // in-place rather than tearing down and rebuilding the whole facet — otherwise the
+ // search results update destroys the focused input and moves focus / resets its value.
+ if ( wasSearchFocused && config.facetSearch && facetSearchState ) {
+ const listEl = facetEl.querySelector( '#' + listId );
+ if ( listEl ) {
+ listEl.innerHTML = itemsHTML;
+ listEl.querySelectorAll( 'input[type="checkbox"]' ).forEach( ( checkbox, i ) => {
+ checkbox.onchange = () => { facetSearch.select( facetSearchState.values[ i ] ); };
+ } );
+ return;
+ }
+ }
+
+ const searchHTML = config.facetSearch && facetSearchState ?
+ facetSearchInputTemplateHTML
+ .replace( '%[id]', searchInputId )
+ .replace( '%[facetLabel]', config.label )
+ .replace( '%[value]', '' ) :
+ '';
+
+ facetEl.innerHTML =
+ renderFacetSummaryHTML( config.label, newState.hasActiveValues ) +
+ searchHTML +
+ `` +
+ facetShowMoreTemplateHTML.replace( '%[listId]', listId ) +
+ facetShowLessTemplateHTML.replace( '%[listId]', listId );
+
+ const showMoreBtn = facetEl.querySelector( '.gc-facet-show-more' );
+ const showLessBtn = facetEl.querySelector( '.gc-facet-show-less' );
+ if ( isSearching || !newState.canShowMoreValues ) { showMoreBtn.hidden = true; }
+ if ( isSearching || !newState.canShowLessValues ) { showLessBtn.hidden = true; }
+
+ facetEl.open = wasOpen;
+
+ // Attach event handlers
+ if ( newState.hasActiveValues ) {
+ facetEl.querySelector( '.gc-facet-clear' ).onclick = ( e ) => { e.stopPropagation(); facetControllers[ index ].deselectAll(); };
+ }
+
+ if ( config.facetSearch && facetSearchState ) {
+ const searchInput = facetEl.querySelector( '#' + searchInputId );
+ searchInput.oninput = () => {
+ clearTimeout( facetSearchTimers[ index ] );
+ const query = searchInput.value;
+ if ( query.length >= 2 ) {
+ facetSearchTimers[ index ] = setTimeout( () => {
+ facetSearch.updateText( query );
+ facetSearch.search();
+ }, 300 );
+ } else {
+ facetSearch.updateText( '' );
+ }
+ };
+ if ( wasSearchFocused ) { searchInput.focus(); }
+ }
+
+ facetEl.querySelectorAll( '.gc-facet-values input[type="checkbox"]' ).forEach( ( checkbox, i ) => {
+ if ( isSearching ) {
+ checkbox.onchange = () => { facetSearch.select( facetSearchState.values[ i ] ); };
+ } else {
+ checkbox.onchange = () => { facetControllers[ index ].toggleSelect( newState.values[ i ] ); };
+ }
+ } );
+
+ showMoreBtn.onclick = () => { facetControllers[ index ].showMoreValues(); };
+ showLessBtn.onclick = () => { facetControllers[ index ].showLessValues(); };
+
+ if ( newState.hasActiveValues ) {
+ const activeLabels = newState.values.filter( ( v ) => v.state === 'selected' ).map( ( v ) => v.value ).join( ', ' );
+ announceFacetChange( isFr ? `Filtre actif\u00a0: ${activeLabels}` : `Filter active: ${activeLabels}` );
+ }
+
+ updateFacetLayoutVisibility();
+ updateClearAllVisibility();
+}
+
+function updateFacetLayoutVisibility(forceHidden = false) {
+ const toggleBtn = document.getElementById( 'gc-facet-toggle' );
+ const resultsCol = document.getElementById( 'gc-results-col' );
+ if ( !toggleBtn || !facetSidebarElement || !resultsCol ) { return; }
+
+ const hasFacetContent = facetStates.some( ( s ) => s?.values?.length > 0 );
+
+ if ( !hasFacetContent || forceHidden ) {
+ toggleBtn.hidden = true;
+ facetSidebarElement.hidden = true;
+ resultsCol.classList.remove( 'col-md-8' );
+ resultsCol.classList.add( 'col-md-12' );
+ } else {
+ toggleBtn.hidden = false;
+ const isExpanded = toggleBtn.getAttribute( 'aria-expanded' ) === 'true';
+ facetSidebarElement.hidden = !isExpanded;
+ if ( isExpanded ) {
+ resultsCol.classList.remove( 'col-md-12' );
+ resultsCol.classList.add( 'col-md-8' );
+ }
+ }
+}
+
+// Rebuild the DOM for a date range facet (predefined periods + custom date pickers)
+function updateDateFacetState( index, dateFacetState, dateFilterState ) {
+ facetStates[ index ] = dateFacetState;
+ dateFilterStates[ index ] = dateFilterState;
+
+ if ( !facetPanelElement || dateFacetState.isLoading ) {
+ return;
+ }
+
+ const config = facetNormalizedConfigs[ index ];
+ const facetEl = document.getElementById( 'gc-facet-' + config.facetId );
+ if ( !facetEl ) {
+ return;
+ }
+
+ facetEl.hidden = dateFacetState.values.length === 0 || ( !config.withDatePicker && !config.withDateRanges );
+ if ( facetEl.hidden ) {
+ updateFacetLayoutVisibility();
+ return;
+ }
+
+ const isFr = lang === 'fr';
+ const todayStr = new Date().toISOString().slice( 0, 10 );
+ const wasOpen = facetEl.open;
+
+ const startId = 'gc-facet-date-start-' + index;
+ const endId = 'gc-facet-date-end-' + index;
+ const hasActive = dateFacetState.hasActiveValues || dateFilterState.range;
+
+ // --- Custom date pickers (above the list) ---
+ let datePickerHTML = '';
+ if ( config.withDatePicker ) {
+ datePickerHTML = facetDatePickerTemplateHTML
+ .replaceAll( '%[startId]', startId )
+ .replaceAll( '%[endId]', endId )
+ .replaceAll( '%[today]', todayStr );
+ }
+
+ // --- Predefined date range list ---
+ let dateRangesHTML = '';
+ const reversedValues = [ ...dateFacetState.values ].reverse();
+ if ( config.withDateRanges ) {
+ const itemsHTML = reversedValues.map( ( value, i ) => {
+ const period = getDateFacetFields()[ i ];
+ if ( !period ) { return ''; }
+ return renderFacetItemHTML( localizedStrings[ lang ].get( period.labelKey ), value.numberOfResults, value.state === 'selected' );
+ } ).join( '' );
+ dateRangesHTML = ``;
+ }
+
+ facetEl.innerHTML =
+ renderFacetSummaryHTML( config.label, hasActive ) +
+ datePickerHTML +
+ dateRangesHTML;
+
+ facetEl.open = wasOpen;
+
+ if ( config.withDatePicker && !dateFilterState.range ) {
+ facetEl.querySelector( '.gc-date-clear' ).hidden = true;
+ }
+
+ // Attach event handlers
+ if ( hasActive ) {
+ facetEl.querySelector( '.gc-facet-clear' ).onclick = ( e ) => {
+ e.stopPropagation();
+ facetControllers[ index ].deselectAll();
+ dateFilterControllers[ index ].clear();
+ };
+ }
+
+ if ( config.withDatePicker ) {
+ const startInput = facetEl.querySelector( '#' + startId );
+ const endInput = facetEl.querySelector( '#' + endId );
+
+ startInput.onchange = () => { if ( startInput.value ) { endInput.min = startInput.value; } };
+ endInput.onchange = () => { if ( endInput.value ) { startInput.max = endInput.value; } };
+
+ // Pre-populate inputs if a custom filter is already active, skipping sentinel values
+ if ( dateFilterState.range ) {
+ const rangeStart = coveoDateToInputDate( dateFilterState.range.start );
+ const rangeEnd = coveoDateToInputDate( dateFilterState.range.end );
+ if ( rangeStart !== '1970-01-01' ) { startInput.value = rangeStart; }
+ if ( rangeEnd !== todayStr ) { endInput.value = rangeEnd; }
+ if ( startInput.value ) { endInput.min = startInput.value; }
+ if ( endInput.value ) { startInput.max = endInput.value; }
+ }
+
+ facetEl.querySelector( '.gc-date-apply' ).onclick = () => {
+ let startVal = startInput.value;
+ let endVal = endInput.value;
+ if ( startVal || endVal ) {
+ // Swap if end is before start
+ if ( startVal && endVal && endVal < startVal ) {
+ [ startVal, endVal ] = [ endVal, startVal ];
+ startInput.value = startVal;
+ endInput.value = endVal;
+ }
+ // Clear predefined range selection before applying custom filter
+ facetControllers[ index ].deselectAll();
+ dateFilterControllers[ index ].setRange( {
+ start: startVal ? inputDateToCoveoDate( startVal, false ) : 'past-100-year',
+ end: endVal ? inputDateToCoveoDate( endVal, true ) : 'now',
+ } );
+ }
+ };
+
+ facetEl.querySelector( '.gc-date-clear' ).onclick = () => {
+ startInput.value = '';
+ endInput.value = '';
+ startInput.max = todayStr;
+ endInput.min = '';
+ dateFilterControllers[ index ].clear();
+ };
+ }
+
+ if ( config.withDateRanges ) {
+ facetEl.querySelectorAll( '.gc-facet-values input[type="checkbox"]' ).forEach( ( checkbox, i ) => {
+ const value = reversedValues[ i ];
+ const period = getDateFacetFields()[ i ];
+ const isSelected = value.state === 'selected';
+
+ // Sync date picker inputs when a predefined range is selected
+ if ( config.withDatePicker && isSelected && period ) {
+ const rangeStart = resolveRangeEndpointToInputDate( period.range.start );
+ const rangeEnd = resolveRangeEndpointToInputDate( period.range.end );
+ const startEl = facetEl.querySelector( '#' + startId );
+ const endEl = facetEl.querySelector( '#' + endId );
+ if ( startEl ) { startEl.value = rangeStart !== '1970-01-01' ? rangeStart : ''; }
+ if ( endEl ) { endEl.value = rangeEnd !== todayStr ? rangeEnd : ''; }
+ }
+
+ checkbox.onchange = () => {
+ dateFilterControllers[ index ].clear();
+ facetControllers[ index ].deselectAll();
+ // Only re-select if it wasn't already selected (deselect = just clear)
+ if ( !isSelected ) {
+ facetControllers[ index ].toggleSelect( value );
+ }
+ };
+ } );
+ }
+
+ if ( hasActive ) {
+ announceFacetChange( isFr ? `Filtre de date actif\u00a0: ${ config.label }` : `Date filter active: ${ config.label }` );
+ }
+
+ updateFacetLayoutVisibility();
+ updateClearAllVisibility();
+}
+
+function updateClearAllVisibility() {
+ const clearAllContainer = document.getElementById( 'gc-facet-clear-all-container' );
+ if ( clearAllContainer ) {
+ clearAllContainer.hidden = !facetStates.some( ( s ) => s?.hasActiveValues ) && !dateFilterStates.some( ( s ) => s?.range );
+ }
+}
+
// Update the URL parameter for pagination in advanced search mode
function updatePagerUrlParam( currentPage ) {
const resultsPerPage = buildResultsPerPage(headlessEngine);
diff --git a/test/srf-en.html b/test/srf-en.html
new file mode 100644
index 0000000..0b77360
--- /dev/null
+++ b/test/srf-en.html
@@ -0,0 +1,105 @@
+---
+title: Search facets/filters results
+description: Demo page for the search with facets
+lang: en
+altLangPage: srf-fr.html
+nositesearch: true
+pageclass: page-type-search
+pageType: search
+share: false
+deptfeature: false
+dateModified: 2026-03-19
+breadcrumbs:
+- title: "GC Search UI"
+ link: "../index.html"
+css: "../src/connector.css"
+script:
+- src: "assets/token.js"
+- src: "../src/connector.js"
+ type: module
+---
+
+
+
+
+
+
+
+
+
+
+Expected output for the result section
+
+ Output for Results section
+ [To be completed, see Connector.js for reference until then]
+