UNPKG

coveo-search-ui

Version:

Coveo JavaScript Search Framework

411 lines (360 loc) • 16 kB
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);