coveo-search-ui
Version:
Coveo JavaScript Search Framework
424 lines (356 loc) • 13.9 kB
text/typescript
import { defaults, indexOf, find } from 'underscore';
import { IQuerySuggestSelection, OmniboxEvents } from '../events/OmniboxEvents';
import { Component } from '../ui/Base/Component';
import { $$, Dom } from '../utils/Dom';
import { InputManager } from './InputManager';
import { ResultPreviewsManager } from './ResultPreviewsManager';
import { QueryProcessor, ProcessingStatus } from './QueryProcessor';
import { QueryUtils } from '../utils/QueryUtils';
export interface Suggestion {
text?: string;
index?: number;
html?: string;
dom?: HTMLElement;
separator?: string;
advancedQuery?: string;
onSelect?: () => void;
}
export interface SuggestionsManagerOptions {
suggestionClass?: string;
selectedClass?: string;
timeout?: number;
previewHeaderText?: string;
}
export enum Direction {
Up = 'Up',
Down = 'Down',
Left = 'Left',
Right = 'Right'
}
export class SuggestionsManager {
public suggestionsListbox: Dom;
private suggestionsProcessor: QueryProcessor<Suggestion>;
private currentSuggestions: Suggestion[];
private options: SuggestionsManagerOptions;
private keyboardFocusedElement: HTMLElement;
private resultPreviewsManager: ResultPreviewsManager;
private root: HTMLElement;
private suggestionListboxID = `coveo-magicbox-suggestions-${QueryUtils.createGuid()}`;
private suggestionListboxClassName = `coveo-magicbox-suggestions`;
public get hasSuggestions() {
return this.currentSuggestions && this.currentSuggestions.length > 0;
}
public get hasFocus() {
return $$(this.element).findClass(this.options.selectedClass).length > 0;
}
public get hasPreviews() {
return this.resultPreviewsManager.hasPreviews;
}
private get focusedSuggestion() {
return find(
this.currentSuggestions,
suggestion =>
$$(suggestion.dom).hasClass(this.options.selectedClass) || $$(suggestion.dom).findClass(this.options.selectedClass).length > 0
);
}
constructor(
private element: HTMLElement,
private magicBoxContainer: HTMLElement,
private inputManager: InputManager,
options?: SuggestionsManagerOptions
) {
this.root = Component.resolveRoot(element);
this.options = defaults(options, <SuggestionsManagerOptions>{
suggestionClass: 'magic-box-suggestion',
selectedClass: 'magic-box-selected'
});
// Put in a sane default, so as to not reject every suggestions if not set on initialization
if (this.options.timeout == undefined) {
this.options.timeout = 500;
}
$$(this.element).on('mouseover', e => {
this.handleMouseOver(e);
});
$$(this.element).on('mouseout', e => {
this.handleMouseOut(e);
});
this.suggestionsProcessor = new QueryProcessor({ timeout: this.options.timeout });
this.resultPreviewsManager = new ResultPreviewsManager(element, {
selectedClass: this.options.selectedClass,
timeout: this.options.timeout
});
this.suggestionsListbox = this.buildSuggestionsContainer();
$$(this.element).append(this.suggestionsListbox.el);
this.addAccessibilityProperties();
this.appendEmptySuggestionOption();
}
public handleMouseOver(e) {
const target = $$(<HTMLElement>e.target);
const parents = target.parents(this.options.suggestionClass);
if (target.hasClass(this.options.suggestionClass)) {
this.processMouseSelection(target.el);
} else if (parents.length > 0 && this.element.contains(parents[0])) {
this.processMouseSelection(parents[0]);
}
}
public handleMouseOut(e) {
const target = $$(<HTMLElement>e.target);
const targetParents = target.parents(this.options.suggestionClass);
//e.relatedTarget is not available if moving off the browser window or is an empty object `{}` when moving out of namespace in LockerService.
if (e.relatedTarget && $$(e.relatedTarget).isValid()) {
const relatedTargetParents = $$(<HTMLElement>e.relatedTarget).parents(this.options.suggestionClass);
if (target.hasClass(this.options.selectedClass) && !$$(<HTMLElement>e.relatedTarget).hasClass(this.options.suggestionClass)) {
this.removeSelectedStatus(target.el);
} else if (relatedTargetParents.length == 0 && targetParents.length > 0) {
this.removeSelectedStatus(targetParents[0]);
}
} else {
if (target.hasClass(this.options.selectedClass)) {
this.removeSelectedStatus(target.el);
} else if (targetParents.length > 0) {
this.removeSelectedStatus(targetParents[0]);
}
}
$$(this.root).trigger(OmniboxEvents.querySuggestLoseFocus);
}
public async moveDown() {
await this.move(Direction.Down);
}
public async moveUp() {
await this.move(Direction.Up);
}
public async moveLeft() {
await this.move(Direction.Left);
}
public async moveRight() {
await this.move(Direction.Right);
}
public selectAndReturnKeyboardFocusedElement(): HTMLElement {
const selected = this.keyboardFocusedElement;
if (selected) {
$$(selected).trigger('keyboardSelect');
// By definition, once an element has been "selected" with the keyboard,
// it is not longer "active" since the event has been processed.
this.keyboardFocusedElement = null;
this.inputManager.blur();
}
return selected;
}
public clearKeyboardFocusedElement() {
this.keyboardFocusedElement = null;
}
public async receiveSuggestions(suggestions: (Promise<Suggestion[]> | Suggestion[])[]) {
const { results, status } = await this.suggestionsProcessor.processQueries(suggestions);
if (status === ProcessingStatus.Overriden) {
return [];
}
this.updateSuggestions(results);
return results;
}
public clearSuggestions() {
this.updateSuggestions([]);
}
public updateSuggestions(suggestions: Suggestion[]) {
this.suggestionsListbox.empty();
this.inputManager.activeDescendant = null;
this.currentSuggestions = suggestions;
$$(this.element).toggleClass('magic-box-hasSuggestion', this.hasSuggestions);
this.inputManager.expanded = this.hasSuggestions;
this.resultPreviewsManager.displaySearchResultPreviewsForSuggestion(null);
if (!this.hasSuggestions) {
this.appendEmptySuggestionOption();
$$(this.root).trigger(OmniboxEvents.querySuggestLoseFocus);
return;
}
suggestions
.sort((a, b) => (b.index || 0) - (a.index || 0))
.forEach(suggestion => {
const dom = suggestion.dom ? this.modifyDomFromExistingSuggestion(suggestion.dom) : this.createDomFromSuggestion(suggestion);
dom.setAttribute('id', `magic-box-suggestion-${indexOf(suggestions, suggestion)}`);
dom.setAttribute('role', 'option');
dom.setAttribute('aria-selected', 'false');
dom.setAttribute('aria-label', dom.text());
dom['suggestion'] = suggestion;
this.suggestionsListbox.append(dom.el);
});
const numberOfSuggestions = this.suggestionsListbox.findAll(`.${this.options.suggestionClass}`).length;
$$(this.root).trigger(OmniboxEvents.querySuggestRendered, { numberOfSuggestions });
}
public get selectedSuggestion(): Suggestion {
if (this.htmlElementIsSuggestion(this.keyboardFocusedElement)) {
return this.returnMoved(this.keyboardFocusedElement) as Suggestion;
}
return null;
}
private async processKeyboardSelection(suggestion: HTMLElement) {
this.addSelectedStatus(suggestion);
this.keyboardFocusedElement = suggestion;
await this.updateSelectedSuggestion(this.focusedSuggestion);
}
private processKeyboardPreviewSelection(preview: HTMLElement) {
this.addSelectedStatus(preview);
this.keyboardFocusedElement = preview;
}
private processMouseSelection(suggestion: HTMLElement) {
this.addSelectedStatus(suggestion);
this.updateSelectedSuggestion(this.focusedSuggestion);
this.keyboardFocusedElement = null;
}
private buildSuggestionsContainer() {
return $$('div', {
className: this.suggestionListboxClassName,
id: this.suggestionListboxID,
role: 'listbox',
ariaLabel: 'Search Suggestions'
});
}
private createDomFromSuggestion(suggestion: Suggestion) {
const dom = $$('div', {
className: `magic-box-suggestion ${this.options.suggestionClass}`
});
suggestion.dom = dom.el;
dom.on('click', () => {
this.selectSuggestion(suggestion);
});
dom.on('keyboardSelect', () => {
this.selectSuggestion(suggestion);
});
if (suggestion.html) {
dom.el.innerHTML = suggestion.html;
return dom;
}
if (suggestion.text) {
dom.text(suggestion.text);
return dom;
}
if (suggestion.separator) {
dom.addClass('magic-box-suggestion-seperator');
const suggestionLabel = $$(
'div',
{
className: 'magic-box-suggestion-seperator-label'
},
suggestion.separator
);
dom.append(suggestionLabel.el);
return dom;
}
return dom;
}
private selectSuggestion(suggestion: Suggestion) {
suggestion.onSelect();
$$(this.root).trigger(OmniboxEvents.querySuggestSelection, <IQuerySuggestSelection>{ suggestion: suggestion.text });
}
private appendEmptySuggestionOption() {
// Accessibility tools reports that a listbox element must always have at least one child with an option
// Even if there is no suggestions to show.
this.suggestionsListbox.append($$('div', { role: 'option' }).el);
}
private modifyDomFromExistingSuggestion(dom: HTMLElement) {
// this need to be done if the selection is in cache and the dom is set in the suggestion
this.removeSelectedStatus(dom);
const found = dom.classList.contains(this.options.suggestionClass) ? dom : $$(dom).find('.' + this.options.suggestionClass);
this.removeSelectedStatus(found);
return $$(dom);
}
private async move(direction: Direction) {
if (this.resultPreviewsManager.focusedPreviewElement) {
await this.moveWithinPreview(direction);
return;
}
if (direction === Direction.Right || direction === Direction.Left) {
const firstPreview = this.resultPreviewsManager.previewElements[0];
if (firstPreview) {
this.processKeyboardPreviewSelection(firstPreview);
return;
}
}
await this.moveWithinSuggestion(direction);
}
private async moveWithinSuggestion(direction: Direction) {
const currentlySelected = $$(this.element).find(`.${this.options.selectedClass}`);
const selectables = $$(this.element).findAll(`.${this.options.suggestionClass}`);
const currentIndex = indexOf(selectables, currentlySelected);
let index = direction === Direction.Up ? currentIndex - 1 : currentIndex + 1;
index = (index + selectables.length) % selectables.length;
await this.selectQuerySuggest(selectables[index]);
}
private async selectQuerySuggest(suggestion: HTMLElement) {
if (suggestion) {
await this.processKeyboardSelection(suggestion);
} else {
this.keyboardFocusedElement = null;
this.inputManager.input.removeAttribute('aria-activedescendant');
}
return suggestion;
}
private async moveWithinPreview(direction: Direction) {
const newFocusedPreview = this.resultPreviewsManager.getElementInDirection(direction);
if (!newFocusedPreview) {
await this.selectQuerySuggest(this.resultPreviewsManager.previewsOwner.dom);
return;
}
this.processKeyboardPreviewSelection(newFocusedPreview);
}
private returnMoved(selected) {
if (selected) {
if (selected['suggestion']) {
return selected['suggestion'];
}
if (selected['no-text-suggestion']) {
return null;
}
if (selected instanceof HTMLElement) {
return {
text: $$(selected).text()
};
}
}
return null;
}
private addSelectedStatus(element: HTMLElement): void {
const selected = this.element.getElementsByClassName(this.options.selectedClass);
for (let i = 0; i < selected.length; i++) {
const elem = <HTMLElement>selected.item(i);
this.removeSelectedStatus(elem);
}
$$(element).addClass(this.options.selectedClass);
this.updateAreaSelectedIfDefined(element, 'true');
}
private async updateSelectedSuggestion(suggestion: Suggestion) {
$$(this.root).trigger(OmniboxEvents.querySuggestGetFocus, <IQuerySuggestSelection>{
suggestion: suggestion.text
});
await this.resultPreviewsManager.displaySearchResultPreviewsForSuggestion(suggestion);
}
private removeSelectedStatus(suggestion: HTMLElement): void {
$$(suggestion).removeClass(this.options.selectedClass);
this.updateAreaSelectedIfDefined(suggestion, 'false');
}
private updateAreaSelectedIfDefined(element: HTMLElement, value: string): void {
if ($$(element).getAttribute('aria-selected')) {
this.inputManager.activeDescendant = element;
$$(element).setAttribute('aria-selected', value);
}
}
private addAccessibilityProperties() {
this.addAccessibilityPropertiesForMagicBox();
this.addAccessibilityPropertiesForInput();
}
private addAccessibilityPropertiesForMagicBox() {
const magicBox = $$(this.magicBoxContainer);
magicBox.setAttribute('role', 'search');
magicBox.setAttribute('aria-haspopup', 'listbox');
}
private addAccessibilityPropertiesForInput() {
const input = $$(this.inputManager.input);
this.inputManager.activeDescendant = null;
this.inputManager.expanded = false;
input.setAttribute('aria-owns', this.suggestionListboxID);
input.setAttribute('aria-controls', this.suggestionListboxID);
}
private htmlElementIsSuggestion(selected: HTMLElement) {
const omniboxSelectables = $$(this.element).findAll(`.${this.options.suggestionClass}`);
return indexOf(omniboxSelectables, selected) > -1;
}
}