diff --git a/frontend/public/off.html b/frontend/public/off.html index 817287e6..23970e95 100644 --- a/frontend/public/off.html +++ b/frontend/public/off.html @@ -252,8 +252,8 @@
- - + + diff --git a/frontend/src/button-transparent.ts b/frontend/src/button-transparent.ts new file mode 100644 index 00000000..ea7c1300 --- /dev/null +++ b/frontend/src/button-transparent.ts @@ -0,0 +1,71 @@ +import {LitElement, html, css} from 'lit'; +import {customElement} from 'lit/decorators.js'; + +import {BasicEvents} from './utils/enums'; + +/** + * A custom element that represents a button without background for a search. + * It sends a custom event "click" when clicked. + * It exists to have already styled button for secondary actions. + * You can modify this variable to customize the button style : + * --button-transparent-padding + * --secondary-hover-color + * @extends {LitElement} + * @slot - This slot is for the button contents, default to "Search" string. + */ +@customElement('searchalicious-button-transparent') +export class SearchaliciousButtonTransparent extends LitElement { + static override styles = css` + .button-transparent { + background-color: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 3.5rem; + cursor: pointer; + padding: var(--button-transparent-padding, 0.25rem 0.5rem); + } + .button-transparent:hover { + background-color: var(--secondary-hover-color, #cfac9e); + } + `; + + private _onClick() { + this._dispatchEvent(); + } + + private _onKeyUp(event: Event) { + const kbd_event = event as KeyboardEvent; + if (kbd_event.key === 'Enter') { + this._dispatchEvent(); + } + } + + private _dispatchEvent() { + this.dispatchEvent( + new CustomEvent(BasicEvents.CLICK, {bubbles: true, composed: true}) + ); + } + + override render() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'searchalicious-button-transparent': SearchaliciousButtonTransparent; + } +} diff --git a/frontend/src/enums.ts b/frontend/src/enums.ts deleted file mode 100644 index cc559eb8..00000000 --- a/frontend/src/enums.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * This file defines constants as Enums to be used in the library - */ -export enum SearchaliciousEvents { - LAUNCH_SEARCH = 'searchalicious-search', - NEW_RESULT = 'searchalicious-result', - CHANGE_PAGE = 'searchalicious-change-page', -} diff --git a/frontend/src/icons/cross.ts b/frontend/src/icons/cross.ts new file mode 100644 index 00000000..4168d850 --- /dev/null +++ b/frontend/src/icons/cross.ts @@ -0,0 +1,51 @@ +import {LitElement, html, css} from 'lit'; +import {customElement} from 'lit/decorators.js'; + +/** + * A custom element that represents a cross icon. + * You can modify this variable to customize the icon style : + * --icon-width by default 0.8rem + * --icon-stroke-color by default black + */ +@customElement('searchalicious-icon-cross') +export class SearchaliciousIconCross extends LitElement { + static override styles = css` + svg { + width: var(--icon-width, 0.8rem); + } + svg line { + stroke: var(--icon-stroke-color, black); + } + `; + override render() { + return html` + + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'searchalicious-icon-cross': SearchaliciousIconCross; + } +} diff --git a/frontend/src/mixins/debounce.ts b/frontend/src/mixins/debounce.ts new file mode 100644 index 00000000..9d217115 --- /dev/null +++ b/frontend/src/mixins/debounce.ts @@ -0,0 +1,42 @@ +import {LitElement} from 'lit'; +import {Constructor} from './utils'; + +/** + * Interface for the DebounceMixin. + * It defines the structure that DebounceMixin should adhere to. + */ +export interface DebounceMixinInterface { + timeout?: number; + debounce void>(func: F, wait?: number): void; +} + +/** + * A mixin class for debouncing function calls. + * It extends the LitElement class and adds debouncing functionality. + * It is used to prevent a function from being called multiple times in a short period of time. + * It is usefull to avoid multiple calls to a function when the user is typing in an input field. + * @param {Constructor} superClass - The superclass to extend from. + * @returns {Constructor & T} - The extended class with debouncing functionality. + */ +export const DebounceMixin = >( + superClass: T +): Constructor & T => + class extends superClass { + timeout?: number = undefined; + + /** + * Debounces a function call. + * It delays the execution of the function until after wait milliseconds have elapsed since the last time this function was invoked. + * @param {Function} func - The function to debounce. + * @param {number} wait - The number of milliseconds to delay. + */ + debounce void>(func: F, wait = 300): void { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + clearTimeout(this.timeout); + this.timeout = setTimeout(() => { + this.timeout = undefined; + func.bind(self)(); + }, wait); + } + } as Constructor & T; diff --git a/frontend/src/mixins/search-action.ts b/frontend/src/mixins/search-action.ts new file mode 100644 index 00000000..44437c5f --- /dev/null +++ b/frontend/src/mixins/search-action.ts @@ -0,0 +1,43 @@ +import {LitElement} from 'lit'; +import {Constructor} from './utils'; +import {BaseSearchDetail, LaunchSearchEvent} from '../events'; +import {SearchaliciousEvents} from '../utils/enums'; +import {property} from 'lit/decorators.js'; + +export interface SearchActionMixinInterface { + searchName: string; + _launchSearch(): Promise; +} + +/** + * A mixin class for search actions. + * It extends the LitElement class and adds search functionality. + * It is used to launch a search event. + * @param {Constructor} superClass - The superclass to extend from. + * @returns {Constructor & T} - The extended class with search functionality. + */ +export const SearchActionMixin = >( + superClass: T +): Constructor & T => { + class SearchActionMixinClass extends superClass { + @property({attribute: 'search-name'}) + searchName = 'searchalicious'; + + /** + * Launches a search event. + * It creates a new event with the search name and dispatches it. + */ + _launchSearch() { + const detail: BaseSearchDetail = {searchName: this.searchName}; + // fire the search event + const event = new CustomEvent(SearchaliciousEvents.LAUNCH_SEARCH, { + bubbles: true, + composed: true, + detail: detail, + }) as LaunchSearchEvent; + this.dispatchEvent(event); + } + } + + return SearchActionMixinClass as Constructor & T; +}; diff --git a/frontend/src/search-ctl.ts b/frontend/src/mixins/search-ctl.ts similarity index 94% rename from frontend/src/search-ctl.ts rename to frontend/src/mixins/search-ctl.ts index 34488e0a..6a87d1bc 100644 --- a/frontend/src/search-ctl.ts +++ b/frontend/src/mixins/search-ctl.ts @@ -3,19 +3,16 @@ import {property, state} from 'lit/decorators.js'; import { EventRegistrationInterface, EventRegistrationMixin, -} from './event-listener-setup'; -import {SearchaliciousEvents} from './enums'; +} from '../event-listener-setup'; +import {SearchaliciousEvents} from '../utils/enums'; import { ChangePageEvent, LaunchSearchEvent, SearchResultEvent, SearchResultDetail, -} from './events'; -import {SearchaliciousFacets} from './search-facets'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Constructor = new (...args: any[]) => T; - +} from '../events'; +import {SearchaliciousFacets} from '../search-facets'; +import {Constructor} from './utils'; export interface SearchaliciousSearchInterface extends EventRegistrationInterface { query: string; @@ -176,14 +173,18 @@ export const SearchaliciousSearchMixin = >( ) .sort() // for perdictability in tests ! .join('&'); + return `${baseUrl}/search?${queryStr}`; } // connect to our specific events override connectedCallback() { super.connectedCallback(); - this.addEventHandler(SearchaliciousEvents.LAUNCH_SEARCH, (event: Event) => - this._handleSearch(event) + this.addEventHandler( + SearchaliciousEvents.LAUNCH_SEARCH, + (event: Event) => { + this._handleSearch(event); + } ); this.addEventHandler(SearchaliciousEvents.CHANGE_PAGE, (event) => this._handleChangePage(event) diff --git a/frontend/src/search-results-ctl.ts b/frontend/src/mixins/search-results-ctl.ts similarity index 90% rename from frontend/src/search-results-ctl.ts rename to frontend/src/mixins/search-results-ctl.ts index 3ff217d2..5a11b2eb 100644 --- a/frontend/src/search-results-ctl.ts +++ b/frontend/src/mixins/search-results-ctl.ts @@ -4,12 +4,10 @@ import {property, state} from 'lit/decorators.js'; import { EventRegistrationInterface, EventRegistrationMixin, -} from './event-listener-setup'; -import {SearchaliciousEvents} from './enums'; -import {SearchResultEvent} from './events'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Constructor = new (...args: any[]) => T; +} from '../event-listener-setup'; +import {SearchaliciousEvents} from '../utils/enums'; +import {SearchResultEvent} from '../events'; +import {Constructor} from './utils'; export interface SearchaliciousResultsCtlInterface extends EventRegistrationInterface { diff --git a/frontend/src/mixins/suggestions-ctl.ts b/frontend/src/mixins/suggestions-ctl.ts new file mode 100644 index 00000000..42e0fe23 --- /dev/null +++ b/frontend/src/mixins/suggestions-ctl.ts @@ -0,0 +1,128 @@ +import {Constructor} from './utils'; +import {LitElement} from 'lit'; +import {property, state} from 'lit/decorators.js'; + +/** + * Type for term options. + */ +export type TermOption = { + id: string; + text: string; + taxonomy_name: string; +}; + +/** + * Type for taxonomies terms response. + */ +export type TaxomiesTermsResponse = { + options: TermOption[]; +}; + +/** + * Interface for the SearchaliciousTaxonomies. + */ +export interface SearchaliciousTaxonomiesInterface { + termsByTaxonomyId: Record; + loadingByTaxonomyId: Record; + taxonomiesBaseUrl: string; + langs: string; + + /** + * Method to get taxonomies terms. + * @param {string} q - The query string. + * @param {string[]} taxonomyNames - The taxonomy names. + * @returns {Promise} - The promise of taxonomies terms response. + */ + getTaxonomiesTerms( + q: string, + taxonomyNames: string[] + ): Promise; +} + +/** + * A mixin class for Searchalicious terms. + * It allows to get taxonomies terms and store them in termsByTaxonomyId. + * @param {Constructor} superClass - The superclass to extend from. + * @returns {Constructor & T} - The extended class with Searchalicious terms functionality. + */ +export const SearchaliciousTermsMixin = >( + superClass: T +): Constructor & T => { + class SearchaliciousTermsMixinClass extends superClass { + // this olds terms corresponding to current input for each taxonomy + @state() + termsByTaxonomyId: Record = {}; + + @state() + loadingByTaxonomyId = {} as Record; + + @property({attribute: 'base-url'}) + taxonomiesBaseUrl = '/'; + + @property() + langs = 'en'; + + /** + * build URL to search taxonomies terms from input + * @param {string} q - The query string. + * @param {string[]} taxonomyNames - The taxonomy names. + * @returns {string} - The terms URL. + */ + _termsUrl(q: string, taxonomyNames: string[]) { + const baseUrl = this.taxonomiesBaseUrl.replace(/\/+$/, ''); + return `${baseUrl}/autocomplete?q=${q}&lang=${this.langs}&taxonomy_names=${taxonomyNames}&size=5`; + } + + /** + * Method to set loading state by taxonomy names. + * We support more than one taxonomy at once, + * as suggest requests can target multiple taxonomies at once + * @param {string[]} taxonomyNames - The taxonomy names. + * @param {boolean} isLoading - The loading state. + */ + _setIsLoadingByTaxonomyNames(taxonomyNames: string[], isLoading: boolean) { + taxonomyNames.forEach((taxonomyName) => { + this.loadingByTaxonomyId[taxonomyName] = isLoading; + }); + } + + /** + * Method to get taxonomies terms. + * @param {string} q - The query string. + * @param {string[]} taxonomyNames - The taxonomy names. + * @returns {Promise} - The promise of taxonomies terms response. + */ + getTaxonomiesTerms( + q: string, + taxonomyNames: string[] + ): Promise { + this._setIsLoadingByTaxonomyNames(taxonomyNames, true); + return fetch(this._termsUrl(q, taxonomyNames), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then((response) => { + this._setIsLoadingByTaxonomyNames(taxonomyNames, false); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.json() as Promise; + }) + .then((response) => { + this.termsByTaxonomyId = response.options.reduce((acc, option) => { + if (!acc[option.taxonomy_name]) { + acc[option.taxonomy_name] = []; + } + acc[option.taxonomy_name].push(option); + return acc; + }, {} as Record); + return response; + }); + } + } + return SearchaliciousTermsMixinClass as Constructor & + T; +}; diff --git a/frontend/src/mixins/utils.ts b/frontend/src/mixins/utils.ts new file mode 100644 index 00000000..11489490 --- /dev/null +++ b/frontend/src/mixins/utils.ts @@ -0,0 +1,7 @@ +/** + * This mixin represents a mixin type + * + * We need it for every mixin we declare. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Constructor = new (...args: any[]) => T; diff --git a/frontend/src/search-a-licious.ts b/frontend/src/search-a-licious.ts index bc9cdc46..580a1494 100644 --- a/frontend/src/search-a-licious.ts +++ b/frontend/src/search-a-licious.ts @@ -1,5 +1,10 @@ +export {SearchaliciousCheckbox} from './search-checkbox'; export {SearchaliciousBar} from './search-bar'; export {SearchaliciousButton} from './search-button'; export {SearchaliciousPages} from './search-pages'; export {SearchaliciousFacets} from './search-facets'; export {SearchaliciousResults} from './search-results'; +export {SearchaliciousAutocomplete} from './search-autocomplete'; +export {SearchaliciousSecondaryButton} from './secondary-button'; +export {SearchaliciousButtonTransparent} from './button-transparent'; +export {SearchaliciousIconCross} from './icons/cross'; diff --git a/frontend/src/search-autocomplete.ts b/frontend/src/search-autocomplete.ts new file mode 100644 index 00000000..2e04cb9d --- /dev/null +++ b/frontend/src/search-autocomplete.ts @@ -0,0 +1,308 @@ +import {LitElement, html, css} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; +import {DebounceMixin} from './mixins/debounce'; +import {classMap} from 'lit/directives/class-map.js'; +import {SearchaliciousEvents} from './utils/enums'; +/** + * Type for autocomplete option. + */ +export type AutocompleteOption = { + value: string; + label: string; +}; +/** + * Type for autocomplete result. + */ +export type AutocompleteResult = { + value: string; + label?: string; +}; + +/** + * Search autocomplete that can be used in facets to add terms that are not yet displayed (because they haven't enough elements). + * It supports adding terms from suggested options but also terms that are not suggested. + * Options are provided by the parent facet component that listen to `autocomplete-input` events to get input and fetch options accordingly + * As a value is selected, an `autocomplete-submit` event is emitted so that parent facet can add the new value. + * @extends {LitElement} + * @slot - This slot is for the button contents, default to "Search" string. + */ +@customElement('searchalicious-autocomplete') +export class SearchaliciousAutocomplete extends DebounceMixin(LitElement) { + static override styles = css` + .search-autocomplete { + position: relative; + display: inline-block; + } + .search-autocomplete input { + width: 100%; + box-sizing: border-box; + } + + ul { + display: none; + position: absolute; + width: 100%; + max-width: 100%; + background-color: white; + border: 1px solid black; + list-style-type: none; + padding: 0; + margin: 0; + } + + ul li { + padding: 0.5em; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + } + + ul li:hover, + ul li.selected { + background-color: var( + --searchalicious-autocomplete-selected-background-color, + #cfac9e + ); + } + + ul.visible { + display: block; + } + `; + + @property({attribute: 'input-name'}) + inputName = 'autocomplete'; + + /** + * The options for the autocomplete. + * It is provided by the parent component. + */ + @property({attribute: false, type: Array}) + options: AutocompleteOption[] = []; + + // selected values + @property() + value = ''; + + @property({attribute: false}) + currentIndex = 0; + + @property({attribute: false}) + visible = false; + + @property({attribute: false}) + isLoading = false; + + /** + * This method is used to get the current index. + * It remove the offset of 1 because the currentIndex is 1-based. + * @returns {number} The current index. + */ + getCurrentIndex() { + return this.currentIndex - 1; + } + + /** + * Handles the input event on the autocomplete and dispatch custom event : "autocomplete-input". + * @param {InputEvent} event - The input event. + */ + handleInput(event: InputEvent) { + const value = (event.target as HTMLInputElement).value; + this.value = value; + // we don't need a very specific event name + // because it will be captured by the parent Facet element + const inputEvent = new CustomEvent( + SearchaliciousEvents.AUTOCOMPLETE_INPUT, + { + detail: {value: value}, + bubbles: true, + composed: true, + } + ); + 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) { + if (!this.value) return; + + const inputEvent = new CustomEvent( + SearchaliciousEvents.AUTOCOMPLETE_SUBMIT, + { + // we send both value and label + detail: { + value: this.value, + label: isSuggestion + ? this.options[this.getCurrentIndex()].label + : undefined, + } as AutocompleteResult, + bubbles: true, + composed: true, + } + ); + this.dispatchEvent(inputEvent); + 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. + */ + override render() { + return html` + + +
      + ${this.isLoading + ? html`
    • Loading...
    • ` + : this._renderPossibleTerms()} +
    +
    + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'searchalicious-autocomplete': SearchaliciousAutocomplete; + } +} diff --git a/frontend/src/search-bar.ts b/frontend/src/search-bar.ts index 7b715cd8..a90d6105 100644 --- a/frontend/src/search-bar.ts +++ b/frontend/src/search-bar.ts @@ -1,6 +1,6 @@ import {LitElement, html, css} from 'lit'; import {customElement, property} from 'lit/decorators.js'; -import {SearchaliciousSearchMixin} from './search-ctl'; +import {SearchaliciousSearchMixin} from './mixins/search-ctl'; /** * The search bar element diff --git a/frontend/src/search-button.ts b/frontend/src/search-button.ts index 770a4de3..814b7783 100644 --- a/frontend/src/search-button.ts +++ b/frontend/src/search-button.ts @@ -1,7 +1,6 @@ import {LitElement, html} from 'lit'; -import {customElement, property} from 'lit/decorators.js'; -import {BaseSearchDetail, LaunchSearchEvent} from './events'; -import {SearchaliciousEvents} from './enums'; +import {customElement} from 'lit/decorators.js'; +import {SearchActionMixin} from './mixins/search-action'; /** * An optional search button element that launch the search. @@ -9,13 +8,11 @@ import {SearchaliciousEvents} from './enums'; * @slot - goes in button contents, default to "Search" string */ @customElement('searchalicious-button') -export class SearchaliciousButton extends LitElement { +export class SearchaliciousButton extends SearchActionMixin(LitElement) { /** * the search we should trigger, * this corresponds to `name` attribute of corresponding search-bar */ - @property({attribute: 'search-name'}) - searchName = 'searchalicious'; override render() { return html` @@ -33,17 +30,6 @@ export class SearchaliciousButton extends LitElement { /** * Launch search by emitting the LAUNCH_SEARCH signal */ - _launchSearch() { - const detail: BaseSearchDetail = {searchName: this.searchName}; - // fire the search event - const event = new CustomEvent(SearchaliciousEvents.LAUNCH_SEARCH, { - bubbles: true, - composed: true, - detail: detail, - }) as LaunchSearchEvent; - this.dispatchEvent(event); - } - private _onClick() { this._launchSearch(); } diff --git a/frontend/src/search-checkbox.ts b/frontend/src/search-checkbox.ts new file mode 100644 index 00000000..db6bf4ad --- /dev/null +++ b/frontend/src/search-checkbox.ts @@ -0,0 +1,86 @@ +import {LitElement, html, PropertyValues} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +/** + * A custom element that represents a checkbox. + * + * This component is useful to have state of variable reflected back in the checkbox, + * overriding updated method. + * @extends {LitElement} + */ +@customElement('searchalicious-checkbox') +export class SearchaliciousCheckbox extends LitElement { + /** + * Represents the checked state of the checkbox. + * @type {boolean} + */ + @property({type: Boolean}) + checked = false; + + /** + * Represents the name of the checkbox. + * @type {string} + */ + @property({type: String}) + name = ''; + + /** + * Refreshes the checkbox to reflect the current state of the `checked` property. + */ + refreshCheckbox() { + const inputElement = this.shadowRoot?.querySelector('input'); + if (inputElement) { + inputElement.checked = this.checked; + } + } + + /** + * Called when the element’s DOM has been updated and rendered. + * @param {PropertyValues} _changedProperties - The changed properties. + */ + protected override updated(_changedProperties: PropertyValues) { + this.refreshCheckbox(); + super.updated(_changedProperties); + } + + /** + * Renders the checkbox. + * @returns {import('lit').TemplateResult<1>} - The HTML template for the checkbox. + */ + override render() { + return html` + + `; + } + + /** + * Handles the change event on the checkbox. + * @param {Event} e - The change event. + */ + _handleChange(e: {target: HTMLInputElement}) { + this.checked = e.target.checked; + const inputEvent = new CustomEvent('change', { + detail: {checked: this.checked, name: this.name}, + bubbles: true, + composed: true, + }); + this.dispatchEvent(inputEvent); + } +} + +declare global { + /** + * The HTMLElementTagNameMap interface represents a map of custom element tag names to custom element constructors. + * Here, it's extended to include 'searchalicious-checkbox' as a valid custom element tag name. + */ + interface HTMLElementTagNameMap { + 'searchalicious-checkbox': SearchaliciousCheckbox; + } +} diff --git a/frontend/src/search-facets.ts b/frontend/src/search-facets.ts index a3c96eb2..73833ba9 100644 --- a/frontend/src/search-facets.ts +++ b/frontend/src/search-facets.ts @@ -1,9 +1,13 @@ import {LitElement, html, nothing, css} from 'lit'; import {customElement, property, queryAssignedNodes} from 'lit/decorators.js'; import {repeat} from 'lit/directives/repeat.js'; - -import {SearchaliciousResultCtlMixin} from './search-results-ctl'; +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 {SearchActionMixin} from './mixins/search-action'; +import {FACET_TERM_OTHER} from './utils/constants'; interface FacetsInfos { [key: string]: FacetInfo; @@ -38,9 +42,16 @@ function stringGuard(s: string | undefined): s is string { * It must contains a SearchaliciousFacet component for each facet we want to display. */ @customElement('searchalicious-facets') -export class SearchaliciousFacets extends SearchaliciousResultCtlMixin( - LitElement +export class SearchaliciousFacets extends SearchActionMixin( + SearchaliciousResultCtlMixin(LitElement) ) { + static override styles = css` + .reset-button-wrapper { + display: flex; + align-items: center; + justify-content: center; + } + `; // the last search facets @property({attribute: false}) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -85,11 +96,25 @@ export class SearchaliciousFacets extends SearchaliciousResultCtlMixin( } } + reset = () => { + this._facetNodes().forEach((node) => { + node.reset(false); + }); + this._launchSearch(); + }; + override render() { // we always want to render slot, baceauso we use queryAssignedNodes // but we may not want to display them const display = this.facets ? '' : 'display: none'; - return html`
    `; + return html`
    + +
    + Reset filters +
    +
    `; } } @@ -118,6 +143,12 @@ export class SearchaliciousFacet extends LitElement { throw new Error('renderFacet not implemented: implement in sub class'); } + reset = (submit?: boolean): void => { + throw new Error( + `reset not implemented: implement in sub class with submit ${submit}` + ); + }; + override render() { if (this.infos) { return this.renderFacet(); @@ -131,34 +162,78 @@ export class SearchaliciousFacet extends LitElement { * This is a "terms" facet, this must be within a searchalicious-facets element */ @customElement('searchalicious-facet-terms') -export class SearchaliciousTermsFacet extends SearchaliciousFacet { +export class SearchaliciousTermsFacet extends SearchActionMixin( + SearchaliciousTermsMixin(DebounceMixin(SearchaliciousFacet)) +) { static override styles = css` + fieldset { + margin-top: 1rem; + } .term-wrapper { display: block; } + .button { + margin-left: auto; + margin-right: auto; + } + .legend-wrapper { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + max-width: 100%; + } + [part='button-transparent'] { + --button-transparent-padding: 0.5rem 1rem; + } `; - @property({attribute: false}) + @property({ + attribute: false, + type: Object, + }) selectedTerms: PresenceInfo = {}; + // Will be usefull if we want to display term without searching + @property({attribute: false, type: Array}) + autocompleteTerms: string[] = []; + + @property({attribute: 'search-name'}) + override searchName = 'off'; + + @property({attribute: 'show-other', type: Boolean}) + showOther = false; + + _launchSearchWithDebounce = () => + this.debounce(() => { + this._launchSearch(); + }); /** * Set wether a term is selected or not */ - setTermSelected(e: Event) { - const element = e.target as HTMLInputElement; - const name = element.name; - if (element.checked) { - this.selectedTerms[name] = true; - } else { - delete this.selectedTerms[name]; - } + setTermSelected({detail}: {detail: {checked: boolean; name: string}}) { + this.selectedTerms = { + ...this.selectedTerms, + ...{[detail.name]: detail.checked}, + }; + } + + addTerm(event: CustomEvent) { + const value = event.detail.value; + if (this.autocompleteTerms.includes(value)) return; + this.autocompleteTerms = [...this.autocompleteTerms, value]; + this.selectedTerms[value] = true; + // Launch search so that filters will be automatically refreshed + this._launchSearchWithDebounce(); } /** * Create the search term based upon the selected terms */ override searchFilter(): string | undefined { - let values = Object.keys(this.selectedTerms); + let values = Object.keys(this.selectedTerms).filter( + (key) => this.selectedTerms[key] + ); // add quotes if we have ":" in values values = values.map((value) => value.includes(':') ? `"${value}"` : value @@ -173,18 +248,70 @@ export class SearchaliciousTermsFacet extends SearchaliciousFacet { return `${this.name}:${orValues}`; } + /** + * Handle the autocomplete-input event on the add term input + * get the terms for the taxonomy + * @param event + * @param taxonomy + */ + onInputAddTerm(event: CustomEvent, taxonomy: string) { + const value = event.detail.value; + // eslint-disable-next-line @typescript-eslint/no-this-alias + this.debounce(() => { + // update options in termsByTaxonomyId SearchaliciousTermsMixin + // which will update the property of the autocomplete component during render + this.getTaxonomiesTerms(value, [taxonomy]); + }); + } + + /** + * Renders the add term input when showOther is true + */ + renderAddTerm() { + const inputName = `add-term-for-${this.name}`; + const taxonomy = getTaxonomyName(this.name); + const otherItem = this.infos!.items?.find( + (item) => item.key === FACET_TERM_OTHER + ) as FacetTerm | undefined; + const onInput = (e: CustomEvent) => { + this.onInputAddTerm(e, taxonomy); + }; + + const options = (this.termsByTaxonomyId[taxonomy] || []).map((term) => { + return { + value: term.id.replace(/^en:/, ''), + label: term.text, + }; + }); + + return html` +
    + + +
    + `; + } + /** * Renders a single term */ renderTerm(term: FacetTerm) { return html`
    -