diff --git a/README.md b/README.md index 82b86de..67ea49d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The goal is to continue to refine and improve this code base on a regular basis. ### Improvement plan -To manage development activities related to this project, a standard internal issue tracking system used at Principal Publisher will be used. Also, regular touchpoints with the search vendor, as well as formal service requests entered through their portal, could also spark some development activities from a vendor perspective. +To manage development activities related to this project, a standard internal issue tracking system used at Principal Publisher will be used. Also, regular touchpoints with the search vendor, as well as formal service requests entered through their portal, could spark development activities from the vendor's team. Example of code contributions may be related to: @@ -29,15 +29,15 @@ Example of code contributions may be related to: - bug fixes, accessibility and security improvements - project maintenance chores -For more details, please [consult full checklist of to do items](todo.md). +For more details, please [consult the checklist of "to do" items](todo.md). ## Releases and API All changes contributed through Pull requests will be packaged as releases. Releases are completed through the "Releases" tab in this GitHub repository; then, deployment to MWS follows the reguar release management cycle accordingly. -Each new verion of this project is defined based on an evaluaton of the impacts of changes against any formerly up-to-date LIVE Search UI implementation on Canada.ca. The scope constitutes of all files within the "dist" folder (distribution files), which are JavaScript scripts and CSS styles. Additionally, volume of usage for features can also be taken into consideration as part of the evaluation of impact on versioning. For example, an interactive feature from the Javascript which is known by certitude to have never been used in a production environment, wouldn't cause any breaking change if modified and therefore, wouldn't generate a major version. +Each new verion of this project is defined based on an evaluaton of the impacts of changes against any formerly up-to-date LIVE Search UI implementation on Canada.ca. The scope constitutes of all files within the "dist" folder (distribution files), which are JavaScript scripts and CSS style sheets. Additionally, volume of usage for features can also be taken into consideration as part of the evaluation of impact on versioning. For example, an interactive feature from the Javascript which is known by certitude to have never been used in a production environment, wouldn't cause any breaking change if modified and therefore, wouldn't generate a major version necessarily. -Search UI follows [Semantic Versioning 2.0.0](https://semver.org/) +Aside from the evaluation based on usage, Search UI follows [Semantic Versioning 2.0.0](https://semver.org/) --- @@ -120,6 +120,10 @@ They must be used within the `[data-gc-search]` attribute. See the **/test/src-e : Set the search behavior of the page as an advanced search. This is optional since it will detect automatically from the path of your page if it is advanced. If not determined, default is: `false` - `originLevel3` : Allows for mimicking a specific search page/context, such as the ESDC contextual search if you set it to: `/en/employment-social-development/search.html`; this value can be be relative or absolute and is used to differentiate and contextualize a search page from another both in terms of scoping the search results and in terms of knowledge base for machine learning-powered features. Default is set to the current page's absolute URL +- `numberOfPages` +: The number of pages to display in the pager. Default is 9 +- `automaticallyCorrectQuery` +: Whether to automatically apply corrections for queries that would otherwise return no results. Default is false #### Templates @@ -138,15 +142,15 @@ For example, to override the search results template, you would do something alo Template override should technically only be used on a few instances of the search pages. If all pages would benefit from a template override, then the recommended action would be to modify the default template HTML code at the source through a pull request. - `sr-single` -: Template for all search results individually +: Template for all search results individually; your custom template MUST include an hyperlink with the class `result-link`, which SHOULD also include the data attribute `data-dtm-srchlnknm` for analytis tracking - `sr-nores` -: For when there is no results to show +: For when there is no results to show; your custom template MUST include a heading of level H2 - `sr-error` -: For when an error occurs in the communication between the search page and the search engine +: For when an error occurs in the communication between the search page and the search engine; your custom template MUST include a heading of level H2 - `sr-query-summary` -: For the summary zone above the search results. Recommended to include an H2 tag for accessibility purposes +: For the summary zone above the search results; your custom template MUST include a heading of level H2 - `sr-noquery-summary` -: For the summary zone above the search results on advanced search pages. Recommended to include an H2 tag for accessibility purposes +: For the summary zone above the search results on advanced search pages; your custom template MUST include a heading of level H2 - `sr-did-you-mean` : For the "Did you mean" suggestion section - `sr-pager-previous` @@ -157,6 +161,8 @@ Template override should technically only be used on a few instances of the sear : For the next page button - `sr-pager-container` : For the wrapper of all pagniation button +- `sr-qs-hint` +: Message announced to screen reader to provide instructions on QS As demonstrated in the example above, and by looking at the default templates, you'll notice that some variables can be used within the templates to be replaced by dynamic content coming from the search engine's API response. @@ -196,6 +202,10 @@ Here is the extensive list of what variables can be used in templates: : returns what the search engine considers a better search query in case a low amount or zero results show (based on criteria handled within the serach engine). To be used on Did you mean template - `page` : returns page number. To be used on Pagination template +- `result.raw.disp_declared_type` +: returns name of the declared type as defined in the meta data of the page (specific to News items) +- `result.raw.description` +: returns the meta description of the pages in the results; can be displayed instead of the default "matches excerpt". #### Parameters @@ -218,7 +228,7 @@ Sometimes your search pages contain more than one input relevant to the search's - `dmn` : Search for search terms in input, only on a specific domain - `sort` -: Sort search results based on different criteria. Options are: by relevance (default when undefined) or by date when parameter is set +: Sort search results based on different criteria. Options are: by relevance (default when undefined) or by date when parameter is set to `descending` or `ascending` - `elctn_cat` : Used specifically for Elections Canada, to define a scope of search amongst their collection. See **/src/connector.js** to see all the options available - `site` @@ -229,6 +239,12 @@ Sometimes your search pages contain more than one input relevant to the search's : Search , within documents of a certain file type. Options are: `application/pdf`, `ps`, `application/msword`, `application/vnd.ms-excel`, `application/vnd.ms-powerpoint`, `application/rtf` - `originLevel3` : Allows for mimicking a specific search page/context by setting its path through this URL parameter; this takes precedence over the configuration through data attribute +- `startdate` +: returns results that have a date meta data higher than or equal to the date provided +- `enddate` +: returns results that have a date meta data lower than or equal to the date provided +- `declaredtype` +: returns results based on the type provided, mapped to disp_declared_type behind the scenes (specific to News items) ### Other diff --git a/index.html b/index.html index 7a86bf5..e703de4 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,17 @@ --- title: Search user interface (UI) with Headless lang: en -dateModified: 2025-08-04 +dateModified: 2025-11-03 ---

This is a demo site for the GC Search UI.

-

Test pages

-

To test search pages, please make sure you have a valid token.

+

Working examples

+ +

Regular pages

diff --git a/src/connector.css b/src/connector.css index 7f4a134..854ed83 100644 --- a/src/connector.css +++ b/src/connector.css @@ -3,7 +3,6 @@ */ .query-suggestions { background-color: white; - border-bottom: 1px solid #ccc; border-left: 1px solid #ccc; border-right: 1px solid #ccc; cursor: pointer; @@ -15,6 +14,9 @@ width: 100%; z-index: 60; } +.query-suggestions:has(li) { + border-bottom: 1px solid #ccc; +} .query-suggestions li { padding: 5px 10px; } diff --git a/src/connector.js b/src/connector.js index 96597ea..cc5a41f 100644 --- a/src/connector.js +++ b/src/connector.js @@ -19,10 +19,11 @@ import { // Search UI base const baseElement = document.querySelector( '[data-gc-search]' ); -// General -const winPath = window.location.pathname; -const monthsEn = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ]; -const monthsFr = [ "janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc." ]; +// Window location variables +const winLoc = window.location; +const winPath = winLoc.pathname; +const winOrigin = winLoc.origin; +const originPath = winOrigin + winPath; // Parameters const defaults = { @@ -36,8 +37,10 @@ const defaults = { "enableHistoryPush": true, "isContextSearch": false, "isAdvancedSearch": false, - "originLevel3": window.location.origin + winPath, - "pipeline": "" + "originLevel3": originPath, + "pipeline": "", + "automaticallyCorrectQuery": false, + "numberOfPages": 9 }; let lang = document.querySelector( "html" )?.lang; let paramsOverride = baseElement ? JSON.parse( baseElement.dataset.gcSearch ) : {}; @@ -80,9 +83,10 @@ let isFirefox = navigator.userAgent.indexOf( "Firefox" ) !== -1; let waitForkeyUp = false; // UI Elements placeholders +const resultSectionID = "wb-land"; let searchBoxElement; -let formElement = document.querySelector( '#gc-searchbox, form[action="#wb-land"]' ); -let resultsSection = document.querySelector( '#wb-land' ); +let formElement = document.querySelector( `.page-type-search main [role=search], #gc-searchbox, form[action="#${resultSectionID}"]` ); +let resultsSection = document.querySelector( `#${resultSectionID}` ); let resultListElement = document.querySelector( '#result-list' ); let querySummaryElement = document.querySelector( '#query-summary' ); let pagerElement = document.querySelector( '#pager' ); @@ -108,7 +112,7 @@ function initSearchUI() { return; } - if ( !lang && window.location.path.includes( "/fr/" ) ) { + if ( !lang && winPath.includes( "/fr/" ) ) { paramsDetect.lang = "fr"; } if ( lang.startsWith( "fr" ) ) { @@ -128,7 +132,7 @@ function initSearchUI() { pl = /\+/g, // Regex for replacing addition symbol with a space search = /([^&=]+)=?([^&]*)/g, decode = function ( s ) { return decodeURIComponent( s.replace( pl, " " ) ); }, - query = window.location.search.substring( 1 ); + query = winLoc.search.substring( 1 ); urlParams = {}; hashParams = {}; @@ -138,7 +142,7 @@ function initSearchUI() { while ( match = search.exec( query ) ) { // eslint-disable-line no-cond-assign urlParams[ decode(match[ 1 ] ) ] = stripHtml( decode( match[ 2 ] ) ); } - query = window.location.hash.substring( 1 ); + query = winLoc.hash.substring( 1 ); while ( match = search.exec( query ) ) { // eslint-disable-line no-cond-assign hashParams[ decode( match[ 1 ] ) ] = stripHtml( decode( match[ 2 ] ) ); @@ -148,14 +152,15 @@ function initSearchUI() { window.onpopstate(); + // Initialize templates initTpl(); // override origineLevel3 through query parameters - if ( urlParams.originLevel3 ){ + if ( urlParams.originLevel3 ) { params.originLevel3 = urlParams.originLevel3; } // override sort through query parameters - if (urlParams.sort){ + if (urlParams.sort) { params.sort = urlParams.sort; } @@ -177,12 +182,20 @@ function initSearchUI() { params.endpoints = getOrganizationEndpoints( params.organizationId, 'prod' ); } + // Show error on load if no access token is provided + if ( !params.accessToken ) { + showQueryErrorMessage(); + return; + } + + // Initialize the Headless engine initEngine(); } -// Auto-create parts of search pages templates if not already defined +// Initialize default templates function initTpl() { + // Auto-create parts of search pages templates if not already defined // Default templates if ( !resultTemplateHTML ) { if ( lang === "fr" ) { @@ -350,9 +363,7 @@ function initTpl() { // auto-create results if ( !resultsSection ) { resultsSection = document.createElement( "section" ); - resultsSection.id = "wb-land"; - - baseElement.prepend( resultsSection ); + resultsSection.id = resultSectionID; } // auto-create query summary element @@ -391,6 +402,7 @@ function initTpl() { // initialize the search box searchBoxElement = document.querySelector( params.searchBoxQuery ); + if ( searchBoxElement ) { // default searchbox attributes @@ -414,20 +426,146 @@ function initTpl() { // Add accessibility instructions after query suggestions suggestionsElement.insertAdjacentHTML( 'afterEnd', qsA11yHintHTML ); suggestionsElement.setAttribute( "aria-describedby", "sr-qs-hint" ); + + // Document-wide listener to close query suggestion box if click elsewhere + document.addEventListener( "click", function( evnt ) { + if ( suggestionsElement && ( evnt.target.className !== "suggestion-item" && evnt.target.id !== searchBoxElement?.id ) ) { + closeSuggestionsBox(); + } + } ); } } +} - // Close query suggestion box if click elsewhere - document.addEventListener( "click", function( evnt ) { - if ( suggestionsElement && ( evnt.target.className !== "suggestion-item" && evnt.target.id !== searchBoxElement?.id ) ) { - closeSuggestionsBox(); - } +// Detect if localStorage is available +function hasLocalStorage() { + try { + return typeof localStorage !== 'undefined'; + } catch ( error ) { + return false; + } +} + +// Limit actions history array to items newer than 7 days +function limitCoveoAnalyticsHistory( actionsHistory ) { + const now = new Date(); + const sevenDaysAgo = now.getTime() - 7 * 24 * 60 * 60 * 1000; + + return actionsHistory.filter( ( action ) => { + const parsedTime = new Date( action.time.replace( /^"|"$/g, "" ) ); + return parsedTime.getTime() >= sevenDaysAgo; } ); } + +// Saves the actions history array to either localStorage or a cookie, depending on what's available +function saveCoveoAnalyticsHistory( actionsHistory ) { + const key = '__coveo.analytics.history'; + const serialized = JSON.stringify( actionsHistory ); + + // Coveo will use localStorage if available, ignoring cookies + if ( hasLocalStorage() ) { + localStorage.setItem( key, serialized ); + } else { + // No localStorage, try cookies + try { + const expiry = 7 * 24 * 60 * 60; // 7-day expiry + document.cookie = `${key}=${serialized}; path=/; max-age=${expiry}`; + } catch ( error ) { + // Do nothing if cookies are disabled + } + } +} + +// Sanitize query to remove HTML tags function sanitizeQuery(q) { return q.replace(/<[^>]*>?/gm, ''); } -// Initiate headless engine + +// rebuild a clean query string out of a JSON object +function buildCleanQueryString( paramsObject ) { + let urlParam = ""; + for ( var prop in paramsObject ) { + if ( paramsObject[ prop ] ) { + if ( urlParam !== "" ) { + urlParam += "&"; + } + + urlParam += prop + "=" + stripHtml( paramsObject[ prop ].replaceAll( '+', ' ' ) ); + } + } + return urlParam; +} + +// Filters out dangerous URIs that can create XSS attacks such as `javascript:`. +function filterProtocol( uri ) { + + const isAbsolute = /^(https?|mailto|tel):/i.test( uri ); + const isRelative = /^(\/|\.\/|\.\.\/)/.test( uri ); + + return isAbsolute || isRelative ? uri : ''; +} + +// Strip HTML tags of a given string +function stripHtml(html) { + let tmp = document.createElement( "DIV" ); + tmp.innerHTML = html; + return tmp.textContent || tmp.innerText || ""; +} + +// Focus to H2 heading in results section +function focusToView() { + let focusElement = resultsSection.querySelector( "h2" ); + + if( focusElement ) { + focusElement.tabIndex = -1; + focusElement.focus(); + } +} + +// Get date converted from GMT (Coveo) to current timezone +function getDateInCurrentTimeZone( date ) { + const offset = date.getTimezoneOffset(); + return new Date( date.getTime() + ( offset * 60 * 1000 ) ); +} + +// get a short date format like YYYY-MM-DD +function getShortDateFormat( date ){ + let currentTZDate = getDateInCurrentTimeZone( date ); + return currentTZDate.toISOString().split( 'T' )[ 0 ]; +} + +// get a long date format like May 21, 2024 +function getLongDateFormat( date, lang ){ + let currentTZDate = getDateInCurrentTimeZone( date ); + let langCA = lang + "-CA"; + + return currentTZDate.toLocaleDateString( langCA, { year: 'numeric', month: 'short', day: 'numeric' } ); +} + +// checking for default date , Jan 1st, 1970 +function isEmptyDate( date ) { + return date instanceof Date && + date.getFullYear() === 1970 && + date.getMonth() === 0 && // January is 0 + date.getDate() === 1; +} + +// Convert date parameter to GMT format YYYY/MM/DD +function getGMTDate( date ) { + const paramDate = new Date( date ); + const GMTDateTime = new Date( paramDate.getTime() - paramDate.getTimezoneOffset()*60*1000 ); + + const year = GMTDateTime.getFullYear(); + const month = GMTDateTime.getMonth() + 1; // Add 1 for 1-indexed month + const day = GMTDateTime.getDate(); + + const formattedMonth = month < 10 ? '0' + month : month; + const formattedDay = day < 10 ? '0' + day : day; + + return `${year}/${formattedMonth}/${formattedDay}`; +} + +// Initiate proprietary Headless engine function initEngine() { headlessEngine = buildSearchEngine( { configuration: { @@ -517,14 +655,17 @@ function initEngine() { } } ); querySummaryController = buildQuerySummary( headlessEngine ); - didYouMeanController = buildDidYouMean( headlessEngine, { options: { automaticallyCorrectQuery: false } } ); - pagerController = buildPager( headlessEngine, { options: { numberOfPages: 9 } } ); + didYouMeanController = buildDidYouMean( headlessEngine, { options: { automaticallyCorrectQuery: params.automaticallyCorrectQuery } } ); + pagerController = buildPager( headlessEngine, { options: { numberOfPages: params.numberOfPages } } ); statusController = buildSearchStatus( headlessEngine ); - 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 ) { + // 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 = []; let qString = ""; + let aqString = ""; + let fqupdate, elctn_cat, filetype, site, year, startDate, endDate; + if ( urlParams.allq ) { qString = urlParams.allq.replaceAll( '+', ' ' ); } @@ -539,7 +680,6 @@ function initEngine() { } qString += q.length ? ' (' + q.join( ')(' ) + ')' : ''; - let aqString = ''; if ( urlParams.fqocct ) { if ( urlParams.fqocct === "title_t" ) { @@ -553,7 +693,8 @@ function initEngine() { } if ( urlParams.fqupdate ) { - let fqupdate = urlParams.fqupdate.toLowerCase(); + fqupdate = urlParams.fqupdate.toLowerCase(); + if ( fqupdate === "datemodified_dt:[now-1day to now]" ) { aqString += ' @date>today-1d'; } @@ -574,7 +715,8 @@ function initEngine() { // Specifically for Elections Canada, allows to search within scope if ( urlParams.elctn_cat ) { - let elctn_cat = urlParams.elctn_cat.toLowerCase(); + elctn_cat = urlParams.elctn_cat.toLowerCase(); + if( elctn_cat === "his" ) { aqString += ' @uri="dir=his"'; } @@ -617,7 +759,8 @@ function initEngine() { } if ( urlParams.filetype ) { - let filetype = urlParams.filetype.toLowerCase(); + filetype = urlParams.filetype.toLowerCase(); + if ( filetype === "application/pdf" ) { aqString += ' @filetype==(pdf)'; } @@ -642,7 +785,8 @@ function initEngine() { } if ( urlParams.year ) { - const year = Number.parseInt( urlParams.year ); + year = Number.parseInt( urlParams.year ); + if ( Number.isInteger( year ) && ( year >= 2000 ) && ( year <= ( new Date().getFullYear() + 1 ) ) ) { aqString += ' @uri=".ca/' + urlParams.year + '"'; } @@ -652,17 +796,17 @@ function initEngine() { } if ( urlParams.site ) { - let site = urlParams.site.toLowerCase().replace( '*', '' ); + site = urlParams.site.toLowerCase().replace( '*', '' ); aqString += ' @canadagazettesite==' + site; } if ( urlParams.startdate ) { - const startDate = getcoveoGMTDate( urlParams.startdate ); + startDate = getGMTDate( urlParams.startdate ); aqString += ' @date >= "' + startDate + '"'; } if ( urlParams.enddate ) { - const endDate = getcoveoGMTDate( urlParams.enddate ); + endDate = getGMTDate( urlParams.enddate ); aqString += ' @date <= "' + endDate + '"'; } @@ -718,16 +862,16 @@ function initEngine() { // Unsubscribe to controllers unsubscribeManager = urlManager.subscribe( () => { - if ( !params.enableHistoryPush || window.location.origin.startsWith( 'file://' ) ) { + if ( !params.enableHistoryPush || winOrigin.startsWith( 'file://' ) ) { return; } let hash = `#${urlManager.state.fragment}`; if ( !statusController.state.firstSearchExecuted ) { - window.history.replaceState( null, document.title, window.location.origin + winPath + hash ); + window.history.replaceState( null, document.title, originPath + hash ); } else { - window.history.pushState( null, document.title, window.location.origin + winPath + hash ); + window.history.pushState( null, document.title, originPath + hash ); } } ); @@ -787,7 +931,7 @@ function initEngine() { else if ( e.keyCode === 38 ) { if ( !( isFirefox && waitForkeyUp ) ) { waitForkeyUp = true; - searchBoxArrowKeyUp(); + searchBoxArrowKey( "up" ); e.preventDefault(); } } @@ -795,7 +939,7 @@ function initEngine() { else if ( e.keyCode === 40 ) { if ( !( isFirefox && waitForkeyUp ) ) { waitForkeyUp = true; - searchBoxArrowKeyDown(); + searchBoxArrowKey( "down" ); } } }; @@ -846,80 +990,55 @@ function initEngine() { didYouMeanElement.textContent = ""; pagerElement.textContent = ""; pagerManuallyCleared = true; + + // Show no results message in Query Summary if no query entered + querySummaryElement.innerHTML = noResultTemplateHTML; + focusToView(); } }; } } -// Detect if localStorage is available -function hasLocalStorage() { - try { - return typeof localStorage !== 'undefined'; - } catch ( error ) { - return false; - } -} - -// Limit actions history array to items newer than 7 days -function limitCoveoAnalyticsHistory( actionsHistory ) { - const now = new Date(); - const sevenDaysAgo = now.getTime() - 7 * 24 * 60 * 60 * 1000; - - return actionsHistory.filter( ( action ) => { - const parsedTime = new Date( action.time.replace( /^"|"$/g, "" ) ); - return parsedTime.getTime() >= sevenDaysAgo; - } ); -} - -// Saves the actions history array to either localStorage or a cookie, depending on what's available -function saveCoveoAnalyticsHistory( actionsHistory ) { - const key = '__coveo.analytics.history'; - const serialized = JSON.stringify( actionsHistory ); - - // Coveo will use localStorage if available, ignoring cookies - if ( hasLocalStorage() ) { - localStorage.setItem( key, serialized ); - } else { - // No localStorage, try cookies - try { - const expiry = 7 * 24 * 60 * 60; // 7-day expiry - document.cookie = `${key}=${serialized}; path=/; max-age=${expiry}`; - } catch ( error ) { - // Do nothing if cookies are disabled - } +// Show error message in Query Summary +function showQueryErrorMessage() { + if( !document.getElementById( resultSectionID ) ) { + baseElement.prepend( resultsSection ); } -} - -function searchBoxArrowKeyUp() { - if ( suggestionsElement.hidden ) { + if ( !querySummaryElement ) { return; } - if ( !activeSuggestion || activeSuggestion <= 1 ) { - activeSuggestion = searchBoxState.suggestions.length; - } - else { - activeSuggestion -= 1; - } - - updateSuggestionSelection(); + querySummaryElement.textContent = ""; + querySummaryElement.innerHTML = resultErrorTemplateHTML; + focusToView(); + pagerManuallyCleared = false; } -function searchBoxArrowKeyDown() { +function searchBoxArrowKey( direction ) { if ( suggestionsElement.hidden ) { return; } - if ( !activeSuggestion || activeSuggestion >= searchBoxState.suggestions.length ) { - activeSuggestion = 1; - } - else { - activeSuggestion += 1; + if ( direction === "up" ) { + if ( !activeSuggestion || activeSuggestion <= 1 ) { + activeSuggestion = searchBoxState.suggestions.length; + } + else { + activeSuggestion -= 1; + } + } else { + if ( !activeSuggestion || activeSuggestion >= searchBoxState.suggestions.length ) { + activeSuggestion = 1; + } + else { + activeSuggestion += 1; + } } updateSuggestionSelection(); } +// Select the active suggestion function selectSuggestion() { let suggestionElement = document.getElementById( 'suggestion-' + activeSuggestion ); @@ -933,6 +1052,24 @@ function selectSuggestion() { } } +// open the suggestions box +function openSuggestionsBox() { + suggestionsElement.hidden = false; + searchBoxElement.setAttribute( 'aria-expanded', 'true' ); +} + +// close the suggestions box +function closeSuggestionsBox() { + if( !suggestionsElement ) { + return; + } + suggestionsElement.hidden = true; + activeSuggestion = 0; + searchBoxElement.setAttribute( 'aria-expanded', 'false' ); + searchBoxElement.removeAttribute( 'aria-activedescendant' ); +} + +// Update the visual selection of the active suggestion function updateSuggestionSelection() { // clear current suggestion let activeSelection = suggestionsElement.getElementsByClassName( 'selected-suggestion' ); @@ -948,11 +1085,12 @@ function updateSuggestionSelection() { searchBoxElement.setAttribute( 'aria-activedescendant', selectedSuggestionId ); } -// Show query suggestions if a search action was not executed (if enabled) +// Update the search box state after search actions - used for QS function updateSearchBoxState( newState ) { const previousState = searchBoxState; searchBoxState = newState; + // Show query suggestions if a search action was not executed (if enabled) if ( updateSearchBoxFromState && searchBoxElement && searchBoxElement.value !== newState.value ) { searchBoxElement.value = stripHtml( newState.value ); updateSearchBoxFromState = false; @@ -968,6 +1106,7 @@ function updateSearchBoxState( newState ) { return; } + // Build suggestions list activeSuggestion = 0; if ( !searchBoxState.isLoadingSuggestions && previousState?.isLoadingSuggestions ) { suggestionsElement.textContent = ''; @@ -1002,84 +1141,6 @@ function updateSearchBoxState( newState ) { } } -// open the suggestions box -function openSuggestionsBox() { - suggestionsElement.hidden = false; - searchBoxElement.setAttribute( 'aria-expanded', 'true' ); -} - -// close the suggestions box -function closeSuggestionsBox() { - if( !suggestionsElement ) { - return; - } - suggestionsElement.hidden = true; - activeSuggestion = 0; - searchBoxElement.setAttribute( 'aria-expanded', 'false' ); - searchBoxElement.setAttribute( 'aria-activedescendant', '' ); -} - -// rebuild a clean query string out of a JSON object -function buildCleanQueryString( paramsObject ) { - let urlParam = ""; - for ( var prop in paramsObject ) { - if ( paramsObject[ prop ] ) { - if ( urlParam !== "" ) { - urlParam += "&"; - } - - urlParam += prop + "=" + stripHtml( paramsObject[ prop ].replaceAll( '+', ' ' ) ); - } - } - - return urlParam; -} - -// Filters out dangerous URIs that can create XSS attacks such as `javascript:`. -function filterProtocol( uri ) { - - const isAbsolute = /^(https?|mailto|tel):/i.test( uri ); - const isRelative = /^(\/|\.\/|\.\.\/)/.test( uri ); - - return isAbsolute || isRelative ? uri : ''; -} - -// Strip HTML tags of a given string -function stripHtml(html) { - let tmp = document.createElement( "DIV" ); - tmp.innerHTML = html; - return tmp.textContent || tmp.innerText || ""; -} - -// Get date converted from GMT (Coveo) to current timezone -function getDateInCurrentTimeZone( date ){ - const offset = date.getTimezoneOffset(); - return new Date( date.getTime() + ( offset * 60 * 1000 ) ); -} - -// get a short date format like YYYY-MM-DD -function getShortDateFormat( date ){ - let currentTZDate = getDateInCurrentTimeZone( date ); - return currentTZDate.toISOString().split( 'T' )[ 0 ]; -} - -// get a long date format like May 21, 2024 -function getLongDateFormat( date, lang ){ - let currentTZDate = getDateInCurrentTimeZone( date ); - if ( lang === 'en' ) { - return monthsEn[ currentTZDate.getMonth() ] + " " + currentTZDate.getDate() + ", " + currentTZDate.getFullYear(); - } - - return currentTZDate.getDate() + " " + monthsFr[ currentTZDate.getMonth() ] + " " + currentTZDate.getFullYear(); -} - - -function isEmptyDate(date) { // checking for default date , Jan 1st, 1970 - return date instanceof Date && - date.getFullYear() === 1970 && - date.getMonth() === 0 && // January is 0 - date.getDate() === 1; -} // Update results list function updateResultListState( newState ) { resultListState = newState; @@ -1093,7 +1154,14 @@ function updateResultListState( newState ) { // Clear results list resultListElement.textContent = ""; + + // Rebuild results list if( !resultListState.hasError && resultListState.hasResults ) { + + if( !document.getElementById( resultSectionID ) ) { + baseElement.prepend( resultsSection ); + } + resultListState.results.forEach( ( result, index ) => { const sectionNode = document.createElement( "section" ); const highlightedExcerpt = HighlightUtils.highlightString( { @@ -1121,35 +1189,34 @@ function updateResultListState( newState ) { let disp_declared_type = ""; let description = ""; let printableUri = encodeURI( result.printableUri ); - printableUri = printableUri.replaceAll( '&' , '&' ); - printableUri = printableUri.replaceAll( '%252F' , '/' ); - printableUri = printableUri.replaceAll( "%252C" , "," ); // handle coma let clickUri = encodeURI( result.clickUri ); - clickUri = clickUri.replaceAll( "%252C" , "%2C" ); // handle coma - clickUri = clickUri.replaceAll( "%252F" , "%2F" ); // handle slash let title = stripHtml( result.title ); + + printableUri = printableUri.replaceAll( '&' , '&' ); + printableUri = printableUri.replaceAll( '%252F' , '/' ); // handle slash + printableUri = printableUri.replaceAll( "%252C" , "," ); // handle comma + clickUri = clickUri.replaceAll( "%252C" , "%2C" ); // handle comma + clickUri = clickUri.replaceAll( "%252F" , "%2F" ); // handle slash + if ( result.raw.hostname && result.raw.displaynavlabel ) { const splittedNavLabel = ( Array.isArray( result.raw.displaynavlabel ) ? result.raw.displaynavlabel[0] : result.raw.displaynavlabel).split( '>' ); breadcrumb = '
  1. ' + stripHtml( result.raw.hostname ) + ' 
  2. ' + stripHtml( splittedNavLabel[splittedNavLabel.length-1] ) + '
'; - } - else { + } else { breadcrumb = '

' + printableUri + '

'; } - + if ( result.raw.disp_declared_type ) { disp_declared_type = stripHtml( result.raw.disp_declared_type ); - } - if ( result.raw.description ) { description = stripHtml( result.raw.description ); - } + // Searh result template mappings sectionNode.innerHTML = resultTemplateHTML .replace( '%[index]', index + 1 ) - .replace( 'https://www.canada.ca', filterProtocol( clickUri ) ) // workaround, invalid href are stripped + .replace( 'https://www.canada.ca', filterProtocol( clickUri ) ) // invalid href are stripped .replace( '%[result.clickUri]', filterProtocol( clickUri ) ) .replace( '%[result.title]', title ) .replace( '%[result.raw.author]', author ) @@ -1170,6 +1237,7 @@ function updateResultListState( newState ) { ); let resultLink = sectionNode.querySelector( ".result-link" ); + resultLink.onclick = () => { interactiveResult.select(); }; resultLink.oncontextmenu = () => { interactiveResult.select(); }; resultLink.onmousedown = () => { interactiveResult.select(); }; @@ -1182,15 +1250,18 @@ function updateResultListState( newState ) { } } -// Update heading that has number of results displayed +// Update heading that has number of results displayed (Query Summary) function updateQuerySummaryState( newState ) { querySummaryState = newState; - if ( !querySummaryElement ) { - return; - } - if ( resultListState.firstSearchExecuted && !querySummaryState.isLoading && !querySummaryState.hasError ) { + + if ( !querySummaryElement ) { + return; + } + if( !document.getElementById( resultSectionID ) ) { + baseElement.prepend( resultsSection ); + } querySummaryElement.textContent = ""; if ( querySummaryState.total > 0 ) { // Manually ask pager to redraw since even is not sent when manually cleared @@ -1199,6 +1270,7 @@ function updateQuerySummaryState( newState ) { } let numberOfResults = querySummaryState.total.toLocaleString( params.lang ); + // Generate the text content const querySummaryHTML = ( ( querySummaryState.query !== "" && !params.isAdvancedSearch ) ? querySummaryTemplateHTML : noQuerySummaryTemplateHTML ) .replace( '%[numberOfResults]', numberOfResults ) @@ -1211,32 +1283,18 @@ function updateQuerySummaryState( newState ) { if ( queryElement ){ queryElement.textContent = querySummaryState.query; } - } - else { + } else { querySummaryElement.innerHTML = noResultTemplateHTML; } focusToView(); pagerManuallyCleared = false; } else if ( querySummaryState.hasError ) { - querySummaryElement.textContent = ""; - querySummaryElement.innerHTML = resultErrorTemplateHTML; - focusToView(); - pagerManuallyCleared = false; - } -} - -// Focus to H2 heading in results section -function focusToView() { - let focusElement = resultsSection.querySelector( "h2" ); - - if( focusElement ) { - focusElement.tabIndex = -1; - focusElement.focus(); + showQueryErrorMessage(); } } -// update did you mean +// update "Did you mean" recommendation function updateDidYouMeanState( newState ) { didYouMeanState = newState; @@ -1259,7 +1317,7 @@ function updateDidYouMeanState( newState ) { } } -// Update pagination +// Update Pagination section function updatePagerState( newState ) { pagerState = newState; if ( pagerState.maxPage === 0 ) { @@ -1284,7 +1342,7 @@ function updatePagerState( newState ) { pagerController.previousPage(); if ( params.isAdvancedSearch ) { - updateUrlParameter( pagerState.currentPage ); + updatePagerUrlParam( pagerState.currentPage ); } }; @@ -1315,7 +1373,7 @@ function updatePagerState( newState ) { pagerController.selectPage( pageNo ); if ( params.isAdvancedSearch ) { - updateUrlParameter( pagerState.currentPage ); + updatePagerUrlParam( pagerState.currentPage ); } }; @@ -1333,7 +1391,7 @@ function updatePagerState( newState ) { pagerController.nextPage(); if ( params.isAdvancedSearch ) { - updateUrlParameter( pagerState.currentPage ); + updatePagerUrlParam( pagerState.currentPage ); } }; @@ -1341,11 +1399,11 @@ function updatePagerState( newState ) { } } -function updateUrlParameter( currentPage ) { - +// Update the URL parameter for pagination in advanced search mode +function updatePagerUrlParam( currentPage ) { const resultsPerPage = buildResultsPerPage(headlessEngine); const { numberOfResults } = resultsPerPage.state; - const urlParams = new URLSearchParams( window.location.search ); + const urlParams = new URLSearchParams( winLoc.search ); const paramName = 'firstResult'; const pageNum = ( currentPage - 1 ) * numberOfResults; @@ -1353,23 +1411,7 @@ function updateUrlParameter( currentPage ) { urlParams.set( paramName, pageNum ); const newSearch = urlParams.toString(); - window.history.replaceState( {}, '', `${window.location.pathname}?${newSearch}${window.location.hash}` ); - -} - -function getcoveoGMTDate( date ) { - const paramDate = new Date( date ); - const coveoGMTDateTime = new Date( paramDate.getTime() - paramDate.getTimezoneOffset()*60*1000 ); - - const year = coveoGMTDateTime.getFullYear(); - const month = coveoGMTDateTime.getMonth() + 1; // Add 1 for 1-indexed month - const day = coveoGMTDateTime.getDate(); - - const formattedMonth = month < 10 ? '0' + month : month; - const formattedDay = day < 10 ? '0' + day : day; - - return `${year}/${formattedMonth}/${formattedDay}`; - + window.history.replaceState( {}, '', `${winPath}?${newSearch}${winLoc.hash}` ); } // Run Search UI diff --git a/test/newsadv-en.html b/test/newsadv-en.html index 38339d9..26d3179 100644 --- a/test/newsadv-en.html +++ b/test/newsadv-en.html @@ -20,7 +20,7 @@ --- -