coveo-search-ui
Version:
Coveo JavaScript Search Framework
411 lines (360 loc) • 16 kB
text/typescript
import 'styling/_Sort';
import { exportGlobally } from '../../GlobalExports';
import { IBuildingQueryEventArgs, IQueryErrorEventArgs, IQuerySuccessEventArgs, QueryEvents } from '../../events/QueryEvents';
import { Assert } from '../../misc/Assert';
import { MODEL_EVENTS } from '../../models/Model';
import { QUERY_STATE_ATTRIBUTES, QueryStateModel } from '../../models/QueryStateModel';
import { $$ } from '../../utils/Dom';
import { SVGDom } from '../../utils/SVGDom';
import { SVGIcons } from '../../utils/SVGIcons';
import { Utils } from '../../utils/Utils';
import { logSortEvent } from '../Analytics/SharedAnalyticsCalls';
import { Component } from '../Base/Component';
import { IComponentBindings } from '../Base/ComponentBindings';
import { ComponentOptions } from '../Base/ComponentOptions';
import { Initialization } from '../Base/Initialization';
import { SortCriteria, VALID_DIRECTION } from './SortCriteria';
import { AccessibleButton, ArrowDirection } from '../../utils/AccessibleButton';
import { l } from '../../strings/Strings';
import { findIndex, find, any } from 'underscore';
export interface ISortOptions {
sortCriteria?: SortCriteria[];
caption?: string;
}
/**
* The `Sort` component renders a widget that the end user can interact with to select the criterion to use when sorting query results.
*
* To improve accessibility, it's recommended to group `Sort` components in a container with `role="radiogroup"`.
*/
export class Sort extends Component {
static ID = 'Sort';
static doExport = () => {
exportGlobally({
Sort: Sort,
SortCriteria: SortCriteria
});
};
/**
* Options for the component
* @componentOptions
*/
static options: ISortOptions = {
/**
* The sort criterion/criteria the end user can select/toggle between when interacting with this component instance.
*
* The available sort criteria are:
* - `relevancy`
* - `date ascending`/`date descending`
* - `qre`
* - `@field ascending`/`@field descending`, where you must replace `field` with the name of a sortable field in your index (e.g., `data-sort-criteria="@size ascending"`).
*
* You can specify a comma separated list of sort criteria to toggle between when interacting with this component instance (e.g., `data-sort-criteria="date descending,date ascending"`).
*
* You can specify multiple sort criteria to be used in the same request by separating them with a semicolon (e.g., `data-sort-criteria="@size ascending;date ascending"` ).
*
* Interacting with this component instance will cycle through those criteria in the order they are listed in.
* Typically, you should only specify a list of sort criteria when you want the end user to be able to to toggle the direction of a `date` or `@field` sort criteria.
* Otherwise, you should configure a distinct `Sort` component instance for each sort criterion you want to make available in your search page.
*
* You must specify a valid value for this option in order for this component instance to work correctly.
*
* Examples:
*
* - `data-sort-criteria="date ascending"` createes a Sort component that allows to sort on `date ascending`, without being able to toggle the order.
* - `data-sort-criteria="date ascending, date descending"` creates a Sort component that allows end users to toggle between `date ascending` and `date descending` on click.
* - `data-sort-criteria="@size ascending; date descending"` creates a Sort component that only allows end users to sort on `@size ascending`. The index then applies a second sort on `date descending` when two items are of equal value.
* - `data-sort-criteria="@size ascending; date descending, @size descending; date descending"` creates a Sort component that allows end users to toggle between `@size ascending` and `@size descending`. For each value, the index applies a second sort on `date descending` when two items are of equal value.
*/
sortCriteria: ComponentOptions.buildCustomListOption(
values => {
return values.map(criteria => {
// 'any' because Underscore won't accept the union type as an argument.
if (typeof criteria === 'string') {
return new SortCriteria(criteria);
} else {
return criteria as SortCriteria;
}
});
},
{ required: true }
),
/**
* The caption to display on this component instance.
*
* By default, the component uses the text content of the element it is instanciated on.
*/
caption: ComponentOptions.buildLocalizedStringOption({ required: true })
};
private currentCriteria: SortCriteria;
private sortButton: HTMLElement;
private directionButton: HTMLElement;
private radioGroup: HTMLElement;
/**
* Creates a new `Sort` component instance.
* @param element The HTMLElement on which to instantiate the component.
* @param options The options for this component instance.
* @param bindings The bindings that the component requires to function normally. If not set, these will be
* automatically resolved (with a slower execution time).
*/
constructor(public element: HTMLElement, public options?: ISortOptions, bindings?: IComponentBindings) {
super(element, Sort.ID, bindings);
this.options = ComponentOptions.initComponentOptions(element, Sort, options);
Assert.isLargerOrEqualsThan(1, this.options.sortCriteria.length);
this.bind.onQueryState(MODEL_EVENTS.CHANGE_ONE, QUERY_STATE_ATTRIBUTES.SORT, () => this.handleQueryStateChanged());
this.bind.onRootElement(QueryEvents.querySuccess, (args: IQuerySuccessEventArgs) => this.handleQuerySuccess(args));
this.bind.onRootElement(QueryEvents.buildingQuery, (args: IBuildingQueryEventArgs) => this.handleBuildingQuery(args));
this.bind.onRootElement(QueryEvents.queryError, (args: IQueryErrorEventArgs) => this.handleQueryError(args));
this.ensureDom();
}
public createDom() {
const el = $$(this.element);
el.on('click', () => this.selectAndExecuteQuery());
const innerText = el.text();
el.empty();
this.findOrCreateRadioGroup();
this.createSortButton(innerText);
if (this.isToggle()) {
this.createDirectionButton();
}
this.update();
}
/**
* Selects this `Sort` component.
*
* Updates the state model if selecting this component toggles its current [`sortCriteria`]{@link Sort.options.sortCriteria}.
*
* @param direction The sort direction. Can be one of: `ascending`, `descending`.
*/
public select(direction?: string) {
if (direction) {
this.currentCriteria = find(this.options.sortCriteria, (criteria: SortCriteria) => {
return criteria.direction == direction;
});
this.updateQueryStateModel();
} else if (Utils.exists(this.currentCriteria)) {
this.selectNextCriteria();
} else {
this.selectFirstCriteria();
}
}
/**
* Selects this `Sort` component, then triggers a query if selecting this component toggles its current [`sortCriteria`]{@link Sort.options.sortCriteria}.
*
* Also logs an event in the usage analytics with the new current sort criteria.
*/
public selectAndExecuteQuery() {
var oldCriteria = this.currentCriteria;
this.select();
if (oldCriteria != this.currentCriteria) {
this.executeSearchQuery();
}
}
public enable() {
$$(this.element).removeClass('coveo-tab-disabled');
this.update();
super.enable();
}
public disable() {
$$(this.element).addClass('coveo-tab-disabled');
super.disable();
}
/**
* Gets the current [`sortCriteria`]{@link Sort.options.sortCriteria} of this `Sort` component.
* @returns {SortCriteria}
*/
public getCurrentCriteria(): SortCriteria {
return this.currentCriteria;
}
/**
* Indicates whether the name of any of the available [`sortCriteria`]{@link Sort.options.sortCriteria} of this `Sort` component matches the argument.
* @param sortId The sort criteria name to look for (e.g., `date descending`).
*/
public match(sortId: string) {
return any(this.options.sortCriteria, (sortCriteria: SortCriteria) => sortId == sortCriteria.toString());
}
private findOrCreateRadioGroup() {
this.radioGroup = this.findRadioGroup();
if (!this.radioGroup) {
this.element.setAttribute('role', 'radiogroup');
this.radioGroup = this.element;
}
}
private createSortButton(innerText?: string) {
this.sortButton = $$('span').el;
this.sortButton.innerText = this.options.caption || innerText;
new AccessibleButton()
.withElement(this.sortButton)
.withEnterKeyboardAction(() => this.selectAndExecuteQuery())
.withArrowsAction((direction, e) => this.onArrowPressed(direction, e))
.withLabel(this.isToggle() ? this.getDirectionalLabel(this.initialDirection as VALID_DIRECTION) : this.getOmnidirectionalLabel())
.withRole('radio')
.build();
this.element.appendChild(this.sortButton);
}
private createDirectionButton() {
this.directionButton = $$('span', { className: 'coveo-icon' }, ...this.createIcons()).el;
new AccessibleButton()
.withElement(this.directionButton)
.withSelectAction(e => {
e.stopPropagation();
this.selectNextCriteriaAndExecuteQuery();
})
.withArrowsAction((direction, e) => this.onArrowPressed(direction, e))
.withLabel(
this.getDirectionalLabel(
this.initialDirection === VALID_DIRECTION.DESCENDING ? VALID_DIRECTION.ASCENDING : VALID_DIRECTION.DESCENDING
)
)
.withRole('radio')
.build();
this.element.appendChild(this.directionButton);
}
private onArrowPressed(direction: ArrowDirection, e: Event) {
this.selectNextRadioButton(direction === ArrowDirection.RIGHT || direction === ArrowDirection.DOWN ? 1 : -1);
e.stopPropagation();
}
private createIcons() {
const iconAscending = $$('span', { className: 'coveo-sort-icon-ascending' }, SVGIcons.icons.arrowUp);
SVGDom.addClassToSVGInContainer(iconAscending.el, 'coveo-sort-icon-ascending-svg');
const iconDescending = $$('span', { className: 'coveo-sort-icon-descending' }, SVGIcons.icons.arrowDown);
SVGDom.addClassToSVGInContainer(iconDescending.el, 'coveo-sort-icon-descending-svg');
return [iconAscending, iconDescending];
}
private findRadioGroup(element = this.element) {
if (!element || element === document.body) {
return null;
}
if (element.getAttribute('role') === 'radiogroup') {
return element;
}
return this.findRadioGroup(element.parentElement);
}
private selectNextRadioButton(direction = 1) {
const radioButtons = $$(this.radioGroup).findAll('[role="radio"]');
const currentIndex = findIndex(radioButtons, radio => radio.getAttribute('aria-checked') === 'true');
let indexToSelect: number;
const isAnythingSelected = currentIndex !== -1;
if (isAnythingSelected) {
indexToSelect = (currentIndex + direction + radioButtons.length) % radioButtons.length;
} else {
if (direction >= 0) {
indexToSelect = 0;
} else {
indexToSelect = radioButtons.length - 1;
}
}
const radioToSelect = radioButtons[indexToSelect];
radioToSelect.focus();
radioToSelect.click();
}
private executeSearchQuery() {
this.queryController.deferExecuteQuery({
beforeExecuteQuery: () => logSortEvent(this.usageAnalytics, this.currentCriteria.sort + this.currentCriteria.direction)
});
}
private selectFirstCriteria() {
this.currentCriteria = this.options.sortCriteria[0];
this.updateQueryStateModel();
}
private selectNextCriteria() {
const indexOfCurrentCriteria = this.currentCriteria ? this.options.sortCriteria.indexOf(this.currentCriteria) : 0;
this.currentCriteria = this.options.sortCriteria[(indexOfCurrentCriteria + 1) % this.options.sortCriteria.length];
this.updateQueryStateModel();
}
private selectNextCriteriaAndExecuteQuery() {
const oldCriteria = this.currentCriteria;
this.selectNextCriteria();
if (oldCriteria != this.currentCriteria) {
this.executeSearchQuery();
}
}
private handleQueryStateChanged() {
this.update();
}
private update() {
// Basically, if the criteria in the model fits with one of ours, it'll become our active criteria
var sortCriteria = <string>this.queryStateModel.get(QueryStateModel.attributesEnum.sort);
if (Utils.isNonEmptyString(sortCriteria)) {
var criteriaFromModel = SortCriteria.parse(sortCriteria);
this.currentCriteria = find(this.options.sortCriteria, (criteria: SortCriteria) => criteriaFromModel.equals(criteria));
} else {
this.currentCriteria = null;
}
this.updateAppearance();
this.updateAccessibilityProperties();
}
private get captionIsDefined() {
return Utils.isNonEmptyString(this.options.caption);
}
private get currentDirection() {
return this.currentCriteria ? this.currentCriteria.direction : this.initialDirection;
}
private get initialDirection() {
return this.options.sortCriteria[0].direction;
}
private get displayedSortText() {
return this.captionIsDefined ? this.options.caption : this.element.textContent;
}
private handleBuildingQuery(data: IBuildingQueryEventArgs) {
Assert.exists(data);
var sort = this.queryStateModel.get(QueryStateModel.attributesEnum.sort);
if (sort == QueryStateModel.defaultAttributes.sort || this.isSelected()) {
if (this.currentCriteria) {
this.currentCriteria.putInQueryBuilder(data.queryBuilder);
}
}
}
private handleQuerySuccess(data: IQuerySuccessEventArgs) {
if (data.results.results.length == 0) {
$$(this.element).addClass('coveo-sort-hidden');
} else {
$$(this.element).removeClass('coveo-sort-hidden');
}
}
private handleQueryError(data: IQueryErrorEventArgs) {
$$(this.element).addClass('coveo-sort-hidden');
}
private isToggle(): boolean {
return this.options.sortCriteria.length > 1;
}
private isSelected(): boolean {
return Utils.exists(this.currentCriteria);
}
private updateAppearance() {
$$(this.element).toggleClass('coveo-selected', this.isSelected());
if (this.isToggle()) {
$$(this.element).removeClass('coveo-ascending');
$$(this.element).removeClass('coveo-descending');
if (this.isSelected()) {
$$(this.element).addClass(this.currentDirection === 'ascending' ? 'coveo-ascending' : 'coveo-descending');
}
}
}
private updateAccessibilityProperties() {
this.sortButton.setAttribute('aria-controls', this.resultListsIds);
const directionIsInitial = this.currentDirection === this.initialDirection;
this.sortButton.setAttribute('aria-checked', `${this.isSelected() && directionIsInitial}`);
if (this.isToggle()) {
this.directionButton.setAttribute('aria-controls', this.resultListsIds);
this.directionButton.setAttribute('aria-checked', `${this.isSelected() && !directionIsInitial}`);
}
}
private get resultListsIds() {
const resultLists = this.searchInterface.getComponents('ResultList') as Component[];
return resultLists.map(resultList => resultList.element.id).join(' ');
}
private getDirectionalLabel(direction: VALID_DIRECTION) {
const localizedCaption = l(this.displayedSortText);
return direction === VALID_DIRECTION.DESCENDING
? l('SortResultsByDescending', localizedCaption)
: l('SortResultsByAscending', localizedCaption);
}
private getOmnidirectionalLabel(): string {
const localizedCaption = l(this.displayedSortText);
return l('SortResultsBy', localizedCaption);
}
private updateQueryStateModel() {
this.queryStateModel.set(QueryStateModel.attributesEnum.sort, this.currentCriteria.toString());
}
}
Initialization.registerAutoCreateComponent(Sort);