From ee1e1cd22da5a6844dc27279ee46ceb39acdd38d Mon Sep 17 00:00:00 2001 From: bblaisATcoveo Date: Tue, 3 Dec 2024 16:08:34 -0500 Subject: [PATCH 01/13] Add Pipeline as parameter Add Pipeline as parameter to let specific implementation use a different pipeline instead of letting normal query pipeline routing. --- src/connector.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/connector.js b/src/connector.js index 0325893..714150e 100644 --- a/src/connector.js +++ b/src/connector.js @@ -35,7 +35,8 @@ const defaults = { "enableHistoryPush": true, "isContextSearch": false, "isAdvancedSearch": false, - "originLevel3": window.location.origin + winPath + "originLevel3": window.location.origin + winPath, + "pipeline": "" }; let lang = document.querySelector( "html" )?.lang; let paramsOverride = baseElement ? JSON.parse( baseElement.dataset.gcSearch ) : {}; @@ -411,6 +412,7 @@ function initEngine() { search: { locale: params.lang, searchHub: params.searchHub, + pipeline: params.pipeline }, preprocessRequest: ( request, clientOrigin ) => { try { From a19a67438c91502851a48a15c289559384865da1 Mon Sep 17 00:00:00 2001 From: bblaisATcoveo Date: Tue, 3 Dec 2024 16:24:23 -0500 Subject: [PATCH 02/13] Fix long documentAuthor value Fix for issue #384306. variable documentAuthor should not be more than 128 char on an analytics call to avoid errors in the analytics console --- src/connector.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/connector.js b/src/connector.js index 714150e..faf0c7a 100644 --- a/src/connector.js +++ b/src/connector.js @@ -416,11 +416,17 @@ function initEngine() { }, preprocessRequest: ( request, clientOrigin ) => { try { - if ( clientOrigin === 'analyticsFetch' ) { + if( clientOrigin === 'analyticsFetch' || clientOrigin === 'analyticsBeacon' ) { let requestContent = JSON.parse( request.body ); // filter user sensitive content requestContent.originLevel3 = params.originLevel3; + + // documentAuthor cannot be longer than 128 chars + if ( requestContent.documentAuthor ){ + requestContent.documentAuthor = requestContent.documentAuthor.substring( 0, 128 ); + } + request.body = JSON.stringify( requestContent ); // Event used to expose a data layer when search events occur; useful for analytics From 1a9c63b345273446bd3d2af094e00014e09e383a Mon Sep 17 00:00:00 2001 From: bblaisATcoveo Date: Tue, 3 Dec 2024 16:50:27 -0500 Subject: [PATCH 03/13] Result labels as list items Fix for issue #381226. Labels in the result list should be displayed as list labels and not a single list item separated by semicolon (;) --- src/connector.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/connector.js b/src/connector.js index faf0c7a..4a52d05 100644 --- a/src/connector.js +++ b/src/connector.js @@ -869,13 +869,9 @@ function updateResultListState( newState ) { else { author = result.raw.author; } - if( params.isContextSearch ) { - author = author.replace( ';', ', ' ); - } - else { - author = author.replace( ',', ';' ); - author = author.replace( ';' , '
  • ' ); - } + + author = author.replaceAll( ';' , '
  • ' ); + } } sectionNode.innerHTML = resultTemplateHTML From 5ac3c44778138860e008830af4b23a8214251c76 Mon Sep 17 00:00:00 2001 From: bblaisATcoveo Date: Tue, 3 Dec 2024 17:01:02 -0500 Subject: [PATCH 04/13] Fix result breadcrumb Fix for issue #373500. Display simple breadcrumb in green following the latest indexing fix, using the format [hostname] > [1st element of breadcrumb]. Default to printableuri if not available. --- src/connector.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/connector.js b/src/connector.js index 4a52d05..17189df 100644 --- a/src/connector.js +++ b/src/connector.js @@ -479,7 +479,7 @@ function initEngine() { resultListController = buildResultList( headlessEngine, { options: { - fieldsToInclude: [ "author", "date", "language", "urihash", "objecttype", "collection", "source", "permanentid", "displaynavlabel" ] + fieldsToInclude: [ "author", "date", "language", "urihash", "objecttype", "collection", "source", "permanentid", "displaynavlabel", "hostname" ] } } ); querySummaryController = buildQuerySummary( headlessEngine ); @@ -872,6 +872,11 @@ function updateResultListState( newState ) { author = author.replaceAll( ';' , '
  • ' ); } + + let breadcrumb = ""; + if ( result.raw.hostname && result.raw.displaynavlabel ) { + const splittedNavLabel = ( Array.isArray( result.raw.displaynavlabel ) ? result.raw.displaynavlabel[0] : result.raw.displaynavlabel).split( '>' ); + breadcrumb = result.raw.hostname + ' 
  • ' + splittedNavLabel[splittedNavLabel.length-1]; } sectionNode.innerHTML = resultTemplateHTML @@ -880,7 +885,7 @@ function updateResultListState( newState ) { .replace( '%[result.clickUri]', filterProtocol( result.clickUri ) ) .replace( '%[result.title]', result.title ) .replace( '%[result.raw.author]', author ) - .replace( '%[result.breadcrumb]', result.raw.displaynavlabel ? result.raw.displaynavlabel : result.printableUri ) + .replace( '%[result.breadcrumb]', breadcrumb ? breadcrumb : result.printableUri ) .replace( '%[result.printableUri]', result.printableUri.replaceAll( '&' , '&' ) ) .replace( '%[short-date-en]', getShortDateFormat( resultDate ) ) .replace( '%[short-date-fr]', getShortDateFormat( resultDate ) ) From 9426189ddb0090776589eff18adac9883cb50878 Mon Sep 17 00:00:00 2001 From: bblaisATcoveo Date: Tue, 3 Dec 2024 17:10:34 -0500 Subject: [PATCH 05/13] Enable Query Suggestions Enable Query Suggestions in the main search box of the search results page. --- src/connector.js | 208 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 150 insertions(+), 58 deletions(-) diff --git a/src/connector.js b/src/connector.js index 17189df..ba22924 100644 --- a/src/connector.js +++ b/src/connector.js @@ -31,6 +31,7 @@ const defaults = { "searchBoxQuery": "#sch-inp-ac", "lang": "en", "numberOfSuggestions": 0, + "minimumCharsForSuggestions": 1, "unsupportedSuggestions": false, "enableHistoryPush": true, "isContextSearch": false, @@ -70,6 +71,12 @@ let querySummaryState; let didYouMeanState; let pagerState; let lastCharKeyUp; +let activeSuggestion = 0; +let activeSuggestionWaitMouseMove = true; + +// Firefox patch +let isFirefox = navigator.userAgent.indexOf( "Firefox" ) != -1; +let waitForkeyUp = false; // UI Elements placeholders let searchBoxElement; @@ -114,6 +121,9 @@ function initSearchUI() { params = Object.assign( defaults, paramsDetect, paramsOverride ); searchBoxElement = document.querySelector( params.searchBoxQuery ); + if ( params.numberOfSuggestions > 0 && searchBoxElement ) { + searchBoxElement.role = "combobox"; + } // Update the URL params and the hash params on navigation window.onpopstate = () => { @@ -357,47 +367,19 @@ function initTpl() { if ( !suggestionsElement && searchBoxElement && params.unsupportedSuggestions && params.numberOfSuggestions > 0 ) { suggestionsElement = document.createElement( "ul" ); suggestionsElement.id = "suggestions"; + suggestionsElement.role = "listbox"; suggestionsElement.classList.add( "rough-experimental", "query-suggestions" ); searchBoxElement.after( suggestionsElement ); + searchBoxElement.setAttribute('aria-controls', 'suggestions'); } - // Query suggestions - if ( suggestionsElement ) { - - // Remove unsupported query suggestion if on production (www.canada.ca) - if( window.location.hostname === "www.canada.ca" ) { - suggestionsElement.remove(); - } - - // Add an alert banner to clearly state that the Query suggestion feature is at a rough experimental state - else { - const firstH1 = document.querySelector( "main h1:first-child" ); - let roughExperimentAlert = document.createElement( "section" ); - - roughExperimentAlert.classList.add( "alert", "alert-danger" ); - - if ( lang === "fr" ) { - roughExperimentAlert.innerHTML = - `

    Avis de fonctionnalité instable

    -

    Cette page utilise une fonctionnalité expérimentale pouvant contenir des problèmes d'accessibilité et/ou de produire des effets indésirables qui peuvent altérer l'expérience de l'utilisateur.

    `; - } - else { - roughExperimentAlert.innerHTML = - `

    Unstable feature notice

    -

    This page leverages an experimental feature subject to contain accessibility issues and/or to produce unwanted behavior which may alter the user experience.

    `; - } - - firstH1.after( roughExperimentAlert ); - - // Remove Query suggestion if click elsewhere - document.addEventListener( "click", function( evnt ) { - if ( suggestionsElement && ( evnt.target.className !== "suggestion-item" && evnt.target.id !== "sch-inp-ac" ) ) { - suggestionsElement.hidden = true; - } - } ); + // Remove Query suggestion if click elsewhere + document.addEventListener( "click", function( evnt ) { + if ( suggestionsElement && ( evnt.target.className !== "suggestion-item" && evnt.target.id !== "sch-inp-ac" ) ) { + closeSuggestionsBox(); } - } + } ); } function sanitizeQuery(q) { return q.replace(/<[^>]*>?/gm, ''); @@ -469,10 +451,6 @@ function initEngine() { open: '', close: '', }, - correctionDelimiters: { - open: '', - close: '', - }, }, } } ); @@ -711,16 +689,58 @@ 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 ) ) { + 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; + searchBoxArrowKeyUp(); + e.preventDefault(); + } + } + // Arrow key down + else if ( e.keyCode === 40 ) { + if ( !( isFirefox && waitForkeyUp ) ){ + waitForkeyUp = true; + searchBoxArrowKeyDown(); + } + } + } 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; + } - if( e.keyCode !== 13 && searchBoxController.state.value !== e.target.value ) { + // Any other key + if ( searchBoxController.state.value !== e.target.value ) { searchBoxController.updateText( DOMPurify.sanitize( e.target.value ) ); } + if ( e.target.value.length < params.minimumCharsForSuggestions ){ + closeSuggestionsBox(); + } }; searchBoxElement.onfocus = () => { lastCharKeyUp = null; - searchBoxController.showSuggestions(); + if ( searchBoxElement.value.length >= params.minimumCharsForSuggestions ) { + searchBoxController.showSuggestions(); + } }; } @@ -750,6 +770,48 @@ function initEngine() { } } +function searchBoxArrowKeyUp() { + if ( suggestionsElement.hidden ){ + return; + } + + if ( !activeSuggestion || activeSuggestion <= 1 ) + activeSuggestion = searchBoxState.suggestions.length; + else + activeSuggestion -= 1; + + updateSuggestionSelection(); +} + +function searchBoxArrowKeyDown() { + if ( suggestionsElement.hidden ){ + return; + } + + if ( !activeSuggestion || activeSuggestion >= searchBoxState.suggestions.length ) + activeSuggestion = 1; + else + activeSuggestion += 1; + + updateSuggestionSelection(); +} + +function updateSuggestionSelection() { + // clear current suggestion + let activeSelection = suggestionsElement.getElementsByClassName( 'selected-suggestion' ); + Array.prototype.forEach.call(activeSelection, function( suggestion ) { + suggestion.classList.remove( 'selected-suggestion' ); + suggestion.removeAttribute( 'aria-selected' ); + }); + + let selectedSuggestionId = 'suggestion-' + activeSuggestion; + let suggestionElement = document.getElementById( selectedSuggestionId ); + suggestionElement.classList.add( 'selected-suggestion' ); + suggestionElement.setAttribute( 'aria-selected', "true" ); + searchBoxElement.setAttribute( 'aria-activedescendant', selectedSuggestionId ); + searchBoxElement.value = suggestionElement.innerText; +} + // Show query suggestions if a search action was not executed (if enabled) function updateSearchBoxState( newState ) { const previousState = searchBoxState; @@ -766,27 +828,57 @@ function updateSearchBoxState( newState ) { } if ( lastCharKeyUp === 13 ) { - suggestionsElement.hidden = true; + closeSuggestionsBox(); return; } + activeSuggestion = 0; if ( !searchBoxState.isLoadingSuggestions && previousState?.isLoadingSuggestions ) { - suggestionsElement.textContent = ''; - searchBoxState.suggestions.forEach( ( suggestion ) => { - const node = document.createElement( "li" ); - node.setAttribute( "class", "suggestion-item" ); - node.onclick = ( e ) => { - searchBoxController.selectSuggestion(e.currentTarget.innerText); - searchBoxElement.value = DOMPurify.sanitize( e.currentTarget.innerText ); - }; - node.innerHTML = suggestion.highlightedValue; - suggestionsElement.appendChild( node ); - }); + suggestionsElement.textContent = ''; + activeSuggestionWaitMouseMove = true; + searchBoxState.suggestions.forEach( ( suggestion, index ) => { + const suggestionId = "suggestion-" + ( index + 1 ); + const node = document.createElement( "li" ); + node.setAttribute( "class", "suggestion-item" ); + node.setAttribute( "role", "option" ); + node.id = suggestionId; + node.onmouseenter = ( e ) => { + if ( !activeSuggestionWaitMouseMove ) { + activeSuggestion = index + 1; + updateSuggestionSelection(); + } + } + node.onmousemove = ( e ) => { + activeSuggestionWaitMouseMove = false; + } + node.onclick = ( e ) => { + searchBoxController.selectSuggestion(e.currentTarget.innerText); + searchBoxElement.value = DOMPurify.sanitize( e.currentTarget.innerText ); + }; + node.innerHTML = suggestion.highlightedValue; + suggestionsElement.appendChild( node ); + }); + + if ( !searchBoxState.isLoading && searchBoxState.suggestions.length > 0 && searchBoxState.value.length >= params.minimumCharsForSuggestions ) { + openSuggestionsBox(); + } + else{ + closeSuggestionsBox(); + } + } +} - if ( searchBoxState.suggestions.length > 0 ) { - suggestionsElement.hidden = false; - } - } +// open the suggestions box +function openSuggestionsBox() { + suggestionsElement.hidden = false; + searchBoxElement.setAttribute('aria-expanded', 'true'); +} + +// open the suggestions box +function closeSuggestionsBox() { + suggestionsElement.hidden = true; + activeSuggestion = 0; + searchBoxElement.setAttribute('aria-expanded', 'false'); } // rebuild a clean query string out of a JSON object @@ -842,7 +934,7 @@ function updateResultListState( newState ) { if ( resultListState.isLoading ) { if ( suggestionsElement ) { - suggestionsElement.hidden = true; + closeSuggestionsBox(); } return; } From be27f1a1848f7276db348b2e36f5f26b57449d5a Mon Sep 17 00:00:00 2001 From: bblaisATcoveo Date: Thu, 12 Dec 2024 13:30:26 -0500 Subject: [PATCH 06/13] Removed unsupportedSuggestions flag Removed unsupportedSuggestions flag from default params and implementation --- src/connector.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/connector.js b/src/connector.js index ba22924..c04d97e 100644 --- a/src/connector.js +++ b/src/connector.js @@ -32,7 +32,6 @@ const defaults = { "lang": "en", "numberOfSuggestions": 0, "minimumCharsForSuggestions": 1, - "unsupportedSuggestions": false, "enableHistoryPush": true, "isContextSearch": false, "isAdvancedSearch": false, @@ -364,7 +363,7 @@ function initTpl() { } // auto-create suggestions element - if ( !suggestionsElement && searchBoxElement && params.unsupportedSuggestions && params.numberOfSuggestions > 0 ) { + if ( !suggestionsElement && searchBoxElement && params.numberOfSuggestions > 0 ) { suggestionsElement = document.createElement( "ul" ); suggestionsElement.id = "suggestions"; suggestionsElement.role = "listbox"; @@ -847,10 +846,10 @@ function updateSearchBoxState( newState ) { activeSuggestion = index + 1; updateSuggestionSelection(); } - } + }; node.onmousemove = ( e ) => { activeSuggestionWaitMouseMove = false; - } + }; node.onclick = ( e ) => { searchBoxController.selectSuggestion(e.currentTarget.innerText); searchBoxElement.value = DOMPurify.sanitize( e.currentTarget.innerText ); From 5c8b8cae8b9d699234c41ecfffdb3317c00ad9fe Mon Sep 17 00:00:00 2001 From: bblaisATcoveo Date: Thu, 12 Dec 2024 13:33:14 -0500 Subject: [PATCH 07/13] Added semicolon for readability --- src/connector.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/connector.js b/src/connector.js index c04d97e..c30d47a 100644 --- a/src/connector.js +++ b/src/connector.js @@ -717,7 +717,7 @@ function initEngine() { searchBoxArrowKeyDown(); } } - } + }; searchBoxElement.onkeyup = ( e ) => { waitForkeyUp = false; lastCharKeyUp = e.keyCode; From 8a7bd737e14f8ba61a9158d9979efacfdb749915 Mon Sep 17 00:00:00 2001 From: bblaisATcoveo Date: Tue, 17 Dec 2024 16:21:23 -0500 Subject: [PATCH 08/13] fixes following code review --- src/connector.css | 9 ++- src/connector.js | 140 ++++++++++++++++++++++++---------------------- 2 files changed, 79 insertions(+), 70 deletions(-) diff --git a/src/connector.css b/src/connector.css index 840ef2c..64be078 100644 --- a/src/connector.css +++ b/src/connector.css @@ -1,7 +1,7 @@ /* * Search UI: Styles for Query suggestion List "combobox", TO BE eventually replaced by GCWeb reference implementation codebase */ - .rough-experimental.query-suggestions { + .query-suggestions { background-color: white; border-bottom: 1px solid #ccc; border-left: 1px solid #ccc; @@ -15,10 +15,13 @@ width: 100%; z-index: 60; } -.rough-experimental.query-suggestions li { +.query-suggestions li { padding: 5px 10px; } -.rough-experimental.query-suggestions li:hover { +.query-suggestions li:hover { + background-color: #ddd; +} +.query-suggestions .selected-suggestion { background-color: #ddd; } diff --git a/src/connector.js b/src/connector.js index c30d47a..ca4cc17 100644 --- a/src/connector.js +++ b/src/connector.js @@ -31,7 +31,7 @@ const defaults = { "searchBoxQuery": "#sch-inp-ac", "lang": "en", "numberOfSuggestions": 0, - "minimumCharsForSuggestions": 1, + "minimumCharsForSuggestions": 2, "enableHistoryPush": true, "isContextSearch": false, "isAdvancedSearch": false, @@ -74,7 +74,7 @@ let activeSuggestion = 0; let activeSuggestionWaitMouseMove = true; // Firefox patch -let isFirefox = navigator.userAgent.indexOf( "Firefox" ) != -1; +let isFirefox = navigator.userAgent.indexOf( "Firefox" ) !== -1; let waitForkeyUp = false; // UI Elements placeholders @@ -119,11 +119,6 @@ function initSearchUI() { // Final parameters object params = Object.assign( defaults, paramsDetect, paramsOverride ); - searchBoxElement = document.querySelector( params.searchBoxQuery ); - if ( params.numberOfSuggestions > 0 && searchBoxElement ) { - searchBoxElement.role = "combobox"; - } - // Update the URL params and the hash params on navigation window.onpopstate = () => { var match, @@ -363,11 +358,17 @@ function initTpl() { } // auto-create suggestions element + searchBoxElement = document.querySelector( params.searchBoxQuery ); if ( !suggestionsElement && searchBoxElement && params.numberOfSuggestions > 0 ) { + if ( params.numberOfSuggestions > 0 ) { + searchBoxElement.role = "combobox"; + searchBoxElement.setAttribute('aria-autocomplete', 'list'); + } + suggestionsElement = document.createElement( "ul" ); suggestionsElement.id = "suggestions"; suggestionsElement.role = "listbox"; - suggestionsElement.classList.add( "rough-experimental", "query-suggestions" ); + suggestionsElement.classList.add( "query-suggestions" ); searchBoxElement.after( suggestionsElement ); searchBoxElement.setAttribute('aria-controls', 'suggestions'); @@ -404,7 +405,7 @@ function initEngine() { requestContent.originLevel3 = params.originLevel3; // documentAuthor cannot be longer than 128 chars - if ( requestContent.documentAuthor ){ + if ( requestContent.documentAuthor ) { requestContent.documentAuthor = requestContent.documentAuthor.substring( 0, 128 ); } @@ -690,7 +691,7 @@ function initEngine() { if ( searchBoxElement ) { searchBoxElement.onkeydown = ( e ) => { // Enter - if ( e.keyCode === 13 && ( activeSuggestion != 0 && suggestionsElement && !suggestionsElement.hidden ) ) { + if ( e.keyCode === 13 && ( activeSuggestion !== 0 && suggestionsElement && !suggestionsElement.hidden ) ) { closeSuggestionsBox(); e.preventDefault(); } @@ -704,7 +705,7 @@ function initEngine() { } // Arrow key up else if ( e.keyCode === 38 ) { - if ( !( isFirefox && waitForkeyUp ) ){ + if ( !( isFirefox && waitForkeyUp ) ) { waitForkeyUp = true; searchBoxArrowKeyUp(); e.preventDefault(); @@ -712,7 +713,7 @@ function initEngine() { } // Arrow key down else if ( e.keyCode === 40 ) { - if ( !( isFirefox && waitForkeyUp ) ){ + if ( !( isFirefox && waitForkeyUp ) ) { waitForkeyUp = true; searchBoxArrowKeyDown(); } @@ -722,8 +723,8 @@ function initEngine() { 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 + 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; } @@ -770,45 +771,49 @@ function initEngine() { } function searchBoxArrowKeyUp() { - if ( suggestionsElement.hidden ){ + if ( suggestionsElement.hidden ) { return; } - if ( !activeSuggestion || activeSuggestion <= 1 ) + if ( !activeSuggestion || activeSuggestion <= 1 ) { activeSuggestion = searchBoxState.suggestions.length; - else + } + else { activeSuggestion -= 1; + } updateSuggestionSelection(); } function searchBoxArrowKeyDown() { - if ( suggestionsElement.hidden ){ + if ( suggestionsElement.hidden ) { return; } - if ( !activeSuggestion || activeSuggestion >= searchBoxState.suggestions.length ) + if ( !activeSuggestion || activeSuggestion >= searchBoxState.suggestions.length ) { activeSuggestion = 1; - else + } + else { activeSuggestion += 1; + } updateSuggestionSelection(); } function updateSuggestionSelection() { - // clear current suggestion - let activeSelection = suggestionsElement.getElementsByClassName( 'selected-suggestion' ); - Array.prototype.forEach.call(activeSelection, function( suggestion ) { - suggestion.classList.remove( 'selected-suggestion' ); - suggestion.removeAttribute( 'aria-selected' ); - }); - - let selectedSuggestionId = 'suggestion-' + activeSuggestion; - let suggestionElement = document.getElementById( selectedSuggestionId ); - suggestionElement.classList.add( 'selected-suggestion' ); - suggestionElement.setAttribute( 'aria-selected', "true" ); - searchBoxElement.setAttribute( 'aria-activedescendant', selectedSuggestionId ); - searchBoxElement.value = suggestionElement.innerText; + // clear current suggestion + let activeSelection = suggestionsElement.getElementsByClassName( 'selected-suggestion' ); + Array.prototype.forEach.call(activeSelection, function( suggestion ) { + suggestion.classList.remove( 'selected-suggestion' ); + suggestion.removeAttribute( 'aria-selected' ); + }); + + let selectedSuggestionId = 'suggestion-' + activeSuggestion; + let suggestionElement = document.getElementById( selectedSuggestionId ); + suggestionElement.classList.add( 'selected-suggestion' ); + suggestionElement.setAttribute( 'aria-selected', "true" ); + searchBoxElement.setAttribute( 'aria-activedescendant', selectedSuggestionId ); + searchBoxElement.value = suggestionElement.innerText; } // Show query suggestions if a search action was not executed (if enabled) @@ -833,51 +838,52 @@ function updateSearchBoxState( newState ) { activeSuggestion = 0; if ( !searchBoxState.isLoadingSuggestions && previousState?.isLoadingSuggestions ) { - suggestionsElement.textContent = ''; - activeSuggestionWaitMouseMove = true; - searchBoxState.suggestions.forEach( ( suggestion, index ) => { - const suggestionId = "suggestion-" + ( index + 1 ); - const node = document.createElement( "li" ); - node.setAttribute( "class", "suggestion-item" ); - node.setAttribute( "role", "option" ); - node.id = suggestionId; - node.onmouseenter = ( e ) => { - if ( !activeSuggestionWaitMouseMove ) { - activeSuggestion = index + 1; - updateSuggestionSelection(); - } - }; - node.onmousemove = ( e ) => { - activeSuggestionWaitMouseMove = false; - }; - node.onclick = ( e ) => { - searchBoxController.selectSuggestion(e.currentTarget.innerText); - searchBoxElement.value = DOMPurify.sanitize( e.currentTarget.innerText ); - }; - node.innerHTML = suggestion.highlightedValue; - suggestionsElement.appendChild( node ); - }); - - if ( !searchBoxState.isLoading && searchBoxState.suggestions.length > 0 && searchBoxState.value.length >= params.minimumCharsForSuggestions ) { - openSuggestionsBox(); - } - else{ - closeSuggestionsBox(); - } - } + suggestionsElement.textContent = ''; + activeSuggestionWaitMouseMove = true; + searchBoxState.suggestions.forEach( ( suggestion, index ) => { + const suggestionId = "suggestion-" + ( index + 1 ); + const node = document.createElement( "li" ); + node.setAttribute( "class", "suggestion-item" ); + node.setAttribute( "role", "option" ); + node.id = suggestionId; + node.onmouseenter = () => { + if ( !activeSuggestionWaitMouseMove ) { + activeSuggestion = index + 1; + updateSuggestionSelection(); + } + }; + node.onmousemove = () => { + activeSuggestionWaitMouseMove = false; + }; + node.onclick = ( e ) => { + searchBoxController.selectSuggestion(e.currentTarget.innerText); + searchBoxElement.value = DOMPurify.sanitize( e.currentTarget.innerText ); + }; + node.innerHTML = suggestion.highlightedValue; + suggestionsElement.appendChild( node ); + }); + + if ( !searchBoxState.isLoading && searchBoxState.suggestions.length > 0 && searchBoxState.value.length >= params.minimumCharsForSuggestions ) { + openSuggestionsBox(); + } + else{ + closeSuggestionsBox(); + } + } } // open the suggestions box function openSuggestionsBox() { suggestionsElement.hidden = false; - searchBoxElement.setAttribute('aria-expanded', 'true'); + searchBoxElement.setAttribute( 'aria-expanded', 'true' ); } // open the suggestions box function closeSuggestionsBox() { suggestionsElement.hidden = true; activeSuggestion = 0; - searchBoxElement.setAttribute('aria-expanded', 'false'); + searchBoxElement.setAttribute( 'aria-expanded', 'false' ); + searchBoxElement.setAttribute( 'aria-activedescendant', '' ); } // rebuild a clean query string out of a JSON object From ba05c916fa81087d6bf7fa8ef6e54633d0548c3d Mon Sep 17 00:00:00 2001 From: bblaisATcoveo Date: Tue, 7 Jan 2025 15:30:03 -0500 Subject: [PATCH 09/13] minor updates following comments --- src/connector.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/connector.js b/src/connector.js index ca4cc17..98d7a2f 100644 --- a/src/connector.js +++ b/src/connector.js @@ -360,10 +360,8 @@ function initTpl() { // auto-create suggestions element searchBoxElement = document.querySelector( params.searchBoxQuery ); if ( !suggestionsElement && searchBoxElement && params.numberOfSuggestions > 0 ) { - if ( params.numberOfSuggestions > 0 ) { - searchBoxElement.role = "combobox"; - searchBoxElement.setAttribute('aria-autocomplete', 'list'); - } + searchBoxElement.role = "combobox"; + searchBoxElement.setAttribute( 'aria-autocomplete', 'list' ); suggestionsElement = document.createElement( "ul" ); suggestionsElement.id = "suggestions"; @@ -371,7 +369,7 @@ function initTpl() { suggestionsElement.classList.add( "query-suggestions" ); searchBoxElement.after( suggestionsElement ); - searchBoxElement.setAttribute('aria-controls', 'suggestions'); + searchBoxElement.setAttribute( 'aria-controls', 'suggestions' ); } // Remove Query suggestion if click elsewhere @@ -404,7 +402,7 @@ function initEngine() { // filter user sensitive content requestContent.originLevel3 = params.originLevel3; - // documentAuthor cannot be longer than 128 chars + // documentAuthor cannot be longer than 128 chars based on search platform if ( requestContent.documentAuthor ) { requestContent.documentAuthor = requestContent.documentAuthor.substring( 0, 128 ); } From 5764952e57bbca8d867011a0034aa4fd75dfc948 Mon Sep 17 00:00:00 2001 From: bblaisATcoveo Date: Tue, 7 Jan 2025 16:13:04 -0500 Subject: [PATCH 10/13] update breadcrumb to follow GoC standard --- src/connector.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/connector.js b/src/connector.js index 98d7a2f..f6c7ce1 100644 --- a/src/connector.js +++ b/src/connector.js @@ -168,14 +168,14 @@ function initTpl() { resultTemplateHTML = `

    %[result.title]

    • %[result.raw.author]
    -
    1. %[result.breadcrumb]
    + %[result.breadcrumb]

    - %[highlightedExcerpt]

    `; } else { resultTemplateHTML = `

    %[result.title]

    • %[result.raw.author]
    -
    1. %[result.breadcrumb]
    + %[result.breadcrumb]

    - %[highlightedExcerpt]

    `; } } @@ -971,7 +971,10 @@ function updateResultListState( newState ) { let breadcrumb = ""; if ( result.raw.hostname && result.raw.displaynavlabel ) { const splittedNavLabel = ( Array.isArray( result.raw.displaynavlabel ) ? result.raw.displaynavlabel[0] : result.raw.displaynavlabel).split( '>' ); - breadcrumb = result.raw.hostname + ' 
  • ' + splittedNavLabel[splittedNavLabel.length-1]; + breadcrumb = '
    1. ' + result.raw.hostname + ' 
    2. ' + splittedNavLabel[splittedNavLabel.length-1] + '
    '; + } + else { + breadcrumb = '

    ' + result.printableUri + '

    '; } sectionNode.innerHTML = resultTemplateHTML @@ -980,7 +983,7 @@ function updateResultListState( newState ) { .replace( '%[result.clickUri]', filterProtocol( result.clickUri ) ) .replace( '%[result.title]', result.title ) .replace( '%[result.raw.author]', author ) - .replace( '%[result.breadcrumb]', breadcrumb ? breadcrumb : result.printableUri ) + .replace( '%[result.breadcrumb]', breadcrumb ) .replace( '%[result.printableUri]', result.printableUri.replaceAll( '&' , '&' ) ) .replace( '%[short-date-en]', getShortDateFormat( resultDate ) ) .replace( '%[short-date-fr]', getShortDateFormat( resultDate ) ) From 170a544001da3f71f981938f8847dca3fe8455e2 Mon Sep 17 00:00:00 2001 From: bblaisATcoveo Date: Tue, 28 Jan 2025 15:01:43 -0500 Subject: [PATCH 11/13] Accessibility and security updates - always have aria-selected on suggestions - add aria-setsize and aria-posinset to suggestions - add sanitization to values coming from search engine --- src/connector.js | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/src/connector.js b/src/connector.js index f6c7ce1..3e0d483 100644 --- a/src/connector.js +++ b/src/connector.js @@ -801,13 +801,13 @@ function searchBoxArrowKeyDown() { 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.removeAttribute( 'aria-selected' ); + suggestion.setAttribute( 'aria-selected', "false" ); }); - let selectedSuggestionId = 'suggestion-' + activeSuggestion; - let suggestionElement = document.getElementById( selectedSuggestionId ); suggestionElement.classList.add( 'selected-suggestion' ); suggestionElement.setAttribute( 'aria-selected', "true" ); searchBoxElement.setAttribute( 'aria-activedescendant', selectedSuggestionId ); @@ -839,10 +839,14 @@ function updateSearchBoxState( newState ) { suggestionsElement.textContent = ''; activeSuggestionWaitMouseMove = true; searchBoxState.suggestions.forEach( ( suggestion, index ) => { - const suggestionId = "suggestion-" + ( index + 1 ); + const currentIndex = index + 1; + const suggestionId = "suggestion-" + currentIndex; const node = document.createElement( "li" ); node.setAttribute( "class", "suggestion-item" ); - node.setAttribute( "role", "option" ); + 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 = () => { if ( !activeSuggestionWaitMouseMove ) { @@ -854,10 +858,10 @@ function updateSearchBoxState( newState ) { activeSuggestionWaitMouseMove = false; }; node.onclick = ( e ) => { - searchBoxController.selectSuggestion(e.currentTarget.innerText); + searchBoxController.selectSuggestion( e.currentTarget.innerText ); searchBoxElement.value = DOMPurify.sanitize( e.currentTarget.innerText ); }; - node.innerHTML = suggestion.highlightedValue; + node.innerHTML = DOMPurify.sanitize( suggestion.highlightedValue ); suggestionsElement.appendChild( node ); }); @@ -959,32 +963,36 @@ function updateResultListState( newState ) { if( result.raw.author ) { if( Array.isArray( result.raw.author ) ) { - author = result.raw.author.join( ';' ); + author = DOMPurify.sanitize( result.raw.author.join( ';' ) ); } else { - author = result.raw.author; + author = DOMPurify.sanitize( result.raw.author ); } author = author.replaceAll( ';' , '
  • ' ); } let breadcrumb = ""; + let printableUri = DOMPurify.sanitize( result.printableUri ); + let clickUri = DOMPurify.sanitize( result.clickUri ); + let title = DOMPurify.sanitize( result.title ); if ( result.raw.hostname && result.raw.displaynavlabel ) { const splittedNavLabel = ( Array.isArray( result.raw.displaynavlabel ) ? result.raw.displaynavlabel[0] : result.raw.displaynavlabel).split( '>' ); - breadcrumb = '
    1. ' + result.raw.hostname + ' 
    2. ' + splittedNavLabel[splittedNavLabel.length-1] + '
    '; + breadcrumb = '
    1. ' + DOMPurify.sanitize( result.raw.hostname ) + + ' 
    2. ' + DOMPurify.sanitize( splittedNavLabel[splittedNavLabel.length-1] ) + '
    '; } else { - breadcrumb = '

    ' + result.printableUri + '

    '; + breadcrumb = '

    ' + printableUri + '

    '; } sectionNode.innerHTML = resultTemplateHTML .replace( '%[index]', index + 1 ) - .replace( 'https://www.canada.ca', filterProtocol( result.clickUri ) ) // workaround, invalid href are stripped - .replace( '%[result.clickUri]', filterProtocol( result.clickUri ) ) - .replace( '%[result.title]', result.title ) + .replace( 'https://www.canada.ca', filterProtocol( clickUri ) ) // workaround, invalid href are stripped + .replace( '%[result.clickUri]', filterProtocol( clickUri ) ) + .replace( '%[result.title]', title ) .replace( '%[result.raw.author]', author ) .replace( '%[result.breadcrumb]', breadcrumb ) - .replace( '%[result.printableUri]', result.printableUri.replaceAll( '&' , '&' ) ) + .replace( '%[result.printableUri]', printableUri.replaceAll( '&' , '&' ) ) .replace( '%[short-date-en]', getShortDateFormat( resultDate ) ) .replace( '%[short-date-fr]', getShortDateFormat( resultDate ) ) .replace( '%[long-date-en]', getLongDateFormat( resultDate, 'en' ) ) @@ -1067,7 +1075,9 @@ function updateDidYouMeanState( newState ) { if ( resultListState.firstSearchExecuted ) { didYouMeanElement.textContent = ""; if ( didYouMeanState.hasQueryCorrection ) { - didYouMeanElement.innerHTML = didYouMeanTemplateHTML.replace( '%[correctedQuery]', didYouMeanState.queryCorrection.correctedQuery ); + didYouMeanElement.innerHTML = didYouMeanTemplateHTML.replace( + '%[correctedQuery]', + DOMPurify.sanitize( didYouMeanState.queryCorrection.correctedQuery ) ); const buttonNode = didYouMeanElement.querySelector( 'button' ); buttonNode.onclick = ( e ) => { updateSearchBoxFromState = true; @@ -1101,7 +1111,7 @@ function updatePagerState( newState ) { const liNode = document.createElement( "li" ); const pageNo = page; - liNode.innerHTML = pageTemplateHTML.replaceAll( '%[page]', pageNo ); + liNode.innerHTML = pageTemplateHTML.replaceAll( '%[page]', DOMPurify.sanitize( pageNo ) ); if ( pagerState.currentPage - 1 > page || page > pagerState.currentPage + 1 ) { liNode.classList.add( 'hidden-xs', 'hidden-sm' ); From 3de72e65c9edbac0cda81fa2ca0b6d538efcafbd Mon Sep 17 00:00:00 2001 From: bblaisATcoveo Date: Fri, 28 Feb 2025 08:58:52 -0500 Subject: [PATCH 12/13] Fix issue when clicking outside QS while not using the OTB search box id --- src/connector.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/connector.js b/src/connector.js index 3e0d483..9a05c68 100644 --- a/src/connector.js +++ b/src/connector.js @@ -372,9 +372,9 @@ function initTpl() { searchBoxElement.setAttribute( 'aria-controls', 'suggestions' ); } - // Remove Query suggestion if click elsewhere + // Close query suggestion box if click elsewhere document.addEventListener( "click", function( evnt ) { - if ( suggestionsElement && ( evnt.target.className !== "suggestion-item" && evnt.target.id !== "sch-inp-ac" ) ) { + if ( suggestionsElement && ( evnt.target.className !== "suggestion-item" && evnt.target.id !== searchBoxElement?.id ) ) { closeSuggestionsBox(); } } ); From 702f511f4dc4741b7b09d9a30a8f41329cd6e479 Mon Sep 17 00:00:00 2001 From: bblaisATcoveo Date: Fri, 28 Feb 2025 11:49:09 -0500 Subject: [PATCH 13/13] Change DOMPurify to stripHtml function --- src/connector.js | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/connector.js b/src/connector.js index 9a05c68..096435e 100644 --- a/src/connector.js +++ b/src/connector.js @@ -133,12 +133,12 @@ function initSearchUI() { // 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 ] ) ] = DOMPurify.sanitize( decode( match[ 2 ] ) ); + urlParams[ decode(match[ 1 ] ) ] = stripHtml( decode( match[ 2 ] ) ); } query = window.location.hash.substring( 1 ); while ( match = search.exec( query ) ) { // eslint-disable-line no-cond-assign - hashParams[ decode( match[ 1 ] ) ] = DOMPurify.sanitize( decode( match[ 2 ] ) ); + hashParams[ decode( match[ 1 ] ) ] = stripHtml( decode( match[ 2 ] ) ); } // jshint +W084 }; @@ -614,10 +614,10 @@ function initEngine() { } if ( hashParams.q && searchBoxElement ) { - searchBoxElement.value = DOMPurify.sanitize( hashParams.q ); + searchBoxElement.value = stripHtml( hashParams.q ); } else if ( urlParams.q && searchBoxElement ) { - searchBoxElement.value = DOMPurify.sanitize( urlParams.q ); + searchBoxElement.value = stripHtml( urlParams.q ); } // Get the query portion of the URL @@ -728,7 +728,7 @@ function initEngine() { // Any other key if ( searchBoxController.state.value !== e.target.value ) { - searchBoxController.updateText( DOMPurify.sanitize( e.target.value ) ); + searchBoxController.updateText( stripHtml( e.target.value ) ); } if ( e.target.value.length < params.minimumCharsForSuggestions ){ closeSuggestionsBox(); @@ -754,7 +754,7 @@ function initEngine() { if ( searchBoxElement && searchBoxElement.value ) { // Make sure we have the latest value in the search box state if( searchBoxController.state.value !== searchBoxElement.value ) { - searchBoxController.updateText( DOMPurify.sanitize( searchBoxElement.value ) ); + searchBoxController.updateText( stripHtml( searchBoxElement.value ) ); } searchBoxController.submit(); } @@ -820,7 +820,7 @@ function updateSearchBoxState( newState ) { searchBoxState = newState; if ( updateSearchBoxFromState && searchBoxElement && searchBoxElement.value !== newState.value ) { - searchBoxElement.value = DOMPurify.sanitize( newState.value ); + searchBoxElement.value = stripHtml( newState.value ); updateSearchBoxFromState = false; return; } @@ -859,7 +859,7 @@ function updateSearchBoxState( newState ) { }; node.onclick = ( e ) => { searchBoxController.selectSuggestion( e.currentTarget.innerText ); - searchBoxElement.value = DOMPurify.sanitize( e.currentTarget.innerText ); + searchBoxElement.value = stripHtml( e.currentTarget.innerText ); }; node.innerHTML = DOMPurify.sanitize( suggestion.highlightedValue ); suggestionsElement.appendChild( node ); @@ -897,7 +897,7 @@ function buildCleanQueryString( paramsObject ) { urlParam += "&"; } - urlParam += prop + "=" + DOMPurify.sanitize( paramsObject[ prop ].replaceAll( '+', ' ' ) ); + urlParam += prop + "=" + stripHtml( paramsObject[ prop ].replaceAll( '+', ' ' ) ); } } @@ -913,6 +913,13 @@ function filterProtocol( 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(); @@ -963,23 +970,23 @@ function updateResultListState( newState ) { if( result.raw.author ) { if( Array.isArray( result.raw.author ) ) { - author = DOMPurify.sanitize( result.raw.author.join( ';' ) ); + author = stripHtml( result.raw.author.join( ';' ) ); } else { - author = DOMPurify.sanitize( result.raw.author ); + author = stripHtml( result.raw.author ); } author = author.replaceAll( ';' , '
  • ' ); } let breadcrumb = ""; - let printableUri = DOMPurify.sanitize( result.printableUri ); - let clickUri = DOMPurify.sanitize( result.clickUri ); - let title = DOMPurify.sanitize( result.title ); + let printableUri = stripHtml( result.printableUri ); + let clickUri = stripHtml( result.clickUri ); + let title = stripHtml( result.title ); if ( result.raw.hostname && result.raw.displaynavlabel ) { const splittedNavLabel = ( Array.isArray( result.raw.displaynavlabel ) ? result.raw.displaynavlabel[0] : result.raw.displaynavlabel).split( '>' ); - breadcrumb = '
    1. ' + DOMPurify.sanitize( result.raw.hostname ) + - ' 
    2. ' + DOMPurify.sanitize( splittedNavLabel[splittedNavLabel.length-1] ) + '
    '; + breadcrumb = '
    1. ' + stripHtml( result.raw.hostname ) + + ' 
    2. ' + stripHtml( splittedNavLabel[splittedNavLabel.length-1] ) + '
    '; } else { breadcrumb = '

    ' + printableUri + '

    '; @@ -1077,7 +1084,7 @@ function updateDidYouMeanState( newState ) { if ( didYouMeanState.hasQueryCorrection ) { didYouMeanElement.innerHTML = didYouMeanTemplateHTML.replace( '%[correctedQuery]', - DOMPurify.sanitize( didYouMeanState.queryCorrection.correctedQuery ) ); + stripHtml( didYouMeanState.queryCorrection.correctedQuery ) ); const buttonNode = didYouMeanElement.querySelector( 'button' ); buttonNode.onclick = ( e ) => { updateSearchBoxFromState = true; @@ -1111,7 +1118,7 @@ function updatePagerState( newState ) { const liNode = document.createElement( "li" ); const pageNo = page; - liNode.innerHTML = pageTemplateHTML.replaceAll( '%[page]', DOMPurify.sanitize( pageNo ) ); + liNode.innerHTML = pageTemplateHTML.replaceAll( '%[page]', stripHtml( pageNo ) ); if ( pagerState.currentPage - 1 > page || page > pagerState.currentPage + 1 ) { liNode.classList.add( 'hidden-xs', 'hidden-sm' );