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 = ``; - } + } + } + + 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 = + `
    +
    +
    +

    Filtres

    +

    + + %[facetItems] +
    +
    +
    +
    `; + } else { + facetLayoutTemplateHTML = + `
    +
    +
    +

    Filters

    +

    + + %[facetItems] +
    +
    +
    +
    `; + } + } + + if ( !breadcrumbItemTemplateHTML ) { + breadcrumbItemTemplateHTML = + `
  • `; + } + + if ( !breadcrumbListTemplateHTML ) { + breadcrumbListTemplateHTML = + ``; + } + + // 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] +