From a0040230933f9b311b1b9c085ccd261a7a081a17 Mon Sep 17 00:00:00 2001 From: Kout95 Date: Fri, 14 Jun 2024 14:26:57 +0200 Subject: [PATCH] feat: update search-bar for autocompletion --- frontend/public/off.html | 2 +- frontend/src/mixins/autocomplete.ts | 222 ++++++++++++++++++++++++++++ frontend/src/search-a-licious.ts | 1 + frontend/src/search-autocomplete.ts | 210 ++++---------------------- frontend/src/search-bar.ts | 140 +++++++++++++++--- frontend/src/search-term-line.ts | 74 ++++++++++ 6 files changed, 444 insertions(+), 205 deletions(-) create mode 100644 frontend/src/mixins/autocomplete.ts create mode 100644 frontend/src/search-term-line.ts diff --git a/frontend/public/off.html b/frontend/public/off.html index 23970e95..07e9d3f1 100644 --- a/frontend/public/off.html +++ b/frontend/public/off.html @@ -153,7 +153,7 @@
  • + ${option.label} +
  • ` + ) + : html`
  • No results found
  • `; } /** - * Handles the input event on the autocomplete and dispatch custom event : "autocomplete-input". - * @param {InputEvent} event - The input event. + * This method is used to handle the input value. + * @param value */ - handleInput(event: InputEvent) { - const value = (event.target as HTMLInputElement).value; - this.value = value; + override handleInput(value: string) { // we don't need a very specific event name // because it will be captured by the parent Facet element const inputEvent = new CustomEvent( @@ -121,33 +90,13 @@ export class SearchaliciousAutocomplete extends DebounceMixin(LitElement) { ); this.dispatchEvent(inputEvent); } - /** - * This method is used to remove focus from the input element. - * It is used to quit after selecting an option. - */ - blurInput() { - const input = this.shadowRoot!.querySelector('input'); - if (input) { - input.blur(); - } - } - - /** - * This method is used to reset the input value and blur it. - * It is used to reset the input after a search. - */ - resetInput() { - this.value = ''; - this.currentIndex = 0; - this.blurInput(); - } /** * This method is used to submit the input value. * It is used to submit the input value after selecting an option. * @param {boolean} isSuggestion - A boolean value to check if the value is a suggestion. */ - submit(isSuggestion = false) { + override submit(isSuggestion = false) { if (!this.value) return; const inputEvent = new CustomEvent( @@ -156,9 +105,7 @@ export class SearchaliciousAutocomplete extends DebounceMixin(LitElement) { // we send both value and label detail: { value: this.value, - label: isSuggestion - ? this.options[this.getCurrentIndex()].label - : undefined, + label: isSuggestion ? this.currentOption!.label : undefined, } as AutocompleteResult, bubbles: true, composed: true, @@ -168,111 +115,6 @@ export class SearchaliciousAutocomplete extends DebounceMixin(LitElement) { this.resetInput(); } - /** - * This method is used to get the autocomplete value by index. - * @param {number} index - The index of the autocomplete value. - * @returns {string} The autocomplete value. - */ - getAutocompleteValueByIndex(index: number) { - return this.options[index].value; - } - - /** - * Handles keyboard event to navigate the suggestion list - * @param {string} direction - The direction of the arrow key event. - */ - handleArrowKey(direction: 'up' | 'down') { - const offset = direction === 'down' ? 1 : -1; - const maxIndex = this.options.length + 1; - this.currentIndex = (this.currentIndex + offset + maxIndex) % maxIndex; - } - - /** - * When Enter is pressed: - * * if an option was selected (using keyboard arrows) it becomes the value - * * otherwise the input string is the value - * We then submit the value. - * @param event - */ - handleEnter(event: KeyboardEvent) { - let isAutoComplete = false; - if (this.currentIndex) { - isAutoComplete = true; - this.value = this.getAutocompleteValueByIndex(this.getCurrentIndex()); - } else { - const value = (event.target as HTMLInputElement).value; - this.value = value; - } - this.submit(isAutoComplete); - } - - /** - * dispatch key events according to the key pressed (arrows or enter) - * @param event - */ - handleKeyDown(event: KeyboardEvent) { - switch (event.key) { - case 'ArrowDown': - this.handleArrowKey('down'); - return; - case 'ArrowUp': - this.handleArrowKey('up'); - return; - case 'Enter': - this.handleEnter(event); - return; - } - } - - /** - * On a click on the autocomplete option, we select it as value and submit it. - * @param index - */ - onClick(index: number) { - return () => { - this.value = this.getAutocompleteValueByIndex(index); - // we need to increment the index because currentIndex is 1-based - this.currentIndex = index + 1; - this.submit(true); - }; - } - - /** - * This method is used to handle the focus event on the input element. - * It is used to show the autocomplete options when the input is focused. - */ - handleFocus() { - this.visible = true; - } - - /** - * This method is used to handle the blur event on the input element. - * It is used to hide the autocomplete options when the input is blurred. - * It is debounced to avoid to quit before select with click. - */ - handleBlur() { - this.debounce(() => { - this.visible = false; - }); - } - - /** - * Renders the possible terms as list for user to select from - * @returns {import('lit').TemplateResult<1>} The HTML template for the possible terms. - */ - _renderPossibleTerms() { - return this.options.length - ? this.options.map( - (option, index) => html`
  • - ${option.label} -
  • ` - ) - : html`
  • No results found
  • `; - } - /** * Renders the search autocomplete: input box and eventual list of possible choices. */ @@ -285,16 +127,16 @@ export class SearchaliciousAutocomplete extends DebounceMixin(LitElement) { name="${this.inputName}" id="${this.inputName}" .value=${this.value} - @input=${this.handleInput} - @keydown=${this.handleKeyDown} + @input=${this.onInput} + @keydown=${this.onKeyDown} autocomplete="off" - @focus=${this.handleFocus} - @blur=${this.handleBlur} + @focus=${this.onFocus} + @blur=${this.onBlur} /> `; diff --git a/frontend/src/search-bar.ts b/frontend/src/search-bar.ts index a90d6105..38955591 100644 --- a/frontend/src/search-bar.ts +++ b/frontend/src/search-bar.ts @@ -1,6 +1,9 @@ import {LitElement, html, css} from 'lit'; import {customElement, property} from 'lit/decorators.js'; import {SearchaliciousSearchMixin} from './mixins/search-ctl'; +import {SearchaliciousTermsMixin} from './mixins/suggestions-ctl'; +import {AutocompleteMixin} from './mixins/autocomplete'; +import {classMap} from 'lit/directives/class-map.js'; /** * The search bar element @@ -9,43 +12,140 @@ import {SearchaliciousSearchMixin} from './mixins/search-ctl'; * and it also manage all the search thanks to SearchaliciousSearchMixin inheritance. */ @customElement('searchalicious-bar') -export class SearchaliciousBar extends SearchaliciousSearchMixin(LitElement) { +export class SearchaliciousBar extends AutocompleteMixin( + SearchaliciousTermsMixin(SearchaliciousSearchMixin(LitElement)) +) { static override styles = css` :host { display: block; padding: 5px; } + + .search-bar { + position: relative; + } + + .search-bar ul { + --left-offset: 8px; + position: absolute; + left: var(--left-offset); + background-color: LightYellow; + border: 1px solid #ccc; + width: 100%; + width: calc(100% - var(--left-offset) - 1px); + z-index: 1000; + list-style: none; + padding: 0; + margin: 0; + } + + ul li { + cursor: pointer; + } + + ul li:hover, + ul li.selected { + background-color: var( + --searchalicious-autocomplete-selected-background-color, + #cfac9e + ); + } `; + /** + * The selected taxonomies + */ + @property({type: String, attribute: 'taxonomies'}) + taxonomies = ''; + /** * Place holder in search bar */ @property() placeholder = 'Search...'; - override render() { - return html` - - `; + get parsedTaxonomies() { + return this.taxonomies.split(','); + } + + /** + * Handle the input event + * It will update the query and call the getTaxonomiesTerms method to show suggestions + * @param value + */ + override handleInput(value: string) { + this.query = value; + this.debounce(() => { + this.getTaxonomiesTerms(value, this.parsedTaxonomies).then(() => { + this.options = this.terms.map((term) => ({ + value: term.text, + label: term.text, + })); + }); + }); } - private _onQueryChange(event: Event) { - this.query = (event.target as HTMLInputElement).value; + /** + * Submit the search + */ + override submit(isSuggestion?: boolean) { + console.log(this.query, this.value, isSuggestion); + if (isSuggestion) { + // TODO filter by query + this.resetInput(); + this.query = ''; + } else { + this.query = this.value; + this.blurInput(); + } + this.search(); } - private _onKeyUp(event: Event) { - const kbd_event = event as KeyboardEvent; - if (kbd_event.key === 'Enter') { - // launch search - this.search(); + + /** + * Render the suggestions when the input is focused and the value is not empty + */ + renderSuggestions() { + // Don't show suggestions if the input is not focused or the value is empty or there are no suggestions + if (!this.visible || !this.value || this.terms.length === 0) { + return html``; } + + return html` + + `; + } + + override render() { + return html` + + `; } } diff --git a/frontend/src/search-term-line.ts b/frontend/src/search-term-line.ts new file mode 100644 index 00000000..56b7766c --- /dev/null +++ b/frontend/src/search-term-line.ts @@ -0,0 +1,74 @@ +import {css, html, LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +@customElement('searchalicious-term-line') +export class SearchaliciousTermLine extends LitElement { + static override styles = css` + .term-line { + display: flex; + align-items: center; + padding: 0.5rem 1rem; + box-sizing: border-box; + overflow: hidden; + max-width: 100%; + + --img-size: 2rem; + } + + .term-line-img-wrapper { + width: 2rem; + height: 2rem; + overflow: hidden; + } + + .term-line-text-wrapper { + --margin-left: 1rem; + margin-left: var(--margin-left); + width: calc(100% - var(--img-size) - var(--margin-left)); + } + + .term-line-img-wrapper img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + background-color: var(--img-background-color, #d9d9d9); + } + + .term-line-text { + font-weight: bold; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + overflow: hidden; + } + `; + + @property({type: Object, attribute: 'term'}) + term?: { + imageUrl?: string; + id: string; + text: string; + taxonomy_name: string; + }; + + override render() { + return html` +
    +
    + +
    +
    +
    ${this.term?.text}
    +
    ${this.term?.taxonomy_name}
    +
    +
    + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'searchalicious-term-line': SearchaliciousTermLine; + } +}