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(atomic): add configurable query suggestions & recent queries to search box #1261

Merged
merged 6 commits into from
Oct 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -1054,6 +1066,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 @@ -1156,6 +1180,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 @@ -1752,6 +1778,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 @@ -1932,6 +1970,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 @@ -1999,6 +2039,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
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;
Copy link
Member

Choose a reason for hiding this comment

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

numberOfSuggestions ? What do you think ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Like described in the documents, there will also be "numberOfResults" for the other types of suggestions. So these scenarios can be configured:

Screen Shot 2021-09-28 at 3 26 15 PM

Copy link
Contributor

@btaillon-coveo btaillon-coveo Sep 29, 2021

Choose a reason for hiding this comment

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

I think that instead of making this an option here, we could have an opiniated default, and if people want to override it then they need to specify atomic-search-box-query-suggestions and atomic-search-box-recent-queries themselves.

Otherwise, I find it confusing to predict what number of suggestions & recent queries will each appear based on this option.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is why each of the sub elements, atomic-search-box-query-suggestions and atomic-search-box-recent-queries, have maximums, which when not defined, fill up the numberOfQueries value. How else could we configure the following:

  • I want a max of 8 queries at all times (any type)
  • When there is no query in the input, I want all the recent queries I can up to 8, but fill the rest with query suggestions
  • When there is a query, I want up to 3 matching recent queries, and fill the rest with query suggestions


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,
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is the new initial value 0?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We can't let the search box manage the registering and fetching of query suggestions, mainly for the promise we need to wait for before rendering all suggestions. The other atomic-search-box-query-suggestions handles it

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