diff --git a/src/lib/components/SearchWrapper.js b/src/lib/components/SearchWrapper.js index 217c645..43720db 100644 --- a/src/lib/components/SearchWrapper.js +++ b/src/lib/components/SearchWrapper.js @@ -13,6 +13,10 @@ export class SearchWrapper extends BaseComponent { #arePropertiesSuggestionsEnabled = false; /** @type {"start"|"end"} */ #propertiesSuggestionsPosition = "start"; + /** @type {HTMLElement|null} */ + #cachedAutocompleteContainer = null; + /** @type {TermToken|QuotedTermToken|null} */ + #lastTermToken = null; build() { this.#searchField = this.container.querySelector('input[name=q]'); @@ -94,6 +98,7 @@ export class SearchWrapper extends BaseComponent { let searchValue = this.#searchField.value; if (!searchValue) { + this.#lastTermToken = null; return null; } @@ -103,16 +108,37 @@ export class SearchWrapper extends BaseComponent { ); if (token instanceof TermToken) { + this.#lastTermToken = token; return token.value; } if (token instanceof QuotedTermToken) { + this.#lastTermToken = token; return token.decodedValue; } + this.#lastTermToken = null; return searchValue; } + /** + * Resolve the autocomplete container from the document. Once resolved, it can be safely reused without breaking + * anything. Assuming refactored autocomplete handler is still implemented the way it is. + * + * This means, that properties will only be suggested once actual autocomplete logic was activated. + * + * @return {HTMLElement|null} Resolved element or nothing. + */ + #resolveAutocompleteContainer() { + if (this.#cachedAutocompleteContainer) { + return this.#cachedAutocompleteContainer; + } + + this.#cachedAutocompleteContainer = document.querySelector('.autocomplete'); + + return this.#cachedAutocompleteContainer; + } + /** * Render the list of suggestions into the existing popup or create and populate a new one. * @param {string[]} suggestions List of suggestion to render the popup from. @@ -121,12 +147,24 @@ export class SearchWrapper extends BaseComponent { #renderSuggestions(suggestions, targetInput) { /** @type {HTMLElement[]} */ const suggestedListItems = suggestions - .map(suggestedTerm => SearchWrapper.#renderTermSuggestion(suggestedTerm)); + .map(suggestedTerm => this.#renderTermSuggestion(suggestedTerm)); requestAnimationFrame(() => { - const autocompleteContainer = document.querySelector('.autocomplete') ?? SearchWrapper.#renderAutocompleteContainer(); + const autocompleteContainer = this.#resolveAutocompleteContainer(); - for (let existingTerm of autocompleteContainer.querySelectorAll('.autocomplete__item--property')) { + if (!autocompleteContainer) { + return; + } + + // Since the autocomplete popup was refactored to re-use the same element over and over again, we need to remove + // the options from the popup manually when autocomplete was removed from the DOM, since site is not doing that. + const termsToRemove = autocompleteContainer.isConnected + // Only removing properties when element is still connected to the DOM (popup is used by the website) + ? autocompleteContainer.querySelectorAll('.autocomplete__item--property') + // Remove everything if popup was disconnected from the DOM. + : autocompleteContainer.querySelectorAll('.autocomplete__item') + + for (let existingTerm of termsToRemove) { existingTerm.remove(); } @@ -239,29 +277,12 @@ export class SearchWrapper extends BaseComponent { return suggestionsList; } - /** - * Render a new autocomplete container similar to the one generated by website. Might be sensitive to the updates - * made to the Philomena. - * @return {HTMLElement} - */ - static #renderAutocompleteContainer() { - const autocompleteContainer = document.createElement('div'); - autocompleteContainer.className = 'autocomplete'; - - const innerListContainer = document.createElement('ul'); - innerListContainer.className = 'autocomplete__list'; - - autocompleteContainer.append(innerListContainer); - - return autocompleteContainer; - } - /** * Render a single suggestion item and connect required events to interact with the user. * @param {string} suggestedTerm Term to use for suggestion item. * @return {HTMLElement} Resulting element. */ - static #renderTermSuggestion(suggestedTerm) { + #renderTermSuggestion(suggestedTerm) { /** @type {HTMLElement} */ const suggestionItem = document.createElement('li'); suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property'); @@ -269,17 +290,43 @@ export class SearchWrapper extends BaseComponent { suggestionItem.innerText = suggestedTerm; suggestionItem.addEventListener('mouseover', () => { - this.#findAndResetSelectedSuggestion(suggestionItem); + SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem); suggestionItem.classList.add('autocomplete__item--selected'); }); suggestionItem.addEventListener('mouseout', () => { - this.#findAndResetSelectedSuggestion(suggestionItem); - }) + SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem); + }); + + suggestionItem.addEventListener('click', () => { + this.#replaceLastActiveTokenWithSuggestion(suggestedTerm); + }); return suggestionItem; } + /** + * Automatically replace the last active token stored in the variable with the new value. + * @param {string} suggestedTerm Term to replace the value with. + */ + #replaceLastActiveTokenWithSuggestion(suggestedTerm) { + if (!this.#lastTermToken) { + return; + } + + const searchQuery = this.#searchField.value; + const beforeToken = searchQuery.substring(0, this.#lastTermToken.index); + const afterToken = searchQuery.substring(this.#lastTermToken.index + this.#lastTermToken.value.length); + + let replacementValue = suggestedTerm; + + if (replacementValue.includes('"')) { + replacementValue = `"${QuotedTermToken.encode(replacementValue)}"` + } + + this.#searchField.value = beforeToken + replacementValue + afterToken; + } + /** * Find the selected suggestion item(s) and unselect them. Similar to the logic implemented by the Philomena's * front-end.