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
4 changes: 4 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ The project is currently composed of several widgets
* searchalicious-button-transparent is a transparent button with defined style
* it can be used to replace the default button
* searchalicious-chart renders vega chart, currently only for distribution. Requires [vega](https://vega.github.io/).
* searchalicious-icon-cross is a cross icon
* it can be used to delete actions
* searchalicious-suggestion-entry is a suggestion entry
* it can be used to display a suggestion in searchalicious-bar

You can give a specific `name` attribute to your search bar.
Then all other component that needs to connect with this search must use the same value in `search-name` attribute
Expand Down
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" suggestions="brand,category"></searchalicious-bar>
</div>
<div class="columns">
<searchalicious-button search-name="off">
Expand Down
28 changes: 26 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,30 @@ export const SearchaliciousSearchMixin = <T extends Constructor<LitElement>>(
@state()
_count?: number;

/** list of facets containers */
_facetsParentNode() {
Kout95 marked this conversation as resolved.
Show resolved Hide resolved
return document.querySelectorAll(
`searchalicious-facets[search-name=${this.name}]`
);
Comment on lines +122 to +124
Copy link
Member

Choose a reason for hiding this comment

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

This might not work with default name, but I will fix this with a commit before testing.

}

/**
* 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 +174,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
233 changes: 233 additions & 0 deletions frontend/src/mixins/suggestion-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import {LitElement} from 'lit';
import {Constructor} from './utils';
import {property} from 'lit/decorators.js';
import {DebounceMixin, DebounceMixinInterface} from './debounce';

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

onInput(event: InputEvent): void;
handleInput(value: string): void;
blurInput(): void;
resetInput(): void;
submit(isSuggestion?: boolean): void;
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 suggestion option.
*/
export type SuggestionSelectionOption = {
value: string;
label: string;
};
/**
* Type for suggestion result.
*/
export type SuggestionSelectionResult = {
value: string;
label?: string;
};

/**
* This mixin handles the logic of having a list of suggestion,
* and letting the user choose on suggestion.
*
* It factors the interaction logic but does not deal with the rendering.
*/
export const SuggestionSelectionMixin = <T extends Constructor<LitElement>>(
superClass: T
): Constructor<SuggestionSelectionMixinInterface> & T => {
class SuggestionSelectionMixinClass extends DebounceMixin(superClass) {
@property({attribute: 'input-name'})
inputName = 'suggestion';

/**
* The options for the suggestion.
* It is provided by the parent component.
*/
@property({attribute: false, type: Array})
options: SuggestionSelectionOption[] = [];

// 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 suggestion and dispatch custom event : "suggestion-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 for ${this} with ${value}`
);
}
/**
* 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 for ${this} with ${isSuggestion}`
);
}

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

/**
* 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 suggestion 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 suggestion 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 suggestion options when the input is blurred.
* It is debounced to avoid to quit before select with click.
*/
onBlur() {
this.debounce(() => {
this.visible = false;
});
}
}

return SuggestionSelectionMixinClass as Constructor<SuggestionSelectionMixinInterface> &
T;
};
1 change: 1 addition & 0 deletions frontend/src/search-a-licious.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export {SearchaliciousSecondaryButton} from './secondary-button';
export {SearchaliciousButtonTransparent} from './button-transparent';
export {SearchaliciousIconCross} from './icons/cross';
export {SearchaliciousChart} from './search-chart';
export {SearchaliciousSuggestionEntry} from './search-suggestion-entry';
Loading