diff --git a/core/src/App.svelte b/core/src/App.svelte index 2ebd6f60c5..87bb0beddb 100644 --- a/core/src/App.svelte +++ b/core/src/App.svelte @@ -105,7 +105,7 @@ export let isSearchFieldVisible; export let inputElem; - export let luigiCustomSearchRenderer__slot; + export let customSearchItemRendererSlot; export let displaySearchResult; export let searchResult; export let storedUserSettings; @@ -556,7 +556,7 @@ searchProvider.onSearchResultItemSelected(item); } }; - searchProvider.customSearchResultRenderer(arr, luigiCustomSearchRenderer__slot, searchApiObj); + searchProvider.customSearchResultRenderer(arr, customSearchItemRendererSlot, searchApiObj); } else { displaySearchResult = true; searchResult = arr; @@ -571,9 +571,9 @@ if (checkSearchProvider(searchProvider)) { displaySearchResult = false; searchResult = []; - if (luigiCustomSearchRenderer__slot) { - while (luigiCustomSearchRenderer__slot.lastElementChild) { - luigiCustomSearchRenderer__slot.removeChild(luigiCustomSearchRenderer__slot.lastElementChild); + if (customSearchItemRendererSlot) { + while (customSearchItemRendererSlot.lastElementChild) { + customSearchItemRendererSlot.removeChild(customSearchItemRendererSlot.lastElementChild); } } } @@ -1880,7 +1880,7 @@ bind:displaySearchResult bind:searchResult bind:inputElem - bind:luigiCustomSearchRenderer__slot + bind:customSearchItemRendererSlot {burgerTooltip} /> {/if} @@ -2003,7 +2003,7 @@ bind:displaySearchResult bind:searchResult bind:inputElem - bind:luigiCustomSearchRenderer__slot + bind:customSearchItemRendererSlot {burgerTooltip} /> {/if} diff --git a/core/src/navigation/GlobalSearch.svelte b/core/src/navigation/GlobalSearch.svelte index efecd5ff8e..4b8dd001bf 100644 --- a/core/src/navigation/GlobalSearch.svelte +++ b/core/src/navigation/GlobalSearch.svelte @@ -1,190 +1,48 @@ @@ -223,23 +81,23 @@ {/if}
- {#if !isCustomSearchRenderer} + {#if !globalSearchHelper.isCustomSearchRenderer}
{:else} - {@html renderCustomSearchItem(result, luigiCustomSearchItemRenderer__slotContainer, index)} + {@html globalSearchHelper.renderCustomSearchItem(result, customSearchItemRendererSlotContainer, index)} {/if} {/each} @@ -258,7 +116,7 @@ {:else} -
+
{/if}
diff --git a/core/src/navigation/GlobalSearchCentered.svelte b/core/src/navigation/GlobalSearchCentered.svelte index 9802080f84..2002ff2673 100644 --- a/core/src/navigation/GlobalSearchCentered.svelte +++ b/core/src/navigation/GlobalSearchCentered.svelte @@ -1,111 +1,56 @@ @@ -279,14 +145,14 @@
{/if}
- {#if !isCustomSearchRenderer} + {#if !globalSearchHelper.isCustomSearchRenderer}
{:else} - {@html renderCustomSearchItem(result, luigiCustomSearchItemRenderer__slotContainer, index)} + {@html renderCustomSearchItem(result, customSearchItemRendererSlotContainer, index)} {/if} {/each} @@ -314,7 +180,7 @@ {:else} -
+
{/if}
diff --git a/core/src/navigation/TopNav.svelte b/core/src/navigation/TopNav.svelte index aa16d723b0..88884217c4 100644 --- a/core/src/navigation/TopNav.svelte +++ b/core/src/navigation/TopNav.svelte @@ -48,7 +48,7 @@ export let isGlobalSearchCentered; export let isSearchFieldVisible; export let inputElem; - export let luigiCustomSearchRenderer__slot; + export let customSearchItemRendererSlot; export let displaySearchResult; export let searchResult; export let burgerTooltip; @@ -208,7 +208,7 @@ dispatch('toggleSearch', { isSearchFieldVisible, inputElem, - luigiCustomSearchRenderer__slot + customSearchItemRendererSlot }); } @@ -304,7 +304,7 @@ bind:searchResult bind:displaySearchResult bind:inputElem - bind:luigiCustomSearchRenderer__slot + bind:customSearchItemRendererSlot on:closeSearchResult /> @@ -319,7 +319,7 @@ bind:searchResult bind:displaySearchResult bind:inputElem - bind:luigiCustomSearchRenderer__slot + bind:customSearchItemRendererSlot on:closeSearchResult {globalSearchConfig} /> diff --git a/core/src/utilities/helpers/global-search-helpers.js b/core/src/utilities/helpers/global-search-helpers.js index f8e9dc79d0..6db11f2555 100644 --- a/core/src/utilities/helpers/global-search-helpers.js +++ b/core/src/utilities/helpers/global-search-helpers.js @@ -1,6 +1,31 @@ /* istanbul ignore file */ -class GlobalSearchHelperClass { - constructor() {} +import { GenericHelpers } from './'; +import { KEYCODE_ARROW_UP, KEYCODE_ARROW_DOWN, KEYCODE_ENTER, KEYCODE_ESC } from './../keycode.js'; +import { Routing } from './../../services/routing'; +import { LuigiI18N } from './../../core-api'; +export class GlobalSearchHelperClass { + dispatch; + search; + isCustomSearchRenderer; + isCustomSearchResultItemRenderer; + customSearchItemRendererSlotContainer; + + constructor(search, dispatcher) { + this.search = search; + this.dispatch = dispatcher; + } + + getCustomRenderer() { + if (!this.search.searchProvider) return; + this.isCustomSearchRenderer = GenericHelpers.isFunction(this.search.searchProvider.customSearchResultRenderer); + this.isCustomSearchResultItemRenderer = GenericHelpers.isFunction( + this.search.searchProvider.customSearchResultItemRenderer + ); + } + + updateCustomSearchItemRendererSlotContainer(updatedSlotContainer) { + this.customSearchItemRendererSlotContainer = updatedSlotContainer; + } handleVisibilityGlobalSearch() { if (!document.querySelector('.lui-global-search')) return; @@ -8,6 +33,163 @@ class GlobalSearchHelperClass { const condition = globalSearchCtn.offsetWidth <= 384; globalSearchCtn.classList.toggle('lui-global-search-toggle', condition); } -} -export const GlobalSearchHelper = new GlobalSearchHelperClass(); + setSearchPlaceholder(inputElement) { + const placeHolder = this.getSearchPlaceholder(); + if (placeHolder) { + inputElement.placeholder = placeHolder; + } + } + + getSearchPlaceholder() { + const searchProvider = this.search.searchProvider; + if (!searchProvider || !searchProvider.inputPlaceholder) { + return undefined; + } + const currentLocale = LuigiI18N.getCurrentLocale(); + if (GenericHelpers.isFunction(searchProvider.inputPlaceholder)) { + return searchProvider.inputPlaceholder(); + } + if (typeof searchProvider.inputPlaceholder === 'string') { + const translated = LuigiI18N.getTranslation(searchProvider.inputPlaceholder); + if (!!translated && translated.trim().length > 0) { + return translated; + } + return searchProvider.inputPlaceholder; + } + if (typeof searchProvider.inputPlaceholder === 'object') { + return searchProvider.inputPlaceholder[currentLocale]; + } + } + + renderCustomSearchItem(item, slotContainer, index) { + setTimeout(() => { + search.searchProvider.customSearchResultItemRenderer(item, slotContainer.children[index], searchApiObj); + }); + return ''; + } + + onKeyUp({ keyCode }, displaySearchResult) { + if (this.search && this.search.searchProvider) { + if (GenericHelpers.isFunction(this.search.searchProvider.onEnter) && keyCode === KEYCODE_ENTER) { + this.search.searchProvider.onEnter(); + } else if (GenericHelpers.isFunction(this.search.searchProvider.onEscape) && keyCode === KEYCODE_ESC) { + this.search.searchProvider.onEscape(); + } else if (keyCode === KEYCODE_ARROW_DOWN) { + if (displaySearchResult) { + document.querySelector('.luigi-search-result-item__0').childNodes[0].setAttribute('aria-selected', 'true'); + document.querySelector('.luigi-search-result-item__0').focus(); + } + } else if (GenericHelpers.isFunction(this.search.searchProvider.onInput)) { + this.search.searchProvider.onInput(); + } + } else { + console.warn('GlobalSearch is not available.'); + } + } + + calcSearchResultItemSelected(direction) { + let renderedSearchResultItems = this.customSearchItemRendererSlotContainer.children; + if (renderedSearchResultItems) { + for (let index = 0; index < renderedSearchResultItems.length; index++) { + let { childNodes, nextSibling, previousSibling } = renderedSearchResultItems[index]; + let nodeSibling; + if (childNodes[0].getAttribute('aria-selected') === 'true') { + if (direction === KEYCODE_ARROW_DOWN) { + nodeSibling = nextSibling !== null ? nextSibling : renderedSearchResultItems[0]; + } + if (direction === KEYCODE_ARROW_UP) { + nodeSibling = + previousSibling !== null + ? previousSibling + : renderedSearchResultItems[renderedSearchResultItems.length - 1]; + } + childNodes[0].setAttribute('aria-selected', 'false'); + nodeSibling.childNodes[0].setAttribute('aria-selected', 'true'); + nodeSibling.focus(); + break; + } + } + } + } + + clearAriaSelected() { + let renderedSearchResultItems = this.customSearchItemRendererSlotContainer.children; + if (renderedSearchResultItems) { + for (let index = 0; index < renderedSearchResultItems.length; index++) { + let element = renderedSearchResultItems[index]; + if (element.childNodes[0].getAttribute('aria-selected') === 'true') { + element.childNodes[0].setAttribute('aria-selected', 'false'); + } + } + } + } + + closeSearchResult() { + this.dispatch('closeSearchResult'); + } + + onSearchResultItemSelected(searchResultItem) { + if (this.search && GenericHelpers.isFunction(this.search.searchProvider.onSearchResultItemSelected)) { + this.search.searchProvider.onSearchResultItemSelected(searchResultItem); + } else if (GenericHelpers.isFunction(this.search.searchProvider.onEscape) && event.keyCode === KEYCODE_ESC) { + this.search.searchProvider.onEscape(); + } + } + + handleKeydown(result, { keyCode }, inputElement, customSearchItemRendererSlotContainer) { + this.updateCustomSearchItemRendererSlotContainer(customSearchItemRendererSlotContainer); + if (keyCode === KEYCODE_ENTER) { + this.search.searchProvider.onSearchResultItemSelected(result, this.search); + } + if (keyCode === KEYCODE_ARROW_UP || keyCode === KEYCODE_ARROW_DOWN) { + this.calcSearchResultItemSelected(keyCode); + } else if (GenericHelpers.isFunction(this.search.searchProvider.onEscape) && keyCode === KEYCODE_ESC) { + this.clearAriaSelected(this.customSearchItemRendererSlotContainer); + setTimeout(() => { + this.setFocusOnGlobalSearchFieldDesktop(inputElement); + }); + this.search.searchProvider.onEscape(); + } + } + + setFocusOnGlobalSearchFieldDesktop(inputElement) { + if (inputElement) { + inputElement.focus(); + } + } + + onActionClick(searchResultItem) { + let node = searchResultItem.pathObject; + if (node.externalLink) { + Routing.navigateToLink(node); + } else { + this.dispatch('handleSearchNavigation', { node }); + } + } + + toggleSearch(isSearchFieldVisible, displaySearchResult, inputElem, customSearchItemRendererSlot) { + if (!isSearchFieldVisible) + setTimeout(() => { + this.setFocusOnGlobalSearchFieldDesktop(); + }); + else { + displaySearchResult = false; + } + + this.dispatch('toggleSearch', { + isSearchFieldVisible, + inputElem, + customSearchItemRendererSlot + }); + + if ( + this.search && + this.search.searchProvider && + GenericHelpers.isFunction(this.search.searchProvider.toggleSearch) + ) { + const fieldVisible = isSearchFieldVisible === undefined ? true : !isSearchFieldVisible; + this.search.searchProvider.toggleSearch(inputElem, fieldVisible); + } + } +} diff --git a/core/test/utilities/helpers/global-search-helpers.spec.js b/core/test/utilities/helpers/global-search-helpers.spec.js new file mode 100644 index 0000000000..9fb10b25c7 --- /dev/null +++ b/core/test/utilities/helpers/global-search-helpers.spec.js @@ -0,0 +1,307 @@ +import { GlobalSearchHelperClass } from '../../../src/utilities/helpers'; +import { LuigiI18N } from '../../../src/core-api'; +import { Routing } from '../../../src/services'; +import { KEYCODE_ENTER, KEYCODE_ESC, KEYCODE_ARROW_DOWN, KEYCODE_ARROW_UP } from '../../../src/utilities/keycode'; +const sinon = require('sinon'); +const chai = require('chai'); +const assert = chai.assert; + +describe('Global-search-helpers', () => { + let globalSearchHelpers; + let search; + let dispatch; + let inputElement; + + beforeEach(() => { + inputElement = document.createElement('input'); + search = {}; + dispatch = sinon.spy(); + globalSearchHelpers = new GlobalSearchHelperClass(search, dispatch); + getCurrentLocaleStub = sinon.stub(LuigiI18N, 'getCurrentLocale').returns('en'); + getTranslationStub = sinon.stub(LuigiI18N, 'getTranslation').returns('Digit here text to search....'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('getCustomRenderer', () => { + it('should return if no search provider', () => { + globalSearchHelpers.getCustomRenderer(); + assert.notExists(globalSearchHelpers.customSearchResultRenderer); + assert.notExists(globalSearchHelpers.customSearchResultItemRenderer); + }); + }); + + describe('setSearchPlaceholder', () => { + function setupSearchProvider(inputPlaceholder) { + search = { + searchProvider: { + inputPlaceholder + } + }; + globalSearchHelpers = new GlobalSearchHelperClass(search, dispatch); + globalSearchHelpers.setSearchPlaceholder(inputElement); + } + + it('should set the search input placeholder when it is a string', () => { + setupSearchProvider('Digit here text to search....'); + assert.strictEqual(inputElement.placeholder, 'Digit here text to search....'); + }); + + it('should set the search input placeholder correctly when it is a function', () => { + setupSearchProvider(() => 'Function placeholder text'); + assert.strictEqual(inputElement.placeholder, 'Function placeholder text'); + }); + + it('should set the search input placeholder correctly with translation', () => { + setupSearchProvider({ + en: 'English placeholder text', + fr: 'Texte de remplacement français' + }); + assert.strictEqual(inputElement.placeholder, 'English placeholder text'); + }); + + it('should not set placeholder if searchProvider was not defined', () => { + assert.strictEqual(inputElement.placeholder, ''); + }); + }); + + describe('onKeyUp', () => { + it('should call onEnter when ENTER key is pressed', () => { + const onEnterSpy = sinon.spy(); + search = { + searchProvider: { + onEnter: onEnterSpy + } + }; + globalSearchHelpers = new GlobalSearchHelperClass(search, dispatch); + const event = { keyCode: KEYCODE_ENTER }; + globalSearchHelpers.onKeyUp(event); + assert.isTrue(onEnterSpy.calledOnce, 'onEnter should be called once'); + }); + + it('should call onEscape when ESC key is pressed', () => { + const onEscapeSpy = sinon.spy(); + search = { + searchProvider: { + onEscape: onEscapeSpy + } + }; + globalSearchHelpers = new GlobalSearchHelperClass(search, dispatch); + const event = { keyCode: KEYCODE_ESC }; + globalSearchHelpers.onKeyUp(event); + assert.isTrue(onEscapeSpy.calledOnce, 'onEscape should be called once'); + }); + + it('should call onInput for other keys when onInput is defined', () => { + const onInputSpy = sinon.spy(); + search = { + searchProvider: { + onInput: onInputSpy + } + }; + globalSearchHelpers = new GlobalSearchHelperClass(search, dispatch); + const event = { keyCode: 65 }; // ASCII code for 'A' + globalSearchHelpers.onKeyUp(event); + assert.isTrue(onInputSpy.calledOnce, 'onInput should be called once'); + }); + }); + + describe('onActionClick', () => { + it('should navigate to external link if node.externalLink is defined', () => { + const navigateToLinkStub = sinon.stub(Routing, 'navigateToLink'); + const searchResultItem = { + pathObject: { + externalLink: 'http://example.com' + } + }; + + globalSearchHelpers.onActionClick(searchResultItem); + + assert.isTrue(navigateToLinkStub.calledOnce); + assert.isTrue(navigateToLinkStub.calledWith(searchResultItem.pathObject)); + + navigateToLinkStub.restore(); + }); + + it('should dispatch handleSearchNavigation if node.externalLink is not defined', () => { + const searchResultItem = { + pathObject: { + link: '/some/path' + } + }; + + globalSearchHelpers.onActionClick(searchResultItem); + + assert.isTrue(dispatch.calledOnce); + assert.isTrue(dispatch.calledWith('handleSearchNavigation', { node: searchResultItem.pathObject })); + }); + }); + + describe('handleKeydown', () => { + let search; + let inputElement; + let customSearchItemRendererSlotContainer; + let globalSearchHelpers; + let clock; + + beforeEach(() => { + search = { + searchProvider: { + onSearchResultItemSelected: sinon.spy(), + onEscape: sinon.spy() + } + }; + inputElement = document.createElement('input'); + customSearchItemRendererSlotContainer = document.createElement('div'); + globalSearchHelpers = new GlobalSearchHelperClass(search); + clock = sinon.useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + function assertMethodCalledWithArgs(method, args) { + const methodStub = sinon.stub(globalSearchHelpers, method); + + globalSearchHelpers.handleKeydown(args.result, args.event, inputElement, customSearchItemRendererSlotContainer); + + assert.isTrue(methodStub.calledOnce, `${method} should be called once`); + assert.isTrue(methodStub.calledWith(...args.expectedArgs), `${method} should be called with correct arguments`); + + methodStub.restore(); + } + + it('should call onSearchResultItemSelected when ENTER key is pressed', () => { + const result = {}; + const event = { keyCode: KEYCODE_ENTER }; + + globalSearchHelpers.handleKeydown(result, event, inputElement, customSearchItemRendererSlotContainer); + + assert.isTrue(search.searchProvider.onSearchResultItemSelected.calledOnce); + assert.isTrue(search.searchProvider.onSearchResultItemSelected.calledWith(result, search)); + }); + + it('should call calcSearchResultItemSelected when ARROW_UP key is pressed', () => { + const event = { keyCode: KEYCODE_ARROW_UP }; + assertMethodCalledWithArgs('calcSearchResultItemSelected', { + result: null, + event, + expectedArgs: [KEYCODE_ARROW_UP] + }); + }); + + it('should call calcSearchResultItemSelected when ARROW_DOWN key is pressed', () => { + const event = { keyCode: KEYCODE_ARROW_DOWN }; + assertMethodCalledWithArgs('calcSearchResultItemSelected', { + result: null, + event, + expectedArgs: [KEYCODE_ARROW_DOWN] + }); + }); + + it('should call onEscape and clearAriaSelected when ESC key is pressed', () => { + const event = { keyCode: KEYCODE_ESC }; + + const clearAriaSelectedStub = sinon.stub(globalSearchHelpers, 'clearAriaSelected'); + const setFocusOnGlobalSearchFieldDesktopStub = sinon.stub( + globalSearchHelpers, + 'setFocusOnGlobalSearchFieldDesktop' + ); + + globalSearchHelpers.handleKeydown(null, event, inputElement, customSearchItemRendererSlotContainer); + clock.tick(0); + + assert.isTrue(search.searchProvider.onEscape.calledOnce); + assert.isTrue(clearAriaSelectedStub.calledOnce); + assert.isTrue(setFocusOnGlobalSearchFieldDesktopStub.calledOnce); + + clearAriaSelectedStub.restore(); + setFocusOnGlobalSearchFieldDesktopStub.restore(); + }); + }); + + describe('toggleSearch', () => { + let globalSearchHelpers; + let search; + let dispatchSpy; + let clock; + + beforeEach(() => { + search = { + searchProvider: { + toggleSearch: sinon.spy() + } + }; + dispatchSpy = sinon.spy(); + globalSearchHelpers = new GlobalSearchHelperClass(search, dispatchSpy); + clock = sinon.useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should set focus on the search field if it is not visible', () => { + const isSearchFieldVisible = false; + const displaySearchResult = true; + const inputElem = document.createElement('input'); + const customSearchItemRendererSlot = document.createElement('div'); + + const setFocusSpy = sinon.spy(globalSearchHelpers, 'setFocusOnGlobalSearchFieldDesktop'); + + globalSearchHelpers.toggleSearch( + isSearchFieldVisible, + displaySearchResult, + inputElem, + customSearchItemRendererSlot + ); + + clock.tick(0); + assert.isTrue(setFocusSpy.calledOnce); + setFocusSpy.restore(); + }); + + it('should dispatch toggleSearch with correct parameters', () => { + const isSearchFieldVisible = true; + const displaySearchResult = true; + const inputElem = document.createElement('input'); + const customSearchItemRendererSlot = document.createElement('div'); + + globalSearchHelpers.toggleSearch( + isSearchFieldVisible, + displaySearchResult, + inputElem, + customSearchItemRendererSlot + ); + + assert.isTrue(dispatchSpy.calledOnce); + assert.isTrue( + dispatchSpy.calledWith('toggleSearch', { + isSearchFieldVisible, + inputElem, + customSearchItemRendererSlot + }) + ); + }); + + it('should call searchProvider.toggleSearch with correct parameters', () => { + const isSearchFieldVisible = true; + const displaySearchResult = true; + const inputElem = document.createElement('input'); + const customSearchItemRendererSlot = document.createElement('div'); + + globalSearchHelpers.toggleSearch( + isSearchFieldVisible, + displaySearchResult, + inputElem, + customSearchItemRendererSlot + ); + + assert.isTrue(search.searchProvider.toggleSearch.calledOnce); + assert.isTrue(search.searchProvider.toggleSearch.calledWith(inputElem, !isSearchFieldVisible)); + }); + }); +});