From a3185aa5915c7c1fcba198118365dbff744a72e6 Mon Sep 17 00:00:00 2001 From: bblaisATcoveo Date: Tue, 10 Feb 2026 15:25:31 -0500 Subject: [PATCH 1/2] add header search box suggestions --- src/connector.js | 21 ++ src/suggestions.css | 128 +++++++++++ src/suggestions.js | 514 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 663 insertions(+) create mode 100644 src/suggestions.css create mode 100644 src/suggestions.js diff --git a/src/connector.js b/src/connector.js index d02d847..98e70a8 100644 --- a/src/connector.js +++ b/src/connector.js @@ -163,6 +163,16 @@ function initSearchUI() { if (urlParams.sort) { params.sort = urlParams.sort; } + // set the custom action cause for the initial search + if ( urlParams.actionCause ) { + params.actionCause = urlParams.actionCause; + + // changing the URL without reloading the page to remove actionCause + if ( window.history.pushState ) { + var newurl = winLoc.href.replace( '&actionCause=' + urlParams.actionCause, '' ); + window.history.pushState( { path : newurl }, '', newurl ); + } + } // Auto detect relative path from originLevel3 if( !params.originLevel3.startsWith( "/" ) && /http|www/.test( params.originLevel3 ) ) { @@ -585,6 +595,12 @@ function initEngine() { // filter user sensitive content requestContent.originLevel3 = params.originLevel3; + // override actionCause if present + if ( params.actionCause ) { + requestContent.actionCause = params.actionCause; + params.actionCause = ""; // reset the parameter to avoid polluting future searches with the same action cause + } + // documentAuthor cannot be longer than 128 chars based on search platform if ( requestContent.documentAuthor ) { requestContent.documentAuthor = requestContent.documentAuthor.substring( 0, 128 ); @@ -612,6 +628,11 @@ function initEngine() { requestContent.analytics.originLevel3 = params.originLevel3; } + // override actionCause if present + if ( params.actionCause ) { + requestContent.analytics.actionCause = params.actionCause; + } + let q = requestContent.q; requestContent.q = sanitizeQuery( q ); diff --git a/src/suggestions.css b/src/suggestions.css new file mode 100644 index 0000000..75db4b5 --- /dev/null +++ b/src/suggestions.css @@ -0,0 +1,128 @@ +/* + * Search UI: Styles for Query suggestion List "combobox", TO BE eventually replaced by GCWeb reference implementation codebase + */ + .query-suggestions { + background-color: white; + border-bottom: 1px solid #ccc; + border-left: 1px solid #e0e0e0; + border-right: 1px solid #e0e0e0; + width: calc(100% - 30px); + cursor: pointer; + list-style-type: none; + padding: 0; + position: absolute; + z-index: 60; +} + +.query-suggestions li { + padding: 5px 10px 5px 30px; + position: relative; +} + +.query-suggestions li:hover { + background-color: #ddd; +} + +.query-suggestions .suggestion-item::before { + content: "\e003"; + font-family: "Glyphicons Halflings"; + font-size: 0.8em; + line-height: 1.4; + margin-right: 12px; + position: relative; + top: 1px; + position: absolute; + transform: translateY(50%); + left: 8px; +} + +.query-suggestions .selected-suggestion { + background-color: #ddd; +} + +.query-suggestions:has(li) { + border-bottom: 1px solid #e0e0e0; +} + +@media screen and (max-width: 991px) { + .query-suggestions { + position: relative; + width: 100%; + } +} + +/* + * Render heading level 2 the same size as the heading level 4 + * To be added and replaced by out-of-the-box GCWeb + */ +.page-type-search h2 { + font-size: 1.1em; +} + +/* + * Render heading level 2 the same size as the heading level 4 + * To be added and replaced by out-of-the-box GCWeb + */ +.page-type-search .location li, .page-type-search cite a { + word-break: break-word; +} + +/* + * Add top margin to alert + * To be added and replaced by out-of-the-box GCWeb + */ +.page-type-search .alert { + margin-top: 30px; +} + +/* + * Pagination styles + * To be added and replaced by out-of-the-box GCWeb + */ +.pager>li>button, .pagination>li>button { + cursor: pointer; + display: inline-block; + margin-bottom: 0.5em; + padding: 10px 16px; +} +.pagination>li>button { + background-color: #eaebed; + border: 1px solid #dcdee1; + color: #335075; + float: left; + line-height: 1.4375; + margin-left: -1px; + padding: 10px 14px; + position: relative; + text-decoration: none; +} +.pagination>li:first-child>button { + border-bottom-left-radius: 4px; + border-top-left-radius: 4px; + margin-left: 0; +} +.pager>li.active>button, .pagination>li.active>button { + cursor: default; +} +.pagination>.active>button, .pagination>.active>button:focus, .pagination>.active>button:hover { + background-color: #2572b4; + border-color: #2572b4; + color: #fff; + cursor: default; + z-index: 3; +} +.pagination>li>.previous-page-button:before, .pagination>li>.next-page-button:after { + font-family: "Glyphicons Halflings"; + font-weight: 400; + line-height: 1em; + position: relative; + top: .1em; +} +.pagination>li>.previous-page-button:before { + content: "\e091"; + margin-right: .5em; +} +.pagination>li>.next-page-button:after { + content: "\e092"; + margin-left: .5em; +} diff --git a/src/suggestions.js b/src/suggestions.js new file mode 100644 index 0000000..4949df1 --- /dev/null +++ b/src/suggestions.js @@ -0,0 +1,514 @@ +// Search UI base +const baseElement = document.querySelector( '[data-gc-search]' ); + +// Window location variables +const winLoc = window.location; +const winPath = winLoc.pathname; +const winOrigin = winLoc.origin; +const originPath = winOrigin + winPath; + +// Parameters +const defaults = { + "searchHub": "canada-gouv-public-websites", + "organizationId": "", + "accessToken":"", + "searchBoxQuery": "#wb-srch-q", + "lang": "en", + "numberOfSuggestions": 5, + "minimumCharsForSuggestions": 3, + "originLevel3": originPath, + "pipeline": "" +}; +let lang = document.querySelector( "html" )?.lang; +let paramsOverride = baseElement ? JSON.parse( baseElement.dataset.gcSearch ) : {}; +let paramsDetect = {}; +let params = {}; +let urlParams; +let hashParams; +let visitorId = getVisitorId(); +let originLevel3RelativeUrl = ""; + +// UI states +let updateSearchBoxFromState = false; +let searchBoxState; +let lastCharKeyUp; +let activeSuggestion = 0; + +// Firefox patch +let isFirefox = navigator.userAgent.indexOf( "Firefox" ) !== -1; +let waitForkeyUp = false; + +// UI Elements placeholders +let searchBoxElement; +let formElement = document.querySelector( 'form[name="cse-search-box"]' ); +let suggestionsElement = document.querySelector( '#suggestions' ); +let qsA11yHintHTML = document.getElementById( 'sr-qs-hint' )?.innerHTML; + +if ( !qsA11yHintHTML ) { + if ( lang === "fr" ) { + qsA11yHintHTML = + ``; + } + else { + qsA11yHintHTML = + ``; + } +} + +// Init parameters and UI +function initSearchUI() { + if( !baseElement || !DOMPurify ) { + return; + } + + if ( !lang && winPath.includes( "/fr/" ) ) { + paramsDetect.lang = "fr"; + } + if ( lang.startsWith( "fr" ) ) { + paramsDetect.lang = "fr"; + } + + paramsDetect.originLevel3 = formElement.action; + + // Final parameters object + params = Object.assign( defaults, paramsDetect, paramsOverride ); + + // Update the URL params and the hash params on navigation + window.onpopstate = () => { + var match, + pl = /\+/g, // Regex for replacing addition symbol with a space + search = /([^&=]+)=?([^&]*)/g, + decode = function ( s ) { return decodeURIComponent( s.replace( pl, " " ) ); }, + query = winLoc.search.substring( 1 ); + + urlParams = {}; + hashParams = {}; + + // Ignore linting errors in regard to affectation instead of condition in the loops + // jshint -W084 + while ( match = search.exec( query ) ) { // eslint-disable-line no-cond-assign + urlParams[ decode(match[ 1 ] ) ] = stripHtml( decode( match[ 2 ] ) ); + } + query = winLoc.hash.substring( 1 ); + + while ( match = search.exec( query ) ) { // eslint-disable-line no-cond-assign + hashParams[ decode( match[ 1 ] ) ] = stripHtml( decode( match[ 2 ] ) ); + } + // jshint +W084 + }; + + window.onpopstate(); + + // Initialize templates + initTpl(); + + // override origineLevel3 through query parameters + if ( urlParams.originLevel3 ) { + params.originLevel3 = urlParams.originLevel3; + } + + // Auto detect relative path from originLevel3 + if( !params.originLevel3.startsWith( "/" ) && /http|www/.test( params.originLevel3 ) ) { + try { + const absoluteURL = new URL( params.originLevel3 ); + originLevel3RelativeUrl = absoluteURL.pathname; + } + catch( exception ) { + console.warn( "Exception while auto detecting relative path: " + exception.message ); + } + } + else { + originLevel3RelativeUrl = params.originLevel3; + } + + if ( !params.endpoints ) { + params.endpoints = getOrganizationEndpoints( params.organizationId ); + } + + // Do nothing if no access token is provided + if ( !params.accessToken ) { + return; + } + + // Initialize the engine + initEngine(); +} + +// Initialize default templates +function initTpl() { + // auto-create suggestions element + searchBoxElement = document.querySelector( params.searchBoxQuery ); + if ( searchBoxElement ) { + + // default searchbox attributes + searchBoxElement.setAttribute( 'type', 'search' ); // default, when query suggestions are disabled + + // if query suggestions are enabled and not advanced search, auto-create suggestions element and update searchbox attributes + if ( params.numberOfSuggestions > 0 && !suggestionsElement ) { + searchBoxElement.setAttribute( 'type', 'text' ); + searchBoxElement.role = "combobox"; + searchBoxElement.setAttribute( 'aria-expanded', 'false' ); + searchBoxElement.setAttribute( 'aria-autocomplete', 'list' ); + + suggestionsElement = document.createElement( "ul" ); + suggestionsElement.id = "suggestions"; + suggestionsElement.role = "listbox"; + suggestionsElement.classList.add( "query-suggestions" ); + + searchBoxElement.after( suggestionsElement ); + searchBoxElement.setAttribute( 'aria-controls', 'suggestions' ); + + // 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(); + } + } ); + } + } +} + +function getOrganizationEndpoints( organizationId ) { + const endpoints = { + analytics: `https://${organizationId}.analytics.org.coveo.com`, + search: `https://${organizationId}.org.coveo.com/rest/search/v2`, + } + + return endpoints; +} + +function sanitizeQuery(q) { + return q.replace(/<[^>]*>?/gm, ''); +} + +function getCookie(cname) { + let name = cname + "="; + let decodedCookie = decodeURIComponent(document.cookie); // Decode URI components + let ca = decodedCookie.split(';'); // Split the string into an array of cookies + + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + // Trim leading spaces from the cookie string + while (c.charAt(0) === ' ') { + c = c.substring(1); + } + // If the cookie name matches, return its value + if (c.indexOf(name) === 0) { + return c.substring(name.length, c.length); + } + } + return ""; // Return an empty string if the cookie is not found +} + +function getVisitorId() { + let cookieValue = getCookie("coveo_visitorId"); + if ( !cookieValue ) { + // Generate a new visitor ID (UUID v4) + cookieValue = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + let r = Math.random() * 16 | 0; + let v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + document.cookie = "coveo_visitorId=" + cookieValue + "; path=/"; + } + return cookieValue; +} +// 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; +} + +// Strip HTML tags of a given string +function stripHtml(html) { + let tmp = document.createElement( "DIV" ); + tmp.innerHTML = html; + return tmp.textContent || tmp.innerText || ""; +} + +// Initiate engine +function initEngine() { + // Listen to "Enter" key up event for search suggestions + if ( searchBoxElement ) { + searchBoxElement.onkeydown = ( e ) => { + // Enter + if ( e.keyCode === 13 && ( activeSuggestion !== 0 && suggestionsElement && !suggestionsElement.hidden ) ) { + selectSuggestion(); + closeSuggestionsBox(); + e.preventDefault(); + } + // Escape or Tab + else if ( e.keyCode === 27 || e.keyCode === 9 ) { + closeSuggestionsBox(); + + if ( e.keyCode === 27 ) { + e.preventDefault(); + } + } + // Arrow key up + else if ( e.keyCode === 38 ) { + if ( !( isFirefox && waitForkeyUp ) ) { + waitForkeyUp = true; + searchBoxArrowKey( "up" ); + e.preventDefault(); + } + } + // Arrow key down + else if ( e.keyCode === 40 ) { + if ( !( isFirefox && waitForkeyUp ) ) { + waitForkeyUp = true; + searchBoxArrowKey( "down" ); + } + } + }; + searchBoxElement.onkeyup = ( e ) => { + waitForkeyUp = false; + lastCharKeyUp = e.keyCode; + // Keys that don't changes the input value + if ( ( e.key.length !== 1 && e.keyCode !== 46 && e.keyCode !== 8 ) || // Non-printable char except Delete or Backspace + ( e.ctrlKey && e.key !== "x" && e.key !== "X" && e.key !== "v" && e.key !== "V" ) ) { // Ctrl-key is pressed but not X or V is use + return; + } + + // Any other key + if ( e.target.value ) { + updateSearchBoxText( sanitizeQuery( e.target.value ) ); + } + if ( e.target.value.length < params.minimumCharsForSuggestions ){ + closeSuggestionsBox(); + } + }; + searchBoxElement.onfocus = () => { + lastCharKeyUp = null; + if ( searchBoxElement.value.length >= params.minimumCharsForSuggestions ) { + updateSearchBoxText( sanitizeQuery( searchBoxElement.value ) ); + } + }; + } + + // Listen to submit event from the search form (advanced searches will instead reload the page with URl parameters to search on load) + if ( formElement ) { + formElement.onsubmit = ( e ) => { + e.preventDefault(); + redirectToSearchPage( 'headerSearchBoxSubmit' ); + }; + } +} + +function redirectToSearchPage( actionCause ) { + if ( formElement && searchBoxElement ) { + window.location.href = formElement.action + "?" + buildCleanQueryString( { q: searchBoxElement.value, actionCause : actionCause } ); + } +} + +function formatHighlightedSuggestion( highlighted ) { + return highlighted.replaceAll( '[', '' ) + .replaceAll( ']', '' ) + .replaceAll( '(', '' ) + .replaceAll( ')', '' ) + .replaceAll( '{', '' ) + .replaceAll( '}', '' ); +} + +function updateSearchBoxText( text ) { + if ( text.length < params.minimumCharsForSuggestions ) { + return; + } + + const body = { + count: params.numberOfSuggestions, + q: text, + locale: params.lang, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + context:{ + searchPageUrl: params.originLevel3, + searchPageRelativeUrl: originLevel3RelativeUrl + }, + searchHub: params.searchHub, + visitorId: visitorId, + analytics:{ + clientId: visitorId, + clientTimestamp: new Date().toISOString(), + documentReferrer: "default", + originContext: "Search", + capture:false + } + } + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + params.accessToken + }, + body: JSON.stringify( body ) + }; + + fetch(params.endpoints.search + "/querySuggest", options) + .then((response) => { + if (!response.ok) { + // Handle HTTP errors, e.g., 404 Not Found + console.error("HTTP error while getting query suggestions: ", response.status, response.statusText); + } + // Parse the response body as JSON and return a new Promise + return response.json(); + }) + .then((data) => { + updateSearchBoxState( { + isLoadingSuggestions: false, + isLoading: false, + value: text, + suggestions: data.completions.map( suggestion => ( { + highlightedValue: formatHighlightedSuggestion( suggestion.highlighted ), + highlighted: suggestion.highlighted + } ) ) + } ); + }) + .catch((error) => { + // Handle network errors or errors thrown in the .then() block + console.error("Error updating search box suggestions: ", error); + }); +} + +function searchBoxArrowKey( direction ) { + if ( suggestionsElement.hidden ) { + return; + } + + 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 ); + + if ( suggestionElement ) { + const selectedVal = stripHtml( suggestionElement.innerText ); + + if ( selectedVal ) { + searchBoxElement.value = selectedVal; + redirectToSearchPage( 'headerSearchBoxSuggestion' ) + } + } +} + +// 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' ); + let selectedSuggestionId = 'suggestion-' + activeSuggestion; + let suggestionElement = document.getElementById( selectedSuggestionId ); + Array.prototype.forEach.call(activeSelection, function( suggestion ) { + suggestion.classList.remove( 'selected-suggestion' ); + suggestion.setAttribute( 'aria-selected', "false" ); + }); + + suggestionElement.classList.add( 'selected-suggestion' ); + suggestionElement.setAttribute( 'aria-selected', "true" ); + searchBoxElement.setAttribute( 'aria-activedescendant', selectedSuggestionId ); +} + +// 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; + return; + } + + if ( !suggestionsElement ) { + return; + } + + if ( lastCharKeyUp === 13 ) { + closeSuggestionsBox(); + return; + } + + // Build suggestions list + activeSuggestion = 0; + if ( !searchBoxState.isLoadingSuggestions ) { + suggestionsElement.textContent = ''; + searchBoxState.suggestions.forEach( ( suggestion, index ) => { + const currentIndex = index + 1; + const suggestionId = "suggestion-" + currentIndex; + const node = document.createElement( "li" ); + node.setAttribute( "class", "suggestion-item" ); + node.setAttribute( "aria-selected", "false" ); + node.setAttribute( "aria-setsize", searchBoxState.suggestions.length ); + node.setAttribute( "aria-posinset", currentIndex ); + node.role = "option"; + node.id = suggestionId; + node.onmouseenter = () => { + activeSuggestion = index + 1; + updateSuggestionSelection(); + }; + node.onclick = ( e ) => { + searchBoxElement.value = stripHtml( e.currentTarget.innerText ); + redirectToSearchPage( 'headerSearchBoxSuggestion' ); + }; + node.innerHTML = DOMPurify.sanitize( suggestion.highlightedValue ); + suggestionsElement.appendChild( node ); + }); + + if ( !searchBoxState.isLoading && searchBoxState.suggestions.length > 0 && searchBoxState.value.length >= params.minimumCharsForSuggestions ) { + openSuggestionsBox(); + } + else{ + closeSuggestionsBox(); + } + } +} + +// Run Search UI +initSearchUI(); From b49db31256dc0ac1e35fc75092773e5b67fce436 Mon Sep 17 00:00:00 2001 From: bblaisATcoveo Date: Tue, 10 Feb 2026 15:40:02 -0500 Subject: [PATCH 2/2] Styling adjustments --- src/suggestions.css | 80 ++------------------------------------------- 1 file changed, 2 insertions(+), 78 deletions(-) diff --git a/src/suggestions.css b/src/suggestions.css index 75db4b5..5965cff 100644 --- a/src/suggestions.css +++ b/src/suggestions.css @@ -41,7 +41,7 @@ } .query-suggestions:has(li) { - border-bottom: 1px solid #e0e0e0; + border-bottom: 1px solid #ccc; } @media screen and (max-width: 991px) { @@ -49,80 +49,4 @@ position: relative; width: 100%; } -} - -/* - * Render heading level 2 the same size as the heading level 4 - * To be added and replaced by out-of-the-box GCWeb - */ -.page-type-search h2 { - font-size: 1.1em; -} - -/* - * Render heading level 2 the same size as the heading level 4 - * To be added and replaced by out-of-the-box GCWeb - */ -.page-type-search .location li, .page-type-search cite a { - word-break: break-word; -} - -/* - * Add top margin to alert - * To be added and replaced by out-of-the-box GCWeb - */ -.page-type-search .alert { - margin-top: 30px; -} - -/* - * Pagination styles - * To be added and replaced by out-of-the-box GCWeb - */ -.pager>li>button, .pagination>li>button { - cursor: pointer; - display: inline-block; - margin-bottom: 0.5em; - padding: 10px 16px; -} -.pagination>li>button { - background-color: #eaebed; - border: 1px solid #dcdee1; - color: #335075; - float: left; - line-height: 1.4375; - margin-left: -1px; - padding: 10px 14px; - position: relative; - text-decoration: none; -} -.pagination>li:first-child>button { - border-bottom-left-radius: 4px; - border-top-left-radius: 4px; - margin-left: 0; -} -.pager>li.active>button, .pagination>li.active>button { - cursor: default; -} -.pagination>.active>button, .pagination>.active>button:focus, .pagination>.active>button:hover { - background-color: #2572b4; - border-color: #2572b4; - color: #fff; - cursor: default; - z-index: 3; -} -.pagination>li>.previous-page-button:before, .pagination>li>.next-page-button:after { - font-family: "Glyphicons Halflings"; - font-weight: 400; - line-height: 1em; - position: relative; - top: .1em; -} -.pagination>li>.previous-page-button:before { - content: "\e091"; - margin-right: .5em; -} -.pagination>li>.next-page-button:after { - content: "\e092"; - margin-left: .5em; -} +} \ No newline at end of file