Skip to content

Commit

Permalink
feat(atomic): add configurable query suggestions & recent queries to …
Browse files Browse the repository at this point in the history
  • Loading branch information
ThibodeauJF authored Oct 6, 2021
1 parent 2d95685 commit bb38b27
Show file tree
Hide file tree
Showing 9 changed files with 511 additions and 34 deletions.
42 changes: 42 additions & 0 deletions packages/atomic/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,18 @@ export namespace Components {
"initialChoice"?: number;
}
interface AtomicSearchBox {
/**
* The amount of queries displayed when the user interacts with the search box. By default, a mix of query suggestions and recent queries will be shown. You can configure those settings using the following components as children: - atomic-search-box-query-suggestions - atomic-search-box-recent-queries
*/
"numberOfQueries": number;
}
interface AtomicSearchBoxQuerySuggestions {
"maxWithQuery"?: number;
"maxWithoutQuery"?: number;
}
interface AtomicSearchBoxRecentQueries {
"maxWithQuery": number;
"maxWithoutQuery"?: number;
}
interface AtomicSearchInterface {
/**
Expand Down Expand Up @@ -1058,6 +1070,18 @@ declare global {
prototype: HTMLAtomicSearchBoxElement;
new (): HTMLAtomicSearchBoxElement;
};
interface HTMLAtomicSearchBoxQuerySuggestionsElement extends Components.AtomicSearchBoxQuerySuggestions, HTMLStencilElement {
}
var HTMLAtomicSearchBoxQuerySuggestionsElement: {
prototype: HTMLAtomicSearchBoxQuerySuggestionsElement;
new (): HTMLAtomicSearchBoxQuerySuggestionsElement;
};
interface HTMLAtomicSearchBoxRecentQueriesElement extends Components.AtomicSearchBoxRecentQueries, HTMLStencilElement {
}
var HTMLAtomicSearchBoxRecentQueriesElement: {
prototype: HTMLAtomicSearchBoxRecentQueriesElement;
new (): HTMLAtomicSearchBoxRecentQueriesElement;
};
interface HTMLAtomicSearchInterfaceElement extends Components.AtomicSearchInterface, HTMLStencilElement {
}
var HTMLAtomicSearchInterfaceElement: {
Expand Down Expand Up @@ -1160,6 +1184,8 @@ declare global {
"atomic-result-text": HTMLAtomicResultTextElement;
"atomic-results-per-page": HTMLAtomicResultsPerPageElement;
"atomic-search-box": HTMLAtomicSearchBoxElement;
"atomic-search-box-query-suggestions": HTMLAtomicSearchBoxQuerySuggestionsElement;
"atomic-search-box-recent-queries": HTMLAtomicSearchBoxRecentQueriesElement;
"atomic-search-interface": HTMLAtomicSearchInterfaceElement;
"atomic-sort-dropdown": HTMLAtomicSortDropdownElement;
"atomic-sort-expression": HTMLAtomicSortExpressionElement;
Expand Down Expand Up @@ -1756,6 +1782,18 @@ declare namespace LocalJSX {
"initialChoice"?: number;
}
interface AtomicSearchBox {
/**
* The amount of queries displayed when the user interacts with the search box. By default, a mix of query suggestions and recent queries will be shown. You can configure those settings using the following components as children: - atomic-search-box-query-suggestions - atomic-search-box-recent-queries
*/
"numberOfQueries"?: number;
}
interface AtomicSearchBoxQuerySuggestions {
"maxWithQuery"?: number;
"maxWithoutQuery"?: number;
}
interface AtomicSearchBoxRecentQueries {
"maxWithQuery"?: number;
"maxWithoutQuery"?: number;
}
interface AtomicSearchInterface {
/**
Expand Down Expand Up @@ -1940,6 +1978,8 @@ declare namespace LocalJSX {
"atomic-result-text": AtomicResultText;
"atomic-results-per-page": AtomicResultsPerPage;
"atomic-search-box": AtomicSearchBox;
"atomic-search-box-query-suggestions": AtomicSearchBoxQuerySuggestions;
"atomic-search-box-recent-queries": AtomicSearchBoxRecentQueries;
"atomic-search-interface": AtomicSearchInterface;
"atomic-sort-dropdown": AtomicSortDropdown;
"atomic-sort-expression": AtomicSortExpression;
Expand Down Expand Up @@ -2007,6 +2047,8 @@ declare module "@stencil/core" {
"atomic-result-text": LocalJSX.AtomicResultText & JSXBase.HTMLAttributes<HTMLAtomicResultTextElement>;
"atomic-results-per-page": LocalJSX.AtomicResultsPerPage & JSXBase.HTMLAttributes<HTMLAtomicResultsPerPageElement>;
"atomic-search-box": LocalJSX.AtomicSearchBox & JSXBase.HTMLAttributes<HTMLAtomicSearchBoxElement>;
"atomic-search-box-query-suggestions": LocalJSX.AtomicSearchBoxQuerySuggestions & JSXBase.HTMLAttributes<HTMLAtomicSearchBoxQuerySuggestionsElement>;
"atomic-search-box-recent-queries": LocalJSX.AtomicSearchBoxRecentQueries & JSXBase.HTMLAttributes<HTMLAtomicSearchBoxRecentQueriesElement>;
"atomic-search-interface": LocalJSX.AtomicSearchInterface & JSXBase.HTMLAttributes<HTMLAtomicSearchInterfaceElement>;
"atomic-sort-dropdown": LocalJSX.AtomicSortDropdown & JSXBase.HTMLAttributes<HTMLAtomicSortDropdownElement>;
"atomic-sort-expression": LocalJSX.AtomicSortExpression & JSXBase.HTMLAttributes<HTMLAtomicSortExpressionElement>;
Expand Down
129 changes: 103 additions & 26 deletions packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import SearchIcon from 'coveo-styleguide/resources/icons/svg/search.svg';
import ClearIcon from 'coveo-styleguide/resources/icons/svg/clear.svg';
import {Component, h, State} from '@stencil/core';
import {Component, h, State, Prop, Listen} from '@stencil/core';
import {
SearchBox,
SearchBoxState,
buildSearchBox,
Suggestion,
loadQuerySetActions,
QuerySetActionCreators,
} from '@coveo/headless';
Expand All @@ -17,6 +16,12 @@ import {
import {Button} from '../common/button';
import {randomID} from '../../utils/utils';
import {isNullOrUndefined} from '@coveo/bueno';
import {
SearchBoxSuggestionElement,
SearchBoxSuggestions,
SearchBoxSuggestionsBindings,
SearchBoxSuggestionsEvent,
} from '../search-box-suggestions/suggestions-common';

/**
* The `atomic-search-box` component creates a search box with built-in support for suggestions.
Expand All @@ -41,21 +46,33 @@ export class AtomicSearchBox {
private inputRef!: HTMLInputElement;
private listRef!: HTMLElement;
private querySetActions!: QuerySetActionCreators;
private pendingSuggestionEvents: SearchBoxSuggestionsEvent[] = [];
private suggestions: SearchBoxSuggestions[] = [];

@BindStateToController('searchBox')
@State()
private searchBoxState!: SearchBoxState;
@State() public error!: Error;
@State() private isExpanded = false;
@State() private activeDescendant = '';
@State() private suggestionElements: SearchBoxSuggestionElement[] = [];

/**
* The amount of queries displayed when the user interacts with the search box.
* By default, a mix of query suggestions and recent queries will be shown.
* You can configure those settings using the following components as children:
* - atomic-search-box-query-suggestions
* - atomic-search-box-recent-queries
*/
@Prop() public numberOfQueries = 8;

public initialize() {
this.id = randomID('atomic-search-box-');
this.querySetActions = loadQuerySetActions(this.bindings.engine);
this.searchBox = buildSearchBox(this.bindings.engine, {
options: {
id: this.id,
numberOfSuggestions: 8, // TODO: handle when adding query suggestion component
numberOfSuggestions: 0,
highlightOptions: {
notMatchDelimiters: {
open: '<span class="font-bold">',
Expand All @@ -68,14 +85,48 @@ export class AtomicSearchBox {
},
},
});

this.suggestions.push(
...this.pendingSuggestionEvents.map((event) =>
event(this.suggestionBindings)
)
);
this.pendingSuggestionEvents = [];
}

@Listen('atomic/searchBoxSuggestion/register')
public registerSuggestions(event: CustomEvent<SearchBoxSuggestionsEvent>) {
event.preventDefault();
event.stopPropagation();
if (this.searchBox) {
this.suggestions.push(event.detail(this.suggestionBindings));
return;
}
this.pendingSuggestionEvents.push(event.detail);
}

private get suggestionBindings(): SearchBoxSuggestionsBindings {
return {
...this.bindings,
id: this.id,
searchBoxController: this.searchBox,
numberOfQueries: this.numberOfQueries,
inputRef: this.inputRef,
triggerSuggestions: () => this.triggerSuggestions(),
getSuggestions: () => this.suggestions,
};
}

private get popupId() {
return `${this.id}-popup`;
}

private get hasInputValue() {
return this.searchBoxState.value !== '';
}

private get hasSuggestions() {
return !!this.searchBoxState.suggestions.length;
return !!this.suggestionElements.length;
}

private get hasActiveDescendant() {
Expand Down Expand Up @@ -131,7 +182,7 @@ export class AtomicSearchBox {
return;
}

const query = this.nextOrFirstValue.getAttribute('data-value');
const query = this.nextOrFirstValue.getAttribute('data-query');
!isNullOrUndefined(query) && this.updateQuery(query);
this.updateActiveDescendant(this.nextOrFirstValue.id);
this.scrollActiveDescendantIntoView();
Expand All @@ -142,27 +193,42 @@ export class AtomicSearchBox {
return;
}

const query = this.previousOrLastValue.getAttribute('data-value');
const query = this.previousOrLastValue.getAttribute('data-query');
!isNullOrUndefined(query) && this.updateQuery(query);
this.updateActiveDescendant(this.previousOrLastValue.id);
this.scrollActiveDescendantIntoView();
}

private async triggerSuggestions() {
await Promise.all(
this.suggestions.map((suggestion) => suggestion.onInput())
);
const suggestionElements = this.suggestions
.sort((a, b) => a.position - b.position)
.map((suggestion) => suggestion.renderItems())
.flat();

const max =
this.numberOfQueries +
suggestionElements.filter((sug) => sug.query === undefined).length;
this.suggestionElements = suggestionElements.slice(0, max);
}

private onInput(value: string) {
this.searchBox.updateText(value);
this.updateActiveDescendant();
this.triggerSuggestions();
}

private onFocus() {
this.isExpanded = true;
if (!this.searchBoxState.suggestions.length) {
this.searchBox.showSuggestions();
}
this.triggerSuggestions();
}

private onBlur() {
this.isExpanded = false;
this.updateActiveDescendant();
this.clearSuggestionElements();
}

private onSubmit() {
Expand Down Expand Up @@ -240,6 +306,7 @@ export class AtomicSearchBox {
class="w-8 h-8 mr-1.5 text-neutral-dark"
onClick={() => {
this.searchBox.clear();
this.clearSuggestionElements();
this.inputRef.focus();
}}
ariaLabel={this.bindings.i18n.t('clear')}
Expand All @@ -251,7 +318,6 @@ export class AtomicSearchBox {

private renderInputContainer() {
const isLoading = this.searchBoxState.isLoading;
const hasValue = this.searchBoxState.value !== '';
return (
<div class="flex-grow flex items-center">
{this.renderInput()}
Expand All @@ -261,34 +327,36 @@ export class AtomicSearchBox {
class="loading w-5 h-5 rounded-full bg-gradient-to-r animate-spin mr-3 grid place-items-center"
></span>
)}
{!isLoading && hasValue && this.renderClearButton()}
{!isLoading && this.hasInputValue && this.renderClearButton()}
</div>
);
}

// TODO: move inside the atomic-query-suggestions/atomic-recent-queries components
private renderSuggestion(suggestion: Suggestion, index: number) {
private clearSuggestionElements() {
this.suggestionElements = [];
}

private renderSuggestion(
suggestion: SearchBoxSuggestionElement,
index: number
) {
const id = `${this.id}-suggestion-${index}`;
const isSelected = id === this.activeDescendant;
return (
<li
id={id}
role="option"
aria-selected={`${isSelected}`}
key={suggestion.rawValue}
data-value={suggestion.rawValue}
key={suggestion.key}
data-query={suggestion.query}
part={isSelected ? 'active-suggestion suggestion' : 'suggestion'}
class={`flex px-4 h-10 items-center text-neutral-dark hover:bg-neutral-light cursor-pointer first:rounded-t-md last:rounded-b-md ${
isSelected ? 'bg-neutral-light' : ''
}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
this.searchBox.selectSuggestion(suggestion.rawValue);
this.inputRef.blur();
}}
onClick={() => suggestion.onSelect()}
>
{/* TODO: add icon when mixed suggestions */}
<span innerHTML={suggestion.highlightedValue}></span>
{suggestion.content}
</li>
);
}
Expand All @@ -307,7 +375,7 @@ export class AtomicSearchBox {
showSuggestions ? '' : 'hidden'
}`}
>
{this.searchBoxState.suggestions.map((suggestion, index) =>
{this.suggestionElements.map((suggestion, index) =>
this.renderSuggestion(suggestion, index)
)}
</ul>
Expand All @@ -321,15 +389,18 @@ export class AtomicSearchBox {
class="w-12 h-auto rounded-r-md rounded-l-none -my-px"
part="submit-button"
ariaLabel={this.bindings.i18n.t('search')}
onClick={() => this.searchBox.submit()}
onClick={() => {
this.searchBox.submit();
this.clearSuggestionElements();
}}
>
<atomic-icon icon={SearchIcon} class="w-4 h-4"></atomic-icon>
</Button>
);
}

public render() {
return (
return [
<div
class={`relative flex bg-background h-full w-full border border-neutral rounded-md ${
this.isExpanded ? 'border-primary ring ring-ring-primary' : ''
Expand All @@ -338,7 +409,13 @@ export class AtomicSearchBox {
{this.renderInputContainer()}
{this.renderSuggestions()}
{this.renderSubmitButton()}
</div>
);
</div>,
!this.suggestions.length && (
<slot>
<atomic-search-box-recent-queries></atomic-search-box-recent-queries>
<atomic-search-box-query-suggestions></atomic-search-box-query-suggestions>
</slot>
),
];
}
}
Loading

0 comments on commit bb38b27

Please sign in to comment.