-
Notifications
You must be signed in to change notification settings - Fork 36
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
Changes from 5 commits
f333932
903ddb7
acd6b24
e30b981
87eddcf
e5a4cdc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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'; | ||
|
@@ -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. | ||
|
@@ -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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is the new initial value There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
highlightOptions: { | ||
notMatchDelimiters: { | ||
open: '<span class="font-bold">', | ||
|
@@ -68,14 +85,45 @@ export class AtomicSearchBox { | |
}, | ||
}, | ||
}); | ||
|
||
this.suggestions.push( | ||
...this.pendingSuggestionEvents.map((event) => | ||
event(this.suggestionBindings) | ||
) | ||
); | ||
this.pendingSuggestionEvents = []; | ||
} | ||
|
||
@Listen('atomic/searchBoxSuggestion') | ||
public setFormat(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, | ||
}; | ||
} | ||
|
||
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() { | ||
|
@@ -148,16 +196,26 @@ export class AtomicSearchBox { | |
this.scrollActiveDescendantIntoView(); | ||
} | ||
|
||
private async triggerSuggestions() { | ||
await Promise.all( | ||
this.suggestions.map((suggestion) => suggestion.onInput()) | ||
); | ||
this.suggestionElements = this.suggestions | ||
// TODO: sort by position | ||
.map((suggestion) => suggestion.renderItems()) | ||
.flat() | ||
.slice(0, this.numberOfQueries); | ||
} | ||
|
||
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() { | ||
|
@@ -251,7 +309,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()} | ||
|
@@ -261,34 +318,32 @@ 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 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.value} | ||
data-value={suggestion.value} | ||
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.onClick()} | ||
> | ||
{/* TODO: add icon when mixed suggestions */} | ||
<span innerHTML={suggestion.highlightedValue}></span> | ||
{suggestion.content} | ||
</li> | ||
); | ||
} | ||
|
@@ -307,7 +362,7 @@ export class AtomicSearchBox { | |
showSuggestions ? '' : 'hidden' | ||
}`} | ||
> | ||
{this.searchBoxState.suggestions.map((suggestion, index) => | ||
{this.suggestionElements.map((suggestion, index) => | ||
this.renderSuggestion(suggestion, index) | ||
)} | ||
</ul> | ||
|
@@ -329,7 +384,7 @@ export class AtomicSearchBox { | |
} | ||
|
||
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' : '' | ||
|
@@ -338,7 +393,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> | ||
), | ||
]; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
import {loadQuerySuggestActions, SearchEngine} from '@coveo/headless'; | ||
import {QuerySuggestionSection} from '@coveo/headless/dist/definitions/state/state-sections'; | ||
import {Component, Element, Prop, State, h} from '@stencil/core'; | ||
import {dispatchSearchBoxSuggestionsEvent} from '../suggestions-common'; | ||
|
||
@Component({ | ||
tag: 'atomic-search-box-query-suggestions', | ||
shadow: true, | ||
}) | ||
export class AtomicSearchBoxQuerySuggestions { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think ending the name with a singular would make it easier to understand? Could it also be possible to shorten by removing
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair enough, but I wanted it to be clear they are for the search box and you can't see them as independent. I could see these AtomicQuerySuggestions/AtomicRecentQueries being standalone somewhere in the page, like quantic uses recent queries |
||
@Element() private host!: HTMLElement; | ||
|
||
@State() public error!: Error; | ||
|
||
@Prop() public maxWithQuery?: number; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we really need two different prop for this ? Just one for both could do, no ? 🤔 Why would it be important to have separate option for both mode ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From the design (figma), you put as many recent queries as possible when the input is empty, but then you match up to |
||
@Prop() public maxWithoutQuery?: number; | ||
|
||
componentWillLoad() { | ||
try { | ||
dispatchSearchBoxSuggestionsEvent( | ||
({engine, id, searchBoxController, numberOfQueries}) => { | ||
const {registerQuerySuggest, fetchQuerySuggestions} = | ||
loadQuerySuggestActions(engine); | ||
|
||
(engine as SearchEngine<QuerySuggestionSection>).dispatch( | ||
registerQuerySuggest({ | ||
id, | ||
count: numberOfQueries, | ||
}) | ||
); | ||
|
||
return { | ||
onInput: () => | ||
(engine as SearchEngine<QuerySuggestionSection>).dispatch( | ||
fetchQuerySuggestions({ | ||
id, | ||
}) | ||
), | ||
renderItems: () => | ||
// TODO: limit values according to maxWithQuery/maxWithoutQuery | ||
searchBoxController.state.suggestions.map((suggestion) => ({ | ||
content: <span innerHTML={suggestion.highlightedValue}></span>, | ||
value: suggestion.rawValue, | ||
onClick: () => | ||
searchBoxController.selectSuggestion(suggestion.rawValue), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this block would be easier to follow if it was broken up into multiple smaller functions. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep! I'll do it in the other PR |
||
})), | ||
}; | ||
}, | ||
this.host | ||
); | ||
} catch (error) { | ||
this.error = error as Error; | ||
} | ||
} | ||
|
||
public render() { | ||
if (this.error) { | ||
return ( | ||
<atomic-component-error | ||
element={this.host} | ||
error={this.error} | ||
></atomic-component-error> | ||
); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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 ?There was a problem hiding this comment.
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:
There was a problem hiding this comment.
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
andatomic-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.
There was a problem hiding this comment.
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: