Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add autocomplete in search bar #165

Merged
merged 16 commits into from
Jun 20, 2024
2 changes: 1 addition & 1 deletion frontend/public/off.html
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@
<li class="search-li">
<div class="row collapse postfix-round search-bar">
<div class="columns">
<searchalicious-bar name="off" page-size="24"></searchalicious-bar>
<searchalicious-bar name="off" page-size="24" taxonomies="brand,category"></searchalicious-bar>
Kout95 marked this conversation as resolved.
Show resolved Hide resolved
</div>
<div class="columns">
<searchalicious-button search-name="off">
Expand Down
222 changes: 222 additions & 0 deletions frontend/src/mixins/autocomplete.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for factoring this logic in a mixin.

Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import {LitElement} from 'lit';
import {Constructor} from './utils';
import {property} from 'lit/decorators.js';
import {DebounceMixin, DebounceMixinInterface} from './debounce';

/**
* Interface for the Autocomplete mixin.
*/
export interface AutocompleteMixinInterface extends DebounceMixinInterface {
inputName: string;
options: AutocompleteOption[];
value: string;
currentIndex: number;
getOptionIndex: number;
visible: boolean;
isLoading: boolean;
currentOption: AutocompleteOption | undefined;

onInput(event: InputEvent): void;
handleInput(value: string): void;
blurInput(): void;
resetInput(): void;
submit(isSuggestion?: boolean): void;
getAutocompleteValueByIndex(index: number): string;
handleArrowKey(direction: 'up' | 'down'): void;
handleEnter(event: KeyboardEvent): void;
handleEscape(): void;
onKeyDown(event: KeyboardEvent): void;
onClick(index: number): () => void;
onFocus(): void;
onBlur(): void;
}
/**
* Type for autocomplete option.
*/
export type AutocompleteOption = {
value: string;
label: string;
};
/**
* Type for autocomplete result.
*/
export type AutocompleteResult = {
value: string;
label?: string;
};

export const AutocompleteMixin = <T extends Constructor<LitElement>>(
Kout95 marked this conversation as resolved.
Show resolved Hide resolved
Kout95 marked this conversation as resolved.
Show resolved Hide resolved
superClass: T
): Constructor<AutocompleteMixinInterface> & T => {
class AutocompleteMixinClass extends DebounceMixin(superClass) {
@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.
*/
get getOptionIndex() {
return this.currentIndex - 1;
}

get currentOption() {
return this.options[this.getOptionIndex];
}

/**
* Handles the input event on the autocomplete and dispatch custom event : "autocomplete-input".
* @param {InputEvent} event - The input event.
*/
onInput(event: InputEvent) {
const value = (event.target as HTMLInputElement).value;
this.value = value;
this.handleInput(value);
}

handleInput(value: string) {
throw new Error(`handleInput method must be implemented with ${value}`);
Kout95 marked this conversation as resolved.
Show resolved Hide resolved
}
/**
* 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) {
throw new Error(`submit method must be implemented with ${isSuggestion}`);
Kout95 marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* 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.currentOption!.value;
} else {
const value = (event.target as HTMLInputElement).value;
this.value = value;
}
this.submit(isAutoComplete);
}

/**
* This method is used to handle the escape key event.
*/
handleEscape() {
this.blurInput();
}

/**
* dispatch key events according to the key pressed (arrows or enter)
* @param event
*/
onKeyDown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowDown':
this.handleArrowKey('down');
return;
case 'ArrowUp':
this.handleArrowKey('up');
return;
case 'Enter':
this.handleEnter(event);
return;
case 'Escape':
this.handleEscape();
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.options[index].value;
// 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.
*/
onFocus() {
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.
*/
onBlur() {
this.debounce(() => {
this.visible = false;
});
}
}

return AutocompleteMixinClass as Constructor<AutocompleteMixinInterface> & T;
};
25 changes: 23 additions & 2 deletions frontend/src/mixins/search-ctl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface SearchaliciousSearchInterface
search(): Promise<void>;
_facetsNodes(): SearchaliciousFacets[];
_facetsFilters(): string;
selectTermByTaxonomy(taxonomy: string, term: string): void;
}

// name of search params as an array (to ease iteration)
Expand Down Expand Up @@ -115,6 +116,27 @@ export const SearchaliciousSearchMixin = <T extends Constructor<LitElement>>(
@state()
_count?: number;

_facetsParentNode() {
Kout95 marked this conversation as resolved.
Show resolved Hide resolved
return document.querySelectorAll('searchalicious-facets');
Kout95 marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* 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
*/
Expand Down Expand Up @@ -149,8 +171,7 @@ export const SearchaliciousSearchMixin = <T extends Constructor<LitElement>>(
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) {
Kout95 marked this conversation as resolved.
Show resolved Hide resolved
allNodes.push(facetElement);
Expand Down
1 change: 1 addition & 0 deletions frontend/src/search-a-licious.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export {SearchaliciousAutocomplete} from './search-autocomplete';
export {SearchaliciousSecondaryButton} from './secondary-button';
export {SearchaliciousButtonTransparent} from './button-transparent';
export {SearchaliciousIconCross} from './icons/cross';
export {SearchaliciousTermLine} from './search-term-line';
Loading