-
-
+
+
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`