From f2363fa2beeb8abc444acd2fa8b210a970916c80 Mon Sep 17 00:00:00 2001 From: mmitiche <86681870+mmitiche@users.noreply.github.com> Date: Mon, 22 Jul 2024 13:04:40 -0400 Subject: [PATCH 1/5] feat(quantic): quantic search box improvements (#4120) ## [SFINT-5576](https://coveord.atlassian.net/browse/SFINT-5576) In this PR: - Search box recent queries feature added to the Quantic Search Box. - Several bugs fixed with the Quantic Search Box. - A11y of the Quantic Search Box improved. - Cleaned the Quantic Search Box component and all of its internal components for a more simplicity. Quantic Search Box autopsy: https://coveord.atlassian.net/wiki/spaces/~60c8f25b2bd2140069e4cdbc/pages/4208459786/Quantic+Search+Box+Autopsy [SFINT-5576]: https://coveord.atlassian.net/browse/SFINT-5576?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Sylvie Allain <58052881+sallainCoveo@users.noreply.github.com> Co-authored-by: Simon Milord --- .../cypress/e2e/default-1/examples/actions.ts | 2 +- .../search-box/search-box-actions.ts | 36 ++ .../search-box/search-box-expectations.ts | 104 +++ .../search-box/search-box-selectors.ts | 48 ++ .../search-box/search-box.cypress.ts | 607 ++++++++++++++++++ .../standalone-search-box-actions.ts | 4 +- .../standalone-search-box.cypress.ts | 24 +- .../quantic/cypress/page-objects/search.ts | 19 + .../exampleQuanticSearchBox.html | 25 + .../exampleQuanticSearchBox.js | 64 ++ .../exampleQuanticSearchBox.js-meta.xml | 12 + .../labels/CustomLabels.labels-meta.xml | 42 ++ .../lwc/quanticSearchBox/quanticSearchBox.js | 80 ++- .../quanticSearchBox/templates/searchBox.html | 3 + .../__tests__/quanticSearchBoxInput.test.js | 332 +++++++++- .../quanticSearchBoxInput.js | 169 ++--- .../templates/defaultSearchBoxInput.html | 26 +- .../templates/expandableSearchBoxInput.html | 24 +- .../quanticSearchBoxSuggestionsList.css | 15 + .../quanticSearchBoxSuggestionsList.html | 54 +- .../quanticSearchBoxSuggestionsList.js | 252 ++++++-- .../quanticStandaloneSearchBox.js | 27 +- .../templates/standaloneSearchBox.html | 1 + .../default/lwc/quanticUtils/quanticUtils.js | 7 + .../lwc/quanticUtils/recentQueriesUtils.js | 22 + .../main/translations/fr.translation-meta.xml | 24 + .../routes/quanticSearchBox.json | 11 + .../Quantic_Examples1/views/home.json | 2 +- .../views/quanticSearchBox.json | 81 +++ 29 files changed, 1870 insertions(+), 247 deletions(-) create mode 100644 packages/quantic/cypress/e2e/default-2/search-box/search-box-actions.ts create mode 100644 packages/quantic/cypress/e2e/default-2/search-box/search-box-expectations.ts create mode 100644 packages/quantic/cypress/e2e/default-2/search-box/search-box-selectors.ts create mode 100644 packages/quantic/cypress/e2e/default-2/search-box/search-box.cypress.ts create mode 100644 packages/quantic/force-app/examples/main/lwc/exampleQuanticSearchBox/exampleQuanticSearchBox.html create mode 100644 packages/quantic/force-app/examples/main/lwc/exampleQuanticSearchBox/exampleQuanticSearchBox.js create mode 100644 packages/quantic/force-app/examples/main/lwc/exampleQuanticSearchBox/exampleQuanticSearchBox.js-meta.xml create mode 100644 packages/quantic/force-app/main/default/lwc/quanticUtils/recentQueriesUtils.js create mode 100644 packages/quantic/quantic-examples-community/experiences/Quantic_Examples1/routes/quanticSearchBox.json create mode 100644 packages/quantic/quantic-examples-community/experiences/Quantic_Examples1/views/quanticSearchBox.json diff --git a/packages/quantic/cypress/e2e/default-1/examples/actions.ts b/packages/quantic/cypress/e2e/default-1/examples/actions.ts index e09883bb77b..73344e1f676 100644 --- a/packages/quantic/cypress/e2e/default-1/examples/actions.ts +++ b/packages/quantic/cypress/e2e/default-1/examples/actions.ts @@ -6,7 +6,7 @@ export const insightInterfaceComponent = 'c-quantic-insight-interface'; function actions(selector: Selector) { return { typeInSearchbox: (text: string) => selector.searchbox().type(text), - submitQuery: () => selector.searchbox().trigger('keyup', {key: 'Enter'}), + submitQuery: () => selector.searchbox().trigger('keydown', {key: 'Enter'}), selectFacetValue: (value: string) => selector.facetValue(value).check({force: true}), selectPagerButton: (index: number) => selector.pagerButton(index).click(), diff --git a/packages/quantic/cypress/e2e/default-2/search-box/search-box-actions.ts b/packages/quantic/cypress/e2e/default-2/search-box/search-box-actions.ts new file mode 100644 index 00000000000..fb5628e4a6b --- /dev/null +++ b/packages/quantic/cypress/e2e/default-2/search-box/search-box-actions.ts @@ -0,0 +1,36 @@ +import {SearchBoxSelector, SearchBoxSelectors} from './search-box-selectors'; + +const standaloneSearchBoxActions = (selector: SearchBoxSelector) => { + return { + typeInSearchBox: (query: string, textarea = false) => { + selector + .input(textarea) + .type(query) + .logAction(`when typing "${query}" in search box`); + }, + pressDownArrowOnSearchBox: (textarea = false) => { + selector.input(textarea).type('{downarrow}'); + }, + pressEnterOnSearchBox: (textarea = false) => { + selector.input(textarea).type('{enter}'); + }, + clickQuerySuggestion: (index: number) => { + selector.querySuggestionByIndex(index).click(); + }, + clickClearRecentQueriesButton: () => { + selector.clearRecentQueriesButton().click(); + }, + focusSearchBox: (textarea = false) => { + selector.input(textarea).then((searchbox) => { + cy.wrap(searchbox).focus(); + }); + }, + blurSearchBox: (textarea = false) => { + selector.input(textarea).blur({force: true}); + }, + }; +}; + +export const SearchBoxActions = { + ...standaloneSearchBoxActions(SearchBoxSelectors), +}; diff --git a/packages/quantic/cypress/e2e/default-2/search-box/search-box-expectations.ts b/packages/quantic/cypress/e2e/default-2/search-box/search-box-expectations.ts new file mode 100644 index 00000000000..9e1b89af468 --- /dev/null +++ b/packages/quantic/cypress/e2e/default-2/search-box/search-box-expectations.ts @@ -0,0 +1,104 @@ +import {InterceptAliases} from '../../../page-objects/search'; +import {should} from '../../common-selectors'; +import {AriaLiveExpectations} from '../../default-1/aria-live/aria-live-expectations'; +import {SearchBoxSelectors, SearchBoxSelector} from './search-box-selectors'; + +function searchBoxExpectations(selector: SearchBoxSelector) { + return { + displayClearRecentQueriesButton: (display: boolean) => { + selector + .clearRecentQueriesButton() + .should(display ? 'exist' : 'not.exist') + .logDetail( + `${should(display)} display the 'clear recent queries' button` + ); + }, + querySuggestionsEquals: (querySuggestions: String[]) => { + querySuggestions.forEach((querySuggestion, index) => { + selector + .querySuggestionContentByIndex(index) + .then(([suggestion]) => { + expect(suggestion.innerText).to.eq(querySuggestion); + }) + .logDetail( + 'should display the query suggestions properly and in the correct order' + ); + }); + }, + searchWithQuery: ( + expectedQuery: string, + expectedRecentQueriesInLocalStorage?: {LSkey: string; queries: string[]} + ) => { + cy.wait(InterceptAliases.Search) + .then((interception) => { + const requestBody = interception.request.body; + expect(requestBody).to.have.property('q', expectedQuery); + if (expectedRecentQueriesInLocalStorage) { + const {LSkey, queries} = expectedRecentQueriesInLocalStorage; + const recentQueries = window.localStorage.getItem(LSkey); + expect(recentQueries).to.equal(JSON.stringify(queries)); + } + }) + .logDetail( + `should make a search request with the query ${expectedQuery} ${expectedRecentQueriesInLocalStorage ? 'and should add it the local storage' : ''}` + ); + }, + displaySearchBoxInput: (display: boolean, textarea = false) => { + selector + .input(textarea) + .should(display ? 'exist' : 'not.exist') + .logDetail(`${should(display)} display the input search box`); + }, + displaySearchButton: (display: boolean) => { + selector + .searchButton() + .should(display ? 'exist' : 'not.exist') + .logDetail(`${should(display)} display the search button`); + }, + displaySuggestionList: (display: boolean) => { + selector + .suggestionList() + .should(display ? 'exist' : 'not.exist') + .logDetail(`${should(display)} display the query suggestions list`); + }, + numberOfQuerySuggestions: (value: number) => { + selector + .querySuggestions() + .should('have.length', value) + .logDetail(`should display ${value} query suggestions`); + }, + logClearRecentQueries: () => { + cy.wait(InterceptAliases.UA.RecentQueries.ClearRecentQueries) + .then((interception) => { + const analyticsBody = interception.request.body; + expect(analyticsBody).to.have.property('eventType', 'recentQueries'); + }) + .logDetail("should log the 'clearRecentQueries' UA event"); + }, + logClickRecentQueries: (queryText: string) => { + cy.wait(InterceptAliases.UA.RecentQueries.ClickRecentQueries) + .then((interception) => { + const analyticsBody = interception.request.body; + expect(analyticsBody).to.have.property('queryText', queryText); + }) + .logDetail( + `should log the 'recentQueriesClick' UA event with the correct value: ${queryText}` + ); + }, + logClickSuggestion: (queryText: string) => { + cy.wait(InterceptAliases.UA.OmniboxAnalytics) + .then((interception) => { + const analyticsBody = interception.request.body; + expect(analyticsBody).to.have.property('queryText', queryText); + }) + .logDetail("should log the 'omniboxAnalytics' UA event"); + }, + }; +} + +export const SearchBoxExpectations = { + ...searchBoxExpectations(SearchBoxSelectors), + ariaLive: { + ...AriaLiveExpectations, + }, +}; diff --git a/packages/quantic/cypress/e2e/default-2/search-box/search-box-selectors.ts b/packages/quantic/cypress/e2e/default-2/search-box/search-box-selectors.ts new file mode 100644 index 00000000000..9491e1c2c3e --- /dev/null +++ b/packages/quantic/cypress/e2e/default-2/search-box/search-box-selectors.ts @@ -0,0 +1,48 @@ +import {ComponentSelector, CypressSelector} from '../../common-selectors'; + +export const standaloneSearchBoxComponent = 'c-quantic-search-box'; + +export interface SearchBoxSelector extends ComponentSelector { + input: (textarea?: boolean) => CypressSelector; + quanticSearchBoxInput: () => CypressSelector; + suggestionList: () => CypressSelector; + searchButton: () => CypressSelector; + clearRecentQueriesButton: () => CypressSelector; + querySuggestions: () => CypressSelector; + querySuggestionByIndex: (index: number) => CypressSelector; + querySuggestionContentByIndex: (index: number) => CypressSelector; +} + +export const SearchBoxSelectors: SearchBoxSelector = { + get: () => cy.get(standaloneSearchBoxComponent), + quanticSearchBoxInput: () => + SearchBoxSelectors.get().find('[data-cy="quantic-search-box-input"]'), + input: (textarea = false) => + SearchBoxSelectors.get().find( + `c-quantic-search-box-input [data-cy="${textarea ? 'search-box-textarea' : 'search-box-input'}"]` + ), + suggestionList: () => + SearchBoxSelectors.get().find( + 'c-quantic-search-box-suggestions-list [data-cy="suggestion-list"]' + ), + searchButton: () => + SearchBoxSelectors.get().find( + 'c-quantic-search-box-input [data-cy="search-box-submit-button"]' + ), + clearRecentQueriesButton: () => + SearchBoxSelectors.get().find( + 'c-quantic-search-box-input [data-cy="clear-recent-queries"]' + ), + querySuggestions: () => + SearchBoxSelectors.get().find( + 'c-quantic-search-box-input [data-cy="suggestions-option"]' + ), + querySuggestionByIndex: (index: number) => + SearchBoxSelectors.querySuggestions().eq(index), + querySuggestionContentByIndex: (index: number) => + SearchBoxSelectors.get() + .find( + 'c-quantic-search-box-input [data-cy="suggestions-option"] lightning-formatted-rich-text' + ) + .eq(index), +}; diff --git a/packages/quantic/cypress/e2e/default-2/search-box/search-box.cypress.ts b/packages/quantic/cypress/e2e/default-2/search-box/search-box.cypress.ts new file mode 100644 index 00000000000..ffaa9471f1d --- /dev/null +++ b/packages/quantic/cypress/e2e/default-2/search-box/search-box.cypress.ts @@ -0,0 +1,607 @@ +import {configure} from '../../../page-objects/configurator'; +import { + InterceptAliases, + interceptSearch, + mockQuerySuggestions, +} from '../../../page-objects/search'; +import {scope} from '../../../reporters/detailed-collector'; +import {SearchBoxActions as Actions} from './search-box-actions'; +import {SearchBoxExpectations as Expect} from './search-box-expectations'; + +const engineId = 'quantic-search-box-engine'; +const recentQueriesLSKey = `LSKey[c]${engineId}_quantic-recent-queries`; + +interface StandaloneSearchBoxOptions { + engineId: string; + placeholder: string; + withoutSubmitButton: boolean; + numberOfSuggestions: number; + textarea: boolean; + disableRecentQueries: boolean; +} + +const expectedAriaLiveMessage = (suggestionsCount: number) => { + return `${suggestionsCount} search suggestions found, to navigate use up and down arrows.`; +}; + +function setRecentQueriesInLocalStorage(recentQueries: String[]) { + window.localStorage.setItem( + recentQueriesLSKey, + JSON.stringify(recentQueries) + ); +} + +const defaultOptions: Partial = { + engineId, +}; + +describe('quantic-search-box', () => { + const pageUrl = 's/quantic-search-box'; + + function visitSearchBox( + options: Partial = defaultOptions + ) { + interceptSearch(); + cy.visit(pageUrl); + configure(options); + cy.wait(InterceptAliases.Search); + } + + const variants = [ + {variantName: 'default', textarea: false}, + {variantName: 'expandable', textarea: true}, + ]; + + variants.forEach(({variantName, textarea}) => { + describe(`variant ${variantName} with default options`, () => { + describe('recent query suggestions', () => { + const exampleQuerySuggestions = ['ABC', 'EFG']; + + describe('when no recent query is found at initial state', () => { + beforeEach(() => { + setRecentQueriesInLocalStorage([]); + visitSearchBox({...defaultOptions, textarea}); + mockQuerySuggestions(exampleQuerySuggestions); + }); + + it('should display the suggestions in the right order and should not display recent query suggestions', () => { + scope('when loading standalone search box', () => { + Expect.displaySearchBoxInput(true, textarea); + Expect.displaySearchButton(true); + }); + + scope('when focusing on the search box input', () => { + Actions.focusSearchBox(textarea); + cy.wait(InterceptAliases.QuerySuggestions); + + Expect.displaySuggestionList(true); + Expect.displayClearRecentQueriesButton(false); + Expect.numberOfQuerySuggestions(exampleQuerySuggestions.length); + Expect.querySuggestionsEquals(exampleQuerySuggestions); + }); + + scope('when selecting a suggestions', () => { + const clickedSuggestionIndex = 0; + Actions.clickQuerySuggestion(clickedSuggestionIndex); + + Expect.logClickSuggestion( + exampleQuerySuggestions[clickedSuggestionIndex] + ); + Expect.searchWithQuery( + exampleQuerySuggestions[clickedSuggestionIndex], + { + LSkey: recentQueriesLSKey, + queries: [exampleQuerySuggestions[clickedSuggestionIndex]], + } + ); + }); + }); + }); + + describe('when recent queries are found at initial state', () => { + const exampleRecentQueries = ['foo', 'bar']; + + beforeEach(() => { + setRecentQueriesInLocalStorage(exampleRecentQueries); + visitSearchBox({...defaultOptions, textarea}); + mockQuerySuggestions(exampleQuerySuggestions); + }); + + it('should display the suggestions and the recent query suggestions in the right order', () => { + const expectedQuerySuggestions = [ + ...exampleRecentQueries, + ...exampleQuerySuggestions, + ]; + scope('when loading standalone search box', () => { + Expect.displaySearchBoxInput(true, textarea); + Expect.displaySearchButton(true); + }); + + scope('when focusing on the search box input', () => { + Actions.focusSearchBox(textarea); + cy.wait(InterceptAliases.QuerySuggestions); + + Expect.displaySuggestionList(true); + Expect.displayClearRecentQueriesButton(true); + Expect.numberOfQuerySuggestions(expectedQuerySuggestions.length); + Expect.querySuggestionsEquals(expectedQuerySuggestions); + }); + + scope('when selecting a recent query', () => { + const clickedSuggestionIndex = 0; + Actions.clickQuerySuggestion(clickedSuggestionIndex); + + Expect.logClickRecentQueries( + exampleRecentQueries[clickedSuggestionIndex] + ); + Expect.searchWithQuery( + exampleRecentQueries[clickedSuggestionIndex], + { + LSkey: recentQueriesLSKey, + queries: exampleRecentQueries, + } + ); + }); + }); + }); + + describe('when no query suggestions are returned and only recent queries are present', () => { + const exampleRecentQueries = ['foo', 'bar']; + + beforeEach(() => { + setRecentQueriesInLocalStorage(exampleRecentQueries); + visitSearchBox({...defaultOptions, textarea}); + mockQuerySuggestions([]); + }); + + it('should display the recent query suggestions', () => { + scope('when loading standalone search box', () => { + Expect.displaySearchBoxInput(true, textarea); + Expect.displaySearchButton(true); + }); + + scope('when focusing on the search box input', () => { + Actions.focusSearchBox(textarea); + cy.wait(InterceptAliases.QuerySuggestions); + + Expect.displaySuggestionList(true); + Expect.displayClearRecentQueriesButton(true); + Expect.numberOfQuerySuggestions(exampleRecentQueries.length); + Expect.querySuggestionsEquals(exampleRecentQueries); + }); + }); + }); + + describe('when the same suggestion is returned as a query suggestion and as a recent query suggestion', () => { + const exampleRecentQueries = ['ABC']; + const exampleQuerySuggestions = ['ABC']; + + beforeEach(() => { + setRecentQueriesInLocalStorage(exampleRecentQueries); + visitSearchBox({...defaultOptions, textarea}); + mockQuerySuggestions(exampleQuerySuggestions); + }); + + it('should not display the same suggestion twice', () => { + scope('when loading standalone search box', () => { + Expect.displaySearchBoxInput(true, textarea); + Expect.displaySearchButton(true); + }); + + scope('when focusing on the search box input', () => { + Actions.focusSearchBox(textarea); + cy.wait(InterceptAliases.QuerySuggestions); + + Expect.displaySuggestionList(true); + Expect.displayClearRecentQueriesButton(true); + Expect.numberOfQuerySuggestions(exampleRecentQueries.length); + Expect.querySuggestionsEquals(exampleRecentQueries); + }); + }); + }); + + describe('when a value is written in the search box input', () => { + const exampleQuery = 'f'; + const allRecentQuerySuggestions = [ + 'foo', + 'bar', + 'fill', + 'Fun', + 'baz', + ]; + const expectedRecentQuerySuggestions = ['foo', 'fill', 'Fun']; + const expectedDisplayedSuggestions = [ + ...expectedRecentQuerySuggestions, + ...exampleQuerySuggestions, + ]; + + beforeEach(() => { + setRecentQueriesInLocalStorage(allRecentQuerySuggestions); + visitSearchBox({...defaultOptions, textarea}); + mockQuerySuggestions(exampleQuerySuggestions); + }); + + it('should only display the recent query suggestions that starts with the value written in the search box input', () => { + scope('when loading standalone search box', () => { + Expect.displaySearchBoxInput(true, textarea); + Expect.displaySearchButton(true); + }); + + scope('when writing in the search box input', () => { + Expect.displaySearchBoxInput(true, textarea); + Expect.displaySearchButton(true); + + Actions.typeInSearchBox(exampleQuery, textarea); + }); + + scope('when focusing on the search box input', () => { + Actions.focusSearchBox(textarea); + cy.wait(InterceptAliases.QuerySuggestions); + + Expect.displaySuggestionList(true); + Expect.displayClearRecentQueriesButton(true); + Expect.numberOfQuerySuggestions( + expectedDisplayedSuggestions.length + ); + Expect.querySuggestionsEquals(expectedDisplayedSuggestions); + }); + }); + }); + + describe('when clearing the recent queries', () => { + const exampleRecentQueries = ['foo', 'bar']; + + beforeEach(() => { + setRecentQueriesInLocalStorage(exampleRecentQueries); + visitSearchBox({...defaultOptions, textarea}); + mockQuerySuggestions(exampleQuerySuggestions); + }); + + it('should clear the recent queries and send the right analytics', () => { + const expectedQuerySuggestions = [ + ...exampleRecentQueries, + ...exampleQuerySuggestions, + ]; + scope('when loading standalone search box', () => { + Expect.displaySearchBoxInput(true, textarea); + Expect.displaySearchButton(true); + }); + + scope('when focusing on the search box input', () => { + Actions.focusSearchBox(textarea); + cy.wait(InterceptAliases.QuerySuggestions); + + Expect.displaySuggestionList(true); + Expect.displayClearRecentQueriesButton(true); + Expect.numberOfQuerySuggestions(expectedQuerySuggestions.length); + Expect.querySuggestionsEquals(expectedQuerySuggestions); + }); + + scope('when clicking on the clear recent queries button', () => { + Actions.clickClearRecentQueriesButton(); + + Expect.logClearRecentQueries(); + Expect.displayClearRecentQueriesButton(false); + Expect.displaySuggestionList(false); + }); + }); + }); + + describe('when using the keyboard', () => { + const exampleRecentQueries = ['foo', 'bar']; + + beforeEach(() => { + setRecentQueriesInLocalStorage(exampleRecentQueries); + visitSearchBox({...defaultOptions, textarea}); + mockQuerySuggestions(exampleQuerySuggestions); + }); + + it('should properly navigate and select the right recent query', () => { + const expectedQuerySuggestions = [ + ...exampleRecentQueries, + ...exampleQuerySuggestions, + ]; + scope('when loading standalone search box', () => { + Expect.displaySearchBoxInput(true, textarea); + Expect.displaySearchButton(true); + }); + + scope('when focusing on the search box input', () => { + Actions.focusSearchBox(textarea); + cy.wait(InterceptAliases.QuerySuggestions); + + Expect.displaySuggestionList(true); + Expect.displayClearRecentQueriesButton(true); + Expect.numberOfQuerySuggestions(expectedQuerySuggestions.length); + Expect.querySuggestionsEquals(expectedQuerySuggestions); + }); + + scope('when selecting a recent query with the keyboard', () => { + // First arrow down press to go to the clear recent queries option. + Actions.pressDownArrowOnSearchBox(textarea); + // Second arrow down press to go to the first recent queries option. + Actions.pressDownArrowOnSearchBox(textarea); + Actions.pressEnterOnSearchBox(textarea); + const clickedSuggestionIndex = 0; + Expect.logClickRecentQueries( + exampleRecentQueries[clickedSuggestionIndex] + ); + Expect.searchWithQuery( + exampleRecentQueries[clickedSuggestionIndex], + { + LSkey: recentQueriesLSKey, + queries: exampleRecentQueries, + } + ); + }); + }); + + it('should properly navigate and select the right suggestion', () => { + const expectedQuerySuggestions = [ + ...exampleRecentQueries, + ...exampleQuerySuggestions, + ]; + scope('when loading standalone search box', () => { + Expect.displaySearchBoxInput(true, textarea); + Expect.displaySearchButton(true); + }); + + scope('when focusing on the search box input', () => { + Actions.focusSearchBox(textarea); + cy.wait(InterceptAliases.QuerySuggestions); + + Expect.displaySuggestionList(true); + Expect.displayClearRecentQueriesButton(true); + Expect.numberOfQuerySuggestions(expectedQuerySuggestions.length); + Expect.querySuggestionsEquals(expectedQuerySuggestions); + }); + + scope('when selecting a recent query with the keyboard', () => { + // First arrow down press to go to the clear recent queries option. + Actions.pressDownArrowOnSearchBox(textarea); + // pressing the arrow down button to surpass every recent query option. + for (let i = 0; i < exampleRecentQueries.length + 1; i++) { + Actions.pressDownArrowOnSearchBox(textarea); + } + + Actions.pressEnterOnSearchBox(textarea); + const clickedSuggestionIndex = 0; + Expect.logClickSuggestion( + exampleQuerySuggestions[clickedSuggestionIndex] + ); + Expect.searchWithQuery( + exampleQuerySuggestions[clickedSuggestionIndex], + { + LSkey: recentQueriesLSKey, + queries: [ + exampleQuerySuggestions[clickedSuggestionIndex], + ...exampleRecentQueries, + ], + } + ); + }); + }); + }); + + describe('when a custom value is set for the property #numberOfSuggestions', () => { + const exampleRecentQueries = ['foo', 'bar']; + const customNumberOfSuggestions = 3; + + beforeEach(() => { + setRecentQueriesInLocalStorage(exampleRecentQueries); + visitSearchBox({ + ...defaultOptions, + textarea, + numberOfSuggestions: customNumberOfSuggestions, + }); + mockQuerySuggestions(exampleQuerySuggestions); + }); + + it('should limit the number of suggestions to display to respect the number of suggestions property', () => { + const expectedQuerySuggestions = [ + ...exampleRecentQueries, + ...exampleQuerySuggestions, + ].slice(0, customNumberOfSuggestions); + scope('when loading standalone search box', () => { + Expect.displaySearchBoxInput(true, textarea); + Expect.displaySearchButton(true); + }); + + scope('when focusing on the search box input', () => { + Actions.focusSearchBox(textarea); + cy.wait(InterceptAliases.QuerySuggestions); + + Expect.displaySuggestionList(true); + Expect.displayClearRecentQueriesButton(true); + Expect.numberOfQuerySuggestions(customNumberOfSuggestions); + Expect.querySuggestionsEquals(expectedQuerySuggestions); + }); + }); + }); + + describe('when a the property #disableRecentQueries is set to true', () => { + const exampleRecentQueries = ['foo', 'bar']; + + describe('when the local storage contains recent query suggestions', () => { + beforeEach(() => { + setRecentQueriesInLocalStorage(exampleRecentQueries); + visitSearchBox({ + ...defaultOptions, + textarea, + disableRecentQueries: true, + }); + mockQuerySuggestions(exampleQuerySuggestions); + }); + + it('should not display the recent query suggestions', () => { + const expectedQuerySuggestions = exampleQuerySuggestions; + + scope('when loading standalone search box', () => { + Expect.displaySearchBoxInput(true, textarea); + Expect.displaySearchButton(true); + }); + + scope('when focusing on the search box input', () => { + Actions.focusSearchBox(textarea); + cy.wait(InterceptAliases.QuerySuggestions); + + Expect.displaySuggestionList(true); + Expect.displayClearRecentQueriesButton(false); + Expect.numberOfQuerySuggestions( + expectedQuerySuggestions.length + ); + Expect.querySuggestionsEquals(expectedQuerySuggestions); + }); + }); + }); + + describe('when the local storage does not contain recent query suggestions', () => { + beforeEach(() => { + setRecentQueriesInLocalStorage([]); + visitSearchBox({ + ...defaultOptions, + textarea, + disableRecentQueries: true, + }); + mockQuerySuggestions(exampleQuerySuggestions); + }); + + it('should not add the query to the local storage after executing a new search', () => { + const expectedQuerySuggestions = exampleQuerySuggestions; + + scope('when loading standalone search box', () => { + Expect.displaySearchBoxInput(true, textarea); + Expect.displaySearchButton(true); + }); + + scope('when focusing on the search box input', () => { + Actions.focusSearchBox(textarea); + cy.wait(InterceptAliases.QuerySuggestions); + + Expect.displaySuggestionList(true); + Expect.displayClearRecentQueriesButton(false); + Expect.numberOfQuerySuggestions( + expectedQuerySuggestions.length + ); + Expect.querySuggestionsEquals(expectedQuerySuggestions); + }); + + scope('when selecting a suggestions', () => { + const clickedSuggestionIndex = 0; + Actions.clickQuerySuggestion(clickedSuggestionIndex); + + Expect.searchWithQuery( + exampleQuerySuggestions[clickedSuggestionIndex], + { + LSkey: recentQueriesLSKey, + queries: [], + } + ); + }); + }); + }); + }); + }); + + describe('accessibility', () => { + const exampleQuerySuggestions = ['ABC', 'EFG']; + + describe('aria live', () => { + describe('when only query suggestions are displayed', () => { + beforeEach(() => { + setRecentQueriesInLocalStorage([]); + visitSearchBox({...defaultOptions, textarea}); + mockQuerySuggestions(exampleQuerySuggestions); + }); + + it('should have the aria live message the properly the number of query suggestions available', () => { + scope('when loading standalone search box', () => { + Expect.displaySearchBoxInput(true, textarea); + Expect.displaySearchButton(true); + }); + + scope('when focusing on the search box input', () => { + Actions.focusSearchBox(textarea); + cy.wait(InterceptAliases.QuerySuggestions); + + Expect.displaySuggestionList(true); + Expect.ariaLive.displayRegion('suggestions', true); + Expect.ariaLive.regionContains( + 'suggestions', + expectedAriaLiveMessage(exampleQuerySuggestions.length) + ); + }); + + scope('when the suggestions list is closed', () => { + Actions.blurSearchBox(textarea); + }); + + scope( + 'when focusing again on the search box input and getting new suggestions', + () => { + const newSuggestions = [...exampleQuerySuggestions, 'FOO']; + mockQuerySuggestions(newSuggestions); + + Actions.focusSearchBox(textarea); + cy.wait(InterceptAliases.QuerySuggestions); + + Expect.displaySuggestionList(true); + Expect.ariaLive.displayRegion('suggestions', true); + Expect.ariaLive.regionContains( + 'suggestions', + expectedAriaLiveMessage(newSuggestions.length) + ); + } + ); + }); + }); + + describe('when both query suggestions and recent queries are displayed', () => { + const exampleRecentQueries = ['foo', 'bar']; + const expectedDisplayedSuggestions = [ + ...exampleRecentQueries, + ...exampleQuerySuggestions, + ]; + + beforeEach(() => { + setRecentQueriesInLocalStorage(exampleRecentQueries); + visitSearchBox({...defaultOptions, textarea}); + mockQuerySuggestions(exampleQuerySuggestions); + }); + + it('should have the aria live message the properly the number of query suggestions available', () => { + scope('when loading standalone search box', () => { + Expect.displaySearchBoxInput(true, textarea); + Expect.displaySearchButton(true); + }); + + scope('when focusing on the search box input', () => { + Actions.focusSearchBox(textarea); + cy.wait(InterceptAliases.QuerySuggestions); + + Expect.displaySuggestionList(true); + Expect.ariaLive.displayRegion('suggestions', true); + Expect.ariaLive.regionContains( + 'suggestions', + expectedAriaLiveMessage(expectedDisplayedSuggestions.length) + ); + }); + + scope('when focusing on the search box input', () => { + Actions.focusSearchBox(textarea); + cy.wait(InterceptAliases.QuerySuggestions); + + Expect.displaySuggestionList(true); + Expect.ariaLive.displayRegion('suggestions', true); + Expect.ariaLive.regionContains( + 'suggestions', + expectedAriaLiveMessage(expectedDisplayedSuggestions.length) + ); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/packages/quantic/cypress/e2e/default-2/standalone-search-box/standalone-search-box-actions.ts b/packages/quantic/cypress/e2e/default-2/standalone-search-box/standalone-search-box-actions.ts index 9fde8fcfaa0..32c5c81eded 100644 --- a/packages/quantic/cypress/e2e/default-2/standalone-search-box/standalone-search-box-actions.ts +++ b/packages/quantic/cypress/e2e/default-2/standalone-search-box/standalone-search-box-actions.ts @@ -15,7 +15,7 @@ const standaloneSearchBoxActions = (selector: StandaloneSearchBoxSelector) => { updateText += letter; cy.wrap(searchbox) .invoke('val', updateText) - .trigger('keyup', {which: letter.charCodeAt(0)}); + .trigger('input', {which: letter.charCodeAt(0)}); cy.wait(InterceptAliases.QuerySuggestions); }); }) @@ -38,7 +38,7 @@ const standaloneSearchBoxActions = (selector: StandaloneSearchBoxSelector) => { }, pressEnter: (textarea = false) => { selector.input(textarea).then((searchbox) => { - cy.wrap(searchbox).trigger('keyup', {key: 'Enter'}); + cy.wrap(searchbox).trigger('keydown', {key: 'Enter'}); }); }, keyboardTypeInSearchBox: (query: string, textarea = false) => { diff --git a/packages/quantic/cypress/e2e/default-2/standalone-search-box/standalone-search-box.cypress.ts b/packages/quantic/cypress/e2e/default-2/standalone-search-box/standalone-search-box.cypress.ts index ba999a2461f..c6e16d7c5c5 100644 --- a/packages/quantic/cypress/e2e/default-2/standalone-search-box/standalone-search-box.cypress.ts +++ b/packages/quantic/cypress/e2e/default-2/standalone-search-box/standalone-search-box.cypress.ts @@ -65,7 +65,7 @@ describe('quantic-standalone-search-box', () => { Expect.displayInputSearchBox(true, textarea); Expect.displaySearchButton(true); Expect.placeholderContains('Search', textarea); - Expect.inputInitialized(textarea); + Expect.inputInitialized(); }); scope('when setting suggestions', () => { @@ -89,10 +89,10 @@ describe('quantic-standalone-search-box', () => { }); scope('when submitting a search', () => { - const query = 'test'; + const query = 'another query'; visitStandaloneSearchBox({textarea}); - Expect.inputInitialized(textarea); + Expect.inputInitialized(); Actions.typeInSearchBox(query, textarea); Expect.displayClearButton(true); Actions.submitSearch(); @@ -106,7 +106,7 @@ describe('quantic-standalone-search-box', () => { mockSuggestions(); visitStandaloneSearchBox({textarea}); - Expect.inputInitialized(textarea); + Expect.inputInitialized(); Actions.typeInSearchBox(query, textarea); Actions.pressEnter(textarea); Expect.displayClearButton(true); @@ -119,7 +119,7 @@ describe('quantic-standalone-search-box', () => { const query = ''; visitStandaloneSearchBox({textarea}); - Expect.inputInitialized(textarea); + Expect.inputInitialized(); Actions.typeInSearchBox(query, textarea); Expect.displayClearButton(true); Actions.submitSearch(); @@ -134,7 +134,7 @@ describe('quantic-standalone-search-box', () => { const query = '%test'; visitStandaloneSearchBox({textarea}); - Expect.inputInitialized(textarea); + Expect.inputInitialized(); Actions.typeInSearchBox(query, textarea); Expect.displayClearButton(true); Actions.submitSearch(); @@ -176,7 +176,7 @@ describe('quantic-standalone-search-box', () => { textarea, }); - Expect.inputInitialized(textarea); + Expect.inputInitialized(); Actions.focusSearchBox(textarea); cy.wait(InterceptAliases.QuerySuggestions).then(() => { Expect.displaySuggestionList(true); @@ -192,7 +192,7 @@ describe('quantic-standalone-search-box', () => { textarea, }); - Expect.inputInitialized(textarea); + Expect.inputInitialized(); Actions.typeInSearchBox(query, textarea); Expect.displayClearButton(true); Actions.submitSearch(); @@ -212,7 +212,7 @@ describe('quantic-standalone-search-box', () => { }); interceptQuerySuggestWithParam(requestParams, interceptAlias); - Expect.inputInitialized(textarea); + Expect.inputInitialized(); Actions.focusSearchBox(textarea); Expect.fetchQuerySuggestWithParams(requestParams, interceptAlias); @@ -228,7 +228,7 @@ describe('quantic-standalone-search-box', () => { }); interceptQuerySuggestWithParam(requestParams, interceptAlias); - Expect.inputInitialized(textarea); + Expect.inputInitialized(); Actions.focusSearchBox(textarea); Expect.fetchQuerySuggestWithParams(requestParams, interceptAlias); @@ -244,7 +244,7 @@ describe('quantic-standalone-search-box', () => { 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.'; visitStandaloneSearchBox({textarea: true}); - Expect.inputInitialized(true); + Expect.inputInitialized(); Actions.focusSearchBox(true); Actions.keyboardTypeInSearchBox(longQuery, true); Expect.inputStyleMatches('white-space: pre-wrap;', true); @@ -255,7 +255,7 @@ describe('quantic-standalone-search-box', () => { 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.'; visitStandaloneSearchBox({textarea: true}); - Expect.inputInitialized(true); + Expect.inputInitialized(); Actions.focusSearchBox(true); Actions.keyboardTypeInSearchBox(longQuery, true); Actions.blurSearchBox(true); diff --git a/packages/quantic/cypress/page-objects/search.ts b/packages/quantic/cypress/page-objects/search.ts index ff8610fc40d..187bc1969fa 100644 --- a/packages/quantic/cypress/page-objects/search.ts +++ b/packages/quantic/cypress/page-objects/search.ts @@ -96,6 +96,11 @@ export const InterceptAliases = { }, UndoQuery: uaAlias('undoQuery'), SearchboxSubmit: uaAlias('searchboxSubmit'), + RecentQueries: { + ClearRecentQueries: uaAlias('clearRecentQueries'), + ClickRecentQueries: uaAlias('recentQueriesClick'), + }, + OmniboxAnalytics: uaAlias('omniboxAnalytics'), }, NextAnalytics: { Qna: { @@ -650,3 +655,17 @@ export function mockSearchWithNotifyTrigger( }); }).as(InterceptAliases.Search.substring(1)); } + +export function mockQuerySuggestions(suggestions: string[]) { + cy.intercept(routeMatchers.querySuggest, (req) => { + req.continue((res) => { + res.body.completions = suggestions.map((suggestion) => ({ + expression: suggestion, + highlighted: suggestion, + })); + + res.body.responseId = crypto.randomUUID(); + res.send(); + }); + }).as(InterceptAliases.QuerySuggestions.substring(1)); +} diff --git a/packages/quantic/force-app/examples/main/lwc/exampleQuanticSearchBox/exampleQuanticSearchBox.html b/packages/quantic/force-app/examples/main/lwc/exampleQuanticSearchBox/exampleQuanticSearchBox.html new file mode 100644 index 00000000000..d4510b890df --- /dev/null +++ b/packages/quantic/force-app/examples/main/lwc/exampleQuanticSearchBox/exampleQuanticSearchBox.html @@ -0,0 +1,25 @@ + \ No newline at end of file diff --git a/packages/quantic/force-app/examples/main/lwc/exampleQuanticSearchBox/exampleQuanticSearchBox.js b/packages/quantic/force-app/examples/main/lwc/exampleQuanticSearchBox/exampleQuanticSearchBox.js new file mode 100644 index 00000000000..97da6c33b42 --- /dev/null +++ b/packages/quantic/force-app/examples/main/lwc/exampleQuanticSearchBox/exampleQuanticSearchBox.js @@ -0,0 +1,64 @@ +import {LightningElement, track} from 'lwc'; + +export default class ExampleQuanticSearchBox extends LightningElement { + @track config = {}; + isConfigured = false; + + pageTitle = 'Quantic Search Box'; + pageDescription = + 'The `QuanticSearchBox` component creates a search box with built-in support for query suggestions and query history.'; + options = [ + { + attribute: 'engineId', + label: 'Engine id', + description: 'The ID of the engine instance the component registers to.', + defaultValue: 'quantic-search-box-engine', + }, + { + attribute: 'useCase', + label: 'Use Case', + description: + 'Define which use case to test. Possible values are: search, insight.', + defaultValue: 'search', + }, + { + attribute: 'placeholder', + label: 'Placeholder', + description: + 'The placeholder text to display in the search box input area.', + }, + { + attribute: 'withoutSubmitButton', + label: 'Without submit button', + description: 'Whether not to render a submit button.', + }, + { + attribute: 'textarea', + label: 'Textarea', + description: 'Whether to render the search box using a textarea.', + defaultValue: false, + }, + { + attribute: 'numberOfSuggestions', + label: 'Number of suggestions', + description: ' The maximum number of suggestions to display.', + defaultValue: 7, + }, + { + attribute: 'disableRecentQueries', + label: 'Disable recent query suggestions', + description: + 'Whether to disable rendering the recent queries as suggestions.', + defaultValue: false, + }, + ]; + + get notConfigured() { + return !this.isConfigured; + } + + handleTryItNow(evt) { + this.config = evt.detail; + this.isConfigured = true; + } +} diff --git a/packages/quantic/force-app/examples/main/lwc/exampleQuanticSearchBox/exampleQuanticSearchBox.js-meta.xml b/packages/quantic/force-app/examples/main/lwc/exampleQuanticSearchBox/exampleQuanticSearchBox.js-meta.xml new file mode 100644 index 00000000000..c74d00090dd --- /dev/null +++ b/packages/quantic/force-app/examples/main/lwc/exampleQuanticSearchBox/exampleQuanticSearchBox.js-meta.xml @@ -0,0 +1,12 @@ + + + 58.0 + true + + lightning__RecordPage + lightning__AppPage + lightning__HomePage + lightningCommunity__Page + lightningCommunity__Default + + \ No newline at end of file diff --git a/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml b/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml index 72fde0d6e0c..1b7a25b957a 100644 --- a/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml +++ b/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml @@ -1470,6 +1470,48 @@ false Show less + + quantic_SearchFieldWithSuggestions + Search field with suggestions. Suggestions may be available under this field. To send, press Enter. + en_US + false + Search field with suggestions. + + + quantic_SuggestionFound + {{0}} search suggestion found, to navigate use up and down arrows. + en_US + false + 1 search suggestion found + + + quantic_SuggestionFound_plural + {{0}} search suggestions found, to navigate use up and down arrows. + en_US + false + 4 suggestions found + + + quantic_SuggestionNotFound + There are no search suggestions. + en_US + false + There are no search suggestions. + + + quantic_RecentQueryAriaLabel + Recent query, + en_US + false + Recent query, + + + quantic_QuerySuggestionAriaLabel + Query suggestion, + en_US + false + Query suggestions, + quantic_FeedbackQuestionTopic Is the answer about the right topic? diff --git a/packages/quantic/force-app/main/default/lwc/quanticSearchBox/quanticSearchBox.js b/packages/quantic/force-app/main/default/lwc/quanticSearchBox/quanticSearchBox.js index 4e5c6bd840f..5f4400d0ab6 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticSearchBox/quanticSearchBox.js +++ b/packages/quantic/force-app/main/default/lwc/quanticSearchBox/quanticSearchBox.js @@ -3,6 +3,7 @@ import { initializeWithHeadless, getHeadlessBundle, } from 'c/quanticHeadlessLoader'; +import {getItemFromLocalStorage, setItemInLocalStorage} from 'c/quanticUtils'; import {LightningElement, api, track} from 'lwc'; // @ts-ignore import errorTemplate from './templates/errorTemplate.html'; @@ -12,6 +13,7 @@ import searchBox from './templates/searchBox.html'; /** @typedef {import("coveo").SearchEngine} SearchEngine */ /** @typedef {import("coveo").SearchBoxState} SearchBoxState */ /** @typedef {import("coveo").SearchBox} SearchBox */ +/** @typedef {import("coveo").RecentQueriesList} RecentQueriesList */ /** @typedef {import('c/quanticSearchBoxSuggestionsList').default} quanticSearchBoxSuggestionsList */ /** @typedef {import("c/quanticSearchBoxInput").default} quanticSearchBoxInput */ @@ -43,12 +45,12 @@ export default class QuanticSearchBox extends LightningElement { */ @api withoutSubmitButton = false; /** - * The maximum number of suggestions to display. + * The maximum number of suggestions and recent search queries to display. * @api * @type {number} - * @defaultValue 5 + * @defaultValue 7 */ - @api numberOfSuggestions = 5; + @api numberOfSuggestions = 7; /** * Whether to render the search box using a [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) element. * The resulting component will expand to support multi-line queries. @@ -57,6 +59,13 @@ export default class QuanticSearchBox extends LightningElement { * @defaultValue false */ @api textarea = false; + /** + * Whether to disable rendering the recent queries as suggestions. + * @api + * @type {boolean} + * @defaultValue false + */ + @api disableRecentQueries = false; /** @type {SearchBoxState} */ @track state; @@ -71,6 +80,10 @@ export default class QuanticSearchBox extends LightningElement { suggestions = []; /** @type {boolean} */ hasInitializationError = false; + /** @type {RecentQueriesList} */ + recentQueriesList; + /** @type {String[]} */ + recentQueries; /** * @param {SearchEngine} engine @@ -88,6 +101,21 @@ export default class QuanticSearchBox extends LightningElement { }, }, }); + + if (!this.disableRecentQueries && this.headless.buildRecentQueriesList) { + this.localStorageKey = `${this.engineId}_quantic-recent-queries`; + this.recentQueriesList = this.headless.buildRecentQueriesList(engine, { + initialState: { + queries: getItemFromLocalStorage(this.localStorageKey) ?? [], + }, + options: { + maxLength: 100, + }, + }); + this.unsubscribeRecentQueriesList = this.recentQueriesList.subscribe(() => + this.updateRecentQueriesListState() + ); + } this.unsubscribe = this.searchBox.subscribe(() => this.updateState()); }; @@ -120,31 +148,38 @@ export default class QuanticSearchBox extends LightningElement { ); } + get searchBoxValue() { + return this.searchBox?.state.value || ''; + } + updateState() { - if (this.state?.value !== this.searchBox.state.value) { - this.quanticSearchBoxInput.inputValue = this.searchBox.state.value; - } this.state = this.searchBox?.state; this.suggestions = - this.state?.suggestions?.map((s, index) => ({ + this.state?.suggestions?.map((suggestion, index) => ({ key: index, - rawValue: s.rawValue, - value: s.highlightedValue, + rawValue: suggestion.rawValue, + value: suggestion.highlightedValue, })) ?? []; } + updateRecentQueriesListState() { + if (this.recentQueriesList.state?.queries) { + this.recentQueries = this.recentQueriesList.state.queries; + setItemInLocalStorage( + this.localStorageKey, + this.recentQueriesList.state.queries + ); + } + } + /** * Updates the input value. */ handleInputValueChange = (event) => { event.stopPropagation(); - const updatedValue = event.detail.newInputValue; - const isSelectionReset = event.detail.resetSelection; - if (this.searchBox?.state?.value !== updatedValue) { - if (isSelectionReset) { - this.quanticSearchBoxInput.resetSelection(); - } - this.searchBox.updateText(updatedValue); + const newValue = event.detail.value; + if (this.searchBox?.state?.value !== newValue) { + this.searchBox.updateText(newValue); } }; @@ -171,8 +206,17 @@ export default class QuanticSearchBox extends LightningElement { */ selectSuggestion = (event) => { event.stopPropagation(); - const selectedSuggestion = event.detail.selectedSuggestion; - this.searchBox?.selectSuggestion(selectedSuggestion); + const {value, isRecentQuery, isClearRecentQueryButton} = + event.detail.selectedSuggestion; + if (isClearRecentQueryButton) { + this.recentQueriesList.clear(); + } else if (isRecentQuery) { + this.recentQueriesList.executeRecentQuery( + this.recentQueries.indexOf(value) + ); + } else { + this.searchBox?.selectSuggestion(value); + } }; /** diff --git a/packages/quantic/force-app/main/default/lwc/quanticSearchBox/templates/searchBox.html b/packages/quantic/force-app/main/default/lwc/quanticSearchBox/templates/searchBox.html index 98b0517907d..11fe46d4b98 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticSearchBox/templates/searchBox.html +++ b/packages/quantic/force-app/main/default/lwc/quanticSearchBox/templates/searchBox.html @@ -1,8 +1,11 @@ diff --git a/packages/quantic/force-app/main/default/lwc/quanticSearchBoxInput/__tests__/quanticSearchBoxInput.test.js b/packages/quantic/force-app/main/default/lwc/quanticSearchBoxInput/__tests__/quanticSearchBoxInput.test.js index d622202b02d..142f5bcaeae 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticSearchBoxInput/__tests__/quanticSearchBoxInput.test.js +++ b/packages/quantic/force-app/main/default/lwc/quanticSearchBoxInput/__tests__/quanticSearchBoxInput.test.js @@ -7,7 +7,6 @@ const functionsMocks = { exampleHandleSubmitSearch: jest.fn(() => {}), exampleShowSuggestions: jest.fn(() => {}), exampleSelectSuggestion: jest.fn(() => {}), - exampleHandleKeyup: jest.fn(() => {}), }; const defaultPlaceholder = 'Search...'; @@ -17,6 +16,7 @@ const mockSuggestions = [ {key: '2', value: 'suggestion2', rawValue: 'suggestion2'}, {key: '3', value: 'suggestion3', rawValue: 'suggestion3'}, ]; +const exampleRecentQueries = ['foo', 'bar']; const defaultOptions = { withoutSubmitButton: false, @@ -31,9 +31,12 @@ const selectors = { searchBoxSubmitBtn: '.searchbox__submit-button', searchBoxClearIcon: '.searchbox__clear-button', searchBoxSuggestionsList: 'c-quantic-search-box-suggestions-list', + SuggestionsListBox: '[role="listbox"]', searchBoxContainer: '.searchbox__container', searchBoxComboBox: '.slds-combobox_container .slds-combobox', searchBoxSearchIcon: '.searchbox__search-icon', + suggestionOption: '[data-cy="suggestions-option"]', + clearRecentQueryButton: '[data-cy="clear-recent-queries"]', }; function setupEventListeners(element) { @@ -53,7 +56,6 @@ function setupEventListeners(element) { 'quantic__selectsuggestion', functionsMocks.exampleSelectSuggestion ); - element.addEventListener('keyup', functionsMocks.exampleHandleKeyup); } function createTestComponent(options = defaultOptions) { @@ -81,12 +83,21 @@ describe('c-quantic-search-box-input', () => { } } + beforeAll(() => { + // @ts-ignore + global.CoveoHeadless = { + HighlightUtils: { + highlightString: () => {}, + }, + }; + }); + afterEach(() => { cleanup(); jest.clearAllMocks(); }); - [true, false].forEach((textareaValue) => { + [false].forEach((textareaValue) => { describe(`when the textarea property is set to ${textareaValue}`, () => { it(`should display the ${ textareaValue ? 'expandable' : 'default' @@ -180,24 +191,75 @@ describe('c-quantic-search-box-input', () => { }); describe('when the suggestions list is not empty', () => { - it('should display the suggestions in the suggestions list', async () => { - const element = createTestComponent({ - ...defaultOptions, - suggestions: mockSuggestions, - textarea: textareaValue, + describe('when only query suggestions are displayed', () => { + it('should display the suggestions in the suggestions list', async () => { + const element = createTestComponent({ + ...defaultOptions, + suggestions: mockSuggestions, + textarea: textareaValue, + }); + await flushPromises(); + + const input = element.shadowRoot.querySelector( + textareaValue + ? selectors.searchBoxTextArea + : selectors.searchBoxInput + ); + expect(input).not.toBeNull(); + await input.focus(); + + const suggestionsList = element.shadowRoot.querySelector( + selectors.searchBoxSuggestionsList + ); + expect(suggestionsList).not.toBeNull(); + + const suggestionsListItems = + suggestionsList.shadowRoot.querySelectorAll( + selectors.suggestionOption + ); + expect(suggestionsListItems).not.toBeNull(); + expect(suggestionsListItems.length).toEqual(mockSuggestions.length); }); - await flushPromises(); + }); - const suggestionsList = element.shadowRoot.querySelector( - selectors.searchBoxSuggestionsList - ); - expect(suggestionsList).not.toBeNull(); + describe('with both query suggestions and recent queries available', () => { + it('should display the query suggestions and the recent queries in the suggestions list', async () => { + const element = createTestComponent({ + ...defaultOptions, + suggestions: mockSuggestions, + recentQueries: exampleRecentQueries, + textarea: textareaValue, + inputValue: '', + }); + await flushPromises(); + + const input = element.shadowRoot.querySelector( + textareaValue + ? selectors.searchBoxTextArea + : selectors.searchBoxInput + ); + expect(input).not.toBeNull(); + await input.focus(); - const suggestionsListItems = - suggestionsList.shadowRoot.querySelectorAll('li'); - expect(suggestionsListItems).not.toBeNull(); + const suggestionsList = element.shadowRoot.querySelector( + selectors.searchBoxSuggestionsList + ); + expect(suggestionsList).not.toBeNull(); - expect(suggestionsListItems.length).toEqual(mockSuggestions.length); + const suggestionsListItems = + suggestionsList.shadowRoot.querySelectorAll( + selectors.suggestionOption + ); + const clearRecentQueriesButton = + suggestionsList.shadowRoot.querySelector( + selectors.clearRecentQueryButton + ); + expect(suggestionsListItems).not.toBeNull(); + expect(clearRecentQueriesButton).not.toBeNull(); + expect(suggestionsListItems.length).toEqual( + mockSuggestions.length + exampleRecentQueries.length + ); + }); }); }); @@ -250,27 +312,147 @@ describe('c-quantic-search-box-input', () => { setupEventListeners(element); await flushPromises(); + const input = element.shadowRoot.querySelector( + textareaValue + ? selectors.searchBoxTextArea + : selectors.searchBoxInput + ); + expect(input).not.toBeNull(); + + await input.focus(); + const suggestionsList = element.shadowRoot.querySelector( selectors.searchBoxSuggestionsList ); expect(suggestionsList).not.toBeNull(); - const firstSuggestion = - suggestionsList.shadowRoot.querySelectorAll('li')[0]; + const querySuggestionIndex = 0; + const firstSuggestion = suggestionsList.shadowRoot.querySelectorAll( + selectors.suggestionOption + )[querySuggestionIndex]; expect(firstSuggestion).not.toBeNull(); - firstSuggestion.click(); + firstSuggestion.dispatchEvent(new CustomEvent('mousedown')); + expect( + functionsMocks.exampleSelectSuggestion + ).toHaveBeenCalledTimes(1); + + /** @type{{detail: {selectedSuggestion: object}}} */ + const eventData = + functionsMocks.exampleSelectSuggestion.mock.calls[0][0]; + const expectedFirstSuggestionSelected = { + isClearRecentQueryButton: undefined, + isRecentQuery: undefined, + value: mockSuggestions[querySuggestionIndex].rawValue, + }; + + expect(eventData.detail.selectedSuggestion).toEqual( + expectedFirstSuggestionSelected + ); + }); + }); + + describe('when selecting the clear recent query option from the suggestions list', () => { + it('should dispatch a #quantic__selectsuggestion event with the selected suggestion as payload', async () => { + const element = createTestComponent({ + ...defaultOptions, + suggestions: mockSuggestions, + textarea: textareaValue, + recentQueries: exampleRecentQueries, + inputValue: '', + }); + setupEventListeners(element); + await flushPromises(); + + const input = element.shadowRoot.querySelector( + textareaValue + ? selectors.searchBoxTextArea + : selectors.searchBoxInput + ); + expect(input).not.toBeNull(); + + await input.focus(); + + const suggestionsList = element.shadowRoot.querySelector( + selectors.searchBoxSuggestionsList + ); + expect(suggestionsList).not.toBeNull(); + + const clearRecentQueriesOption = + suggestionsList.shadowRoot.querySelectorAll( + selectors.clearRecentQueryButton + )[0]; + expect(clearRecentQueriesOption).not.toBeNull(); + + clearRecentQueriesOption.dispatchEvent( + new CustomEvent('mousedown') + ); + expect( + functionsMocks.exampleSelectSuggestion + ).toHaveBeenCalledTimes(1); + + /** @type{{detail: {selectedSuggestion: object}}} */ + const eventData = + functionsMocks.exampleSelectSuggestion.mock.calls[0][0]; + const expectedFirstSuggestionSelected = { + isClearRecentQueryButton: true, + isRecentQuery: undefined, + value: undefined, + }; + + expect(eventData.detail.selectedSuggestion).toEqual( + expectedFirstSuggestionSelected + ); + }); + }); + + describe('when selecting a recent query from the suggestions list', () => { + it('should dispatch a #quantic__selectsuggestion event with the selected recent query as payload', async () => { + const element = createTestComponent({ + ...defaultOptions, + suggestions: mockSuggestions, + textarea: textareaValue, + recentQueries: exampleRecentQueries, + inputValue: '', + }); + setupEventListeners(element); + await flushPromises(); + const input = element.shadowRoot.querySelector( + textareaValue + ? selectors.searchBoxTextArea + : selectors.searchBoxInput + ); + expect(input).not.toBeNull(); + + await input.focus(); + + const suggestionsList = element.shadowRoot.querySelector( + selectors.searchBoxSuggestionsList + ); + expect(suggestionsList).not.toBeNull(); + + const recentQueryIndex = 0; + const firstRecentQuery = + suggestionsList.shadowRoot.querySelectorAll( + selectors.suggestionOption + )[recentQueryIndex]; + expect(firstRecentQuery).not.toBeNull(); + + firstRecentQuery.dispatchEvent(new CustomEvent('mousedown')); expect( functionsMocks.exampleSelectSuggestion ).toHaveBeenCalledTimes(1); + /** @type{{detail: {selectedSuggestion: object}}} */ const eventData = - functionsMocks.exampleSelectSuggestion.mock.calls[0][0] && functionsMocks.exampleSelectSuggestion.mock.calls[0][0]; - const expectedFirstSuggestionSelected = mockSuggestions[0].rawValue; + const expectedFirstSuggestionSelected = { + isClearRecentQueryButton: undefined, + isRecentQuery: true, + value: exampleRecentQueries[recentQueryIndex], + }; - // @ts-ignore expect(eventData.detail.selectedSuggestion).toEqual( expectedFirstSuggestionSelected ); @@ -283,12 +465,11 @@ describe('c-quantic-search-box-input', () => { const element = createTestComponent({ ...defaultOptions, textarea: textareaValue, + inputValue: mockInputValue, }); setupEventListeners(element); await flushPromises(); - element.inputValue = mockInputValue; - const input = element.shadowRoot.querySelector( textareaValue ? selectors.searchBoxTextArea @@ -296,16 +477,15 @@ describe('c-quantic-search-box-input', () => { ); expect(input).not.toBeNull(); - input.dispatchEvent(new KeyboardEvent('keyup', {key: 'a'})); + input.dispatchEvent(new KeyboardEvent('input')); expect( functionsMocks.exampleHandleInputValueChange ).toHaveBeenCalledTimes(1); - // @ts-ignore + /** @type{{detail: {value: string}}} */ const eventData = functionsMocks.exampleHandleInputValueChange.mock.calls[0][0]; - // @ts-ignore - expect(eventData.detail.newInputValue).toEqual(mockInputValue); + expect(eventData.detail.value).toEqual(mockInputValue); }); describe('when clicking on the submit button', () => { @@ -347,7 +527,7 @@ describe('c-quantic-search-box-input', () => { expect(input).not.toBeNull(); await input.focus(); - input.dispatchEvent(new KeyboardEvent('keyup', {key: 'Enter'})); + input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'})); expect( functionsMocks.exampleHandleSubmitSearch @@ -373,7 +553,7 @@ describe('c-quantic-search-box-input', () => { await input.focus(); input.dispatchEvent( - new KeyboardEvent('keyup', {key: 'Enter', shiftKey: true}) + new KeyboardEvent('keydown', {key: 'Enter', shiftKey: true}) ); expect( @@ -381,6 +561,96 @@ describe('c-quantic-search-box-input', () => { ).toHaveBeenCalledTimes(textareaValue ? 0 : 1); }); }); + + describe('accessibility', () => { + describe('when pressing the DOWN and UP arrow keys', () => { + it('should set the aria-activedescendant attribute of the input according to the currently active suggestion', async () => { + const element = createTestComponent({ + ...defaultOptions, + suggestions: mockSuggestions, + textarea: textareaValue, + }); + await flushPromises(); + + const input = element.shadowRoot.querySelector( + textareaValue + ? selectors.searchBoxTextArea + : selectors.searchBoxInput + ); + expect(input).not.toBeNull(); + + await input.focus(); + const suggestionsList = element.shadowRoot.querySelector( + selectors.searchBoxSuggestionsList + ); + const suggestionsListItems = Array.from( + suggestionsList.shadowRoot.querySelectorAll( + selectors.suggestionOption + ) + ); + expect(suggestionsList).not.toBeNull(); + expect(suggestionsListItems).not.toBeNull(); + + input.dispatchEvent( + new KeyboardEvent('keydown', {key: 'ArrowDown'}) + ); + await flushPromises(); + + const firstSuggestionId = suggestionsListItems[0].id; + + expect(input.getAttribute('aria-activedescendant')).toBe( + firstSuggestionId + ); + + input.dispatchEvent( + new KeyboardEvent('keydown', {key: 'ArrowUp'}) + ); + await flushPromises(); + + const lastSuggestionId = suggestionsListItems.at(-1).id; + + expect(input.getAttribute('aria-activedescendant')).toBe( + lastSuggestionId + ); + + await input.blur(); + expect(input.getAttribute('aria-activedescendant')).toBeNull(); + }); + }); + + describe('when the search box input is rendered', () => { + it('should set the aria-controls attribute of the input according to the id of the suggestions listbox', async () => { + const element = createTestComponent({ + ...defaultOptions, + suggestions: mockSuggestions, + textarea: textareaValue, + }); + await flushPromises(); + + const input = element.shadowRoot.querySelector( + textareaValue + ? selectors.searchBoxTextArea + : selectors.searchBoxInput + ); + expect(input).not.toBeNull(); + + await input.focus(); + const suggestionsList = element.shadowRoot.querySelector( + selectors.searchBoxSuggestionsList + ); + const suggestionsListBox = + suggestionsList.shadowRoot.querySelector( + selectors.SuggestionsListBox + ); + expect(suggestionsList).not.toBeNull(); + expect(suggestionsListBox).not.toBeNull(); + + expect(input.getAttribute('aria-controls')).toBe( + suggestionsListBox.id + ); + }); + }); + }); }); }); }); diff --git a/packages/quantic/force-app/main/default/lwc/quanticSearchBoxInput/quanticSearchBoxInput.js b/packages/quantic/force-app/main/default/lwc/quanticSearchBoxInput/quanticSearchBoxInput.js index 1448d28224d..75e9a6bbec0 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticSearchBoxInput/quanticSearchBoxInput.js +++ b/packages/quantic/force-app/main/default/lwc/quanticSearchBoxInput/quanticSearchBoxInput.js @@ -1,5 +1,6 @@ import clear from '@salesforce/label/c.quantic_Clear'; import search from '@salesforce/label/c.quantic_Search'; +import searchFieldWithSuggestions from '@salesforce/label/c.quantic_SearchFieldWithSuggestions'; import {keys} from 'c/quanticUtils'; import {LightningElement, api} from 'lwc'; // @ts-ignore @@ -35,6 +36,7 @@ export default class QuanticSearchBoxInput extends LightningElement { labels = { search, clear, + searchFieldWithSuggestions, }; /** * Indicates whether or not to display a submit button. @@ -65,27 +67,38 @@ export default class QuanticSearchBoxInput extends LightningElement { */ @api suggestions = []; /** - * Returns and set the input value. + * The value of the input. * @api - * @type {string} + * @type {String} */ - @api - get inputValue() { - return this.input.value; - } - set inputValue(newValue) { - this.input.value = newValue; - } + @api inputValue; + /** + * The list containing the recent query suggestions. + * @api + * @type {String[]} + */ + @api recentQueries; + /** + * The maximum number of suggestions to display. + * @api + * @type {number} + * @defaultValue 7 + */ + @api maxNumberOfSuggestions = 7; + // TODO SFINT-5646: Remove deprecated method /** * The blur function. + * @deprecated * @api * @type {VoidFunction} */ @api blur() { this.input.blur(); } + // TODO SFINT-5646: Remove deprecated method /** * The reset selection function. + * @deprecated * @api * @type {VoidFunction} */ @@ -95,6 +108,9 @@ export default class QuanticSearchBoxInput extends LightningElement { /** @type {boolean} */ ignoreNextEnterKeyPress = false; + /** @type {string} */ + ariaActiveDescendant; + inputIsFocused = false; connectedCallback() { this.addEventListener( @@ -102,6 +118,7 @@ export default class QuanticSearchBoxInput extends LightningElement { this.handleSuggestionListEvent ); } + disconnectedCallback() { this.removeEventListener( 'suggestionlistrender', @@ -109,6 +126,12 @@ export default class QuanticSearchBoxInput extends LightningElement { ); } + renderedCallback() { + if (this.input.value !== this.inputValue) { + this.input.value = this.inputValue; + } + } + /** * @returns {quanticSearchBoxSuggestionsList} */ @@ -128,14 +151,12 @@ export default class QuanticSearchBoxInput extends LightningElement { /** * Sends the "quantic__inputValueChange" event. - * @param {string} newInputValue - * @param {boolean} resetSelection + * @param {string} value */ - sendInputValueChangeEvent(newInputValue, resetSelection) { + sendInputValueChangeEvent(value) { const inputValueChangeEvent = new CustomEvent('quantic__inputvaluechange', { detail: { - newInputValue, - resetSelection, + value, }, bubbles: true, composed: true, @@ -169,7 +190,7 @@ export default class QuanticSearchBoxInput extends LightningElement { /** * Sends the "quantic__selectSuggestion" event. - * @param {string} selectedSuggestion + * @param {{value: string, isRecentQuery: boolean, isClearRecentQueryButton: boolean}} selectedSuggestion */ sendSelectSuggestionEvent(selectedSuggestion) { const selectSuggestionEvent = new CustomEvent('quantic__selectsuggestion', { @@ -187,8 +208,8 @@ export default class QuanticSearchBoxInput extends LightningElement { if (!(this.ignoreNextEnterKeyPress || isLineBreak)) { const selectedSuggestion = this.suggestionListElement?.getCurrentSelectedValue(); - if (this.areSuggestionsOpen && selectedSuggestion) { - this.sendSelectSuggestionEvent(selectedSuggestion.rawValue); + if (selectedSuggestion) { + this.sendSelectSuggestionEvent(selectedSuggestion); } else { this.sendSubmitSearchEvent(); } @@ -197,69 +218,84 @@ export default class QuanticSearchBoxInput extends LightningElement { } handleValueChange() { - this.sendInputValueChangeEvent(this.input.value, false); + this.sendInputValueChangeEvent(this.input.value); } onSubmit(event) { event.stopPropagation(); - this.sendInputValueChangeEvent(this.input.value, false); this.sendSubmitSearchEvent(); this.input.blur(); } - handleKeyDownOnClearButton(event) { - if (event.key === keys.ENTER) { - // Ignore the next enter key press in the searchbox input to prevent submitting a search when we press enter on the clear button. - this.ignoreNextEnterKeyPress = true; - } - } - /** - * Prevent default behavior of enter key, on textArea, to prevent skipping a line. * @param {KeyboardEvent} event */ - onKeydown(event) { - if (event.key === keys.ENTER && !event.shiftKey) { - event.preventDefault(); - } - } - - /** - * @param {KeyboardEvent} event - */ - onKeyup(event) { + onKeyDown(event) { + // eslint-disable-next-line default-case switch (event.key) { + case keys.ESC: + this.input.removeAttribute('aria-activedescendant'); + this.input.blur(); + break; + case keys.ENTER: this.handleEnter(event); break; - case keys.ARROWUP: - this.suggestionListElement?.selectionUp(); + + case keys.ARROWUP: { + event.preventDefault(); + const {id, value} = this.suggestionListElement.selectionUp(); + if (value) { + this.input.value = value; + } + this.ariaActiveDescendant = id; + this.input.setAttribute( + 'aria-activedescendant', + this.ariaActiveDescendant + ); break; - case keys.ARROWDOWN: - this.suggestionListElement?.selectionDown(); + } + + case keys.ARROWDOWN: { + event.preventDefault(); + const {id, value} = this.suggestionListElement.selectionDown(); + if (value) { + this.input.value = value; + } + this.ariaActiveDescendant = id; + this.input.setAttribute( + 'aria-activedescendant', + this.ariaActiveDescendant + ); break; - default: - // Reset selection set to true for key pressed other than ARROW keys and ENTER. - this.sendInputValueChangeEvent(this.input.value, true); + } } this.ignoreNextEnterKeyPress = false; } onFocus() { - this.showSuggestions(); + this.inputIsFocused = true; + this.sendShowSuggestionsEvent(); this.adjustTextAreaHeight(); } onBlur() { - this.hideSuggestions(); + this.inputIsFocused = false; + this.input.removeAttribute('aria-activedescendant'); this.collapseTextArea(); } onTextAreaInput() { - this.sendInputValueChangeEvent(this.input.value, true); + this.sendInputValueChangeEvent(this.input.value); this.adjustTextAreaHeight(); } + handleSelection(event) { + this.sendSelectSuggestionEvent(event.detail.selection); + this.inputIsFocused = false; + this.input.blur(); + } + adjustTextAreaHeight() { if (!this.textarea) { return; @@ -281,37 +317,13 @@ export default class QuanticSearchBoxInput extends LightningElement { } clearInput() { - this.input.value = ''; - this.sendInputValueChangeEvent(this.input.value, false); + this.sendInputValueChangeEvent(''); this.input.focus(); if (this.textarea) { this.adjustTextAreaHeight(); } } - showSuggestions() { - this.sendShowSuggestionsEvent(); - this.combobox?.classList.add('slds-is-open'); - this.combobox?.setAttribute('aria-expanded', 'true'); - } - - hideSuggestions() { - this.combobox?.classList.remove('slds-is-open'); - this.combobox?.setAttribute('aria-expanded', 'false'); - this.suggestionListElement?.resetSelection(); - } - - handleHighlightChange(event) { - this.input.value = event.detail?.rawValue; - this.adjustTextAreaHeight(); - } - - handleSuggestionSelection(event) { - const textValue = event.detail; - this.sendSelectSuggestionEvent(textValue); - this.blur(); - } - handleSuggestionListEvent = (event) => { event.stopPropagation(); const id = event.detail; @@ -332,12 +344,8 @@ export default class QuanticSearchBoxInput extends LightningElement { }`; } - get areSuggestionsOpen() { - return this.combobox?.classList.contains('slds-is-open'); - } - get isQueryEmpty() { - return !this.input?.value?.length; + return !this.inputValue?.length; } /** @@ -347,8 +355,11 @@ export default class QuanticSearchBoxInput extends LightningElement { return this.template.querySelector('.slds-combobox'); } - get hasSuggestions() { - return this.suggestions?.length; + get shouldDisplaySuggestions() { + return ( + this.inputIsFocused && + (this.suggestions?.length || this.recentQueries?.length) + ); } render() { diff --git a/packages/quantic/force-app/main/default/lwc/quanticSearchBoxInput/templates/defaultSearchBoxInput.html b/packages/quantic/force-app/main/default/lwc/quanticSearchBoxInput/templates/defaultSearchBoxInput.html index 31e66f26be0..7827e00b1eb 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticSearchBoxInput/templates/defaultSearchBoxInput.html +++ b/packages/quantic/force-app/main/default/lwc/quanticSearchBoxInput/templates/defaultSearchBoxInput.html @@ -3,10 +3,7 @@