From a6d68571eb863bb6227151bc40a869067aae2d78 Mon Sep 17 00:00:00 2001 From: Kout95 Date: Fri, 14 Jun 2024 14:26:57 +0200 Subject: [PATCH 01/14] 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 efd673d7..f0132b8b 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; + } +} From 7d51c1cadbb4d93807234c4f300f01d5a2f6b87b Mon Sep 17 00:00:00 2001 From: Kout95 Date: Tue, 18 Jun 2024 11:04:05 +0200 Subject: [PATCH 02/14] feat: finish suggestion on search-bar.ts --- frontend/src/mixins/autocomplete.ts | 6 ++--- frontend/src/mixins/search-ctl.ts | 25 ++++++++++++++++++-- frontend/src/search-bar.ts | 7 +++++- frontend/src/search-facets.ts | 36 +++++++++++++++++++++++++---- frontend/src/utils/taxonomies.ts | 4 ++++ 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/frontend/src/mixins/autocomplete.ts b/frontend/src/mixins/autocomplete.ts index 1effa431..04263e9b 100644 --- a/frontend/src/mixins/autocomplete.ts +++ b/frontend/src/mixins/autocomplete.ts @@ -11,10 +11,10 @@ export interface AutocompleteMixinInterface extends DebounceMixinInterface { options: AutocompleteOption[]; value: string; currentIndex: number; + getOptionIndex: number; visible: boolean; isLoading: boolean; currentOption: AutocompleteOption | undefined; - getCurrentIndex: number; onInput(event: InputEvent): void; handleInput(value: string): void; @@ -77,12 +77,12 @@ export const AutocompleteMixin = >( * It remove the offset of 1 because the currentIndex is 1-based. * @returns {number} The current index. */ - get getCurrentIndex() { + get getOptionIndex() { return this.currentIndex - 1; } get currentOption() { - return this.options[this.getCurrentIndex]; + return this.options[this.getOptionIndex]; } /** diff --git a/frontend/src/mixins/search-ctl.ts b/frontend/src/mixins/search-ctl.ts index 3f04a82e..24ec167f 100644 --- a/frontend/src/mixins/search-ctl.ts +++ b/frontend/src/mixins/search-ctl.ts @@ -42,6 +42,7 @@ export interface SearchaliciousSearchInterface search(): Promise; _facetsNodes(): SearchaliciousFacets[]; _facetsFilters(): string; + selectTermByTaxonomy(taxonomy: string, term: string): void; } // name of search params as an array (to ease iteration) @@ -115,6 +116,27 @@ export const SearchaliciousSearchMixin = >( @state() _count?: number; + _facetsParentNode() { + return document.querySelectorAll('searchalicious-facets'); + } + + /** + * Select a term by taxonomy in all facets + * It will update the selected terms in facets + * @param taxonomy + * @param term + */ + selectTermByTaxonomy(taxonomy: string, term: string) { + for (const facets of this._facetsParentNode()) { + // if true, the facets has been updated + if ( + (facets as SearchaliciousFacets).selectTermByTaxonomy(taxonomy, term) + ) { + return; + } + } + } + /** * Wether search should be launched at page load */ @@ -149,8 +171,7 @@ export const SearchaliciousSearchMixin = >( override _facetsNodes = (): SearchaliciousFacets[] => { const allNodes: SearchaliciousFacets[] = []; // search facets elements, we can't filter on search-name because of default value… - const facetsElements = document.querySelectorAll('searchalicious-facets'); - facetsElements.forEach((item) => { + this._facetsParentNode()?.forEach((item) => { const facetElement = item as SearchaliciousFacets; if (facetElement.searchName == this.name) { allNodes.push(facetElement); diff --git a/frontend/src/search-bar.ts b/frontend/src/search-bar.ts index 38955591..3c05374f 100644 --- a/frontend/src/search-bar.ts +++ b/frontend/src/search-bar.ts @@ -4,6 +4,7 @@ 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'; +import {removeLangFromTermId} from './utils/taxonomies'; /** * The search bar element @@ -90,8 +91,12 @@ export class SearchaliciousBar extends AutocompleteMixin( */ override submit(isSuggestion?: boolean) { console.log(this.query, this.value, isSuggestion); + // If the value is a suggestion, select the term and reset the input otherwise search if (isSuggestion) { - // TODO filter by query + this.selectTermByTaxonomy( + this.terms[this.getOptionIndex].taxonomy_name, + removeLangFromTermId(this.terms[this.getOptionIndex].id) + ); this.resetInput(); this.query = ''; } else { diff --git a/frontend/src/search-facets.ts b/frontend/src/search-facets.ts index e6532b66..007dc8ea 100644 --- a/frontend/src/search-facets.ts +++ b/frontend/src/search-facets.ts @@ -5,7 +5,7 @@ import {SearchaliciousResultCtlMixin} from './mixins/search-results-ctl'; import {SearchResultEvent} from './events'; import {DebounceMixin} from './mixins/debounce'; import {SearchaliciousTermsMixin} from './mixins/suggestions-ctl'; -import {getTaxonomyName} from './utils/taxonomies'; +import {getTaxonomyName, removeLangFromTermId} from './utils/taxonomies'; import {SearchActionMixin} from './mixins/search-action'; import {FACET_TERM_OTHER} from './utils/constants'; import {QueryOperator} from './utils/enums'; @@ -73,6 +73,19 @@ export class SearchaliciousFacets extends SearchActionMixin( }); } + getFacetNodeByTaxonomy(taxonomy: string): SearchaliciousFacet | undefined { + return this._facetNodes().find((node) => node.taxonomy === taxonomy); + } + + selectTermByTaxonomy(taxonomy: string, term: string): boolean { + const node = this.getFacetNodeByTaxonomy(taxonomy); + if (node) { + node.setTermSelected(true, term); + return true; + } + return false; + } + /** * Names of facets we need to query, * this is the names of contained facetNodes. @@ -148,6 +161,16 @@ export class SearchaliciousFacet extends LitElement { // eslint-disable-next-line @typescript-eslint/no-explicit-any infos?: FacetInfo; + get taxonomy(): string { + return getTaxonomyName(this.name); + } + + setTermSelected(checked: boolean, name: string) { + throw new Error( + `setTermSelected not implemented: implement in sub class with checked ${checked} and name ${name}` + ); + } + renderFacet() { throw new Error('renderFacet not implemented: implement in sub class'); } @@ -231,10 +254,10 @@ export class SearchaliciousTermsFacet extends SearchActionMixin( /** * Set wether a term is selected or not */ - setTermSelected({detail}: {detail: {checked: boolean; name: string}}) { + override setTermSelected(checked: boolean, name: string) { this.selectedTerms = { ...this.selectedTerms, - ...{[detail.name]: detail.checked}, + ...{[name]: checked}, }; } @@ -311,7 +334,7 @@ export class SearchaliciousTermsFacet extends SearchActionMixin( const options = (this.terms || []).map((term) => { return { - value: term.id.replace(/^en:/, ''), + value: removeLangFromTermId(term.id), label: term.text, }; }); @@ -331,6 +354,9 @@ export class SearchaliciousTermsFacet extends SearchActionMixin( `; } + onCheckboxChange({detail}: {detail: {checked: boolean; name: string}}) { + this.setTermSelected(detail.checked, detail.name); + } /** * Renders a single term @@ -341,7 +367,7 @@ export class SearchaliciousTermsFacet extends SearchActionMixin(