coveo-search-ui
Version:
Coveo JavaScript Search Framework
458 lines (392 loc) • 15.8 kB
text/typescript
import 'styling/_ResponsiveTabs';
import { filter, each, last } from 'underscore';
import { InitializationEvents } from '../../events/InitializationEvents';
import { Logger } from '../../misc/Logger';
import { l } from '../../strings/Strings';
import { $$, Dom } from '../../utils/Dom';
import { EventsUtils } from '../../utils/EventsUtils';
import { SVGDom } from '../../utils/SVGDom';
import { SVGIcons } from '../../utils/SVGIcons';
import { Utils } from '../../utils/Utils';
import { Component } from '../Base/Component';
import { SearchInterface } from '../SearchInterface/SearchInterface';
import { Tab } from '../Tab/Tab';
import { ResponsiveComponents } from './ResponsiveComponents';
import { IResponsiveComponent, IResponsiveComponentOptions, ResponsiveComponentsManager } from './ResponsiveComponentsManager';
import { ResponsiveComponentsUtils } from './ResponsiveComponentsUtils';
import { AccessibleButton } from '../../utils/AccessibleButton';
import Popper from 'popper.js';
import { KeyboardUtils, KEYBOARD } from '../../utils/KeyboardUtils';
export class ResponsiveTabs implements IResponsiveComponent {
private static DROPDOWN_HEADER_LABEL_DEFAULT_VALUE = 'More';
private static TAB_IN_DROPDOWN_CSS_CLASS = 'coveo-tab-dropdown';
private static TAB_IN_DROPDOWN_HEADER_CSS_CLASS = `${ResponsiveTabs.TAB_IN_DROPDOWN_CSS_CLASS}-header`;
private static ACTIVE_DROPDOWN_CSS_CLASS = 'coveo-dropdown-header-active';
private static logger: Logger;
private dropdownHeader: Dom;
private dropdownContent: Dom;
private tabSection: Dom;
private searchInterface: SearchInterface;
private dropdownHeaderLabel: string;
private initialTabOrder: HTMLElement[];
private documentClickListener: EventListener;
private dropdownClickListener: EventListener;
private ignoreNextDocumentClick = false;
constructor(private coveoRoot: Dom, public ID: string) {
this.dropdownHeaderLabel = this.getDropdownHeaderLabel();
this.searchInterface = <SearchInterface>Component.get(this.coveoRoot.el, SearchInterface, false);
this.dropdownContent = this.buildDropdownContent();
this.dropdownHeader = this.buildDropdownHeader();
this.bindDropdownContentEvents();
this.bindDropdownHeaderEvents();
this.tabSection = $$(<HTMLElement>this.coveoRoot.find('.coveo-tab-section'));
this.manageTabSwapping();
this.bindNukeEvents();
this.initialTabOrder = [...this.tabsInTabSection];
}
public static init(root: HTMLElement, component: Component, options: IResponsiveComponentOptions) {
this.logger = new Logger('ResponsiveTabs');
if (!$$(root).find('.coveo-tab-section')) {
this.logger.info('No element with class coveo-tab-section. Responsive tabs cannot be enabled.');
return;
}
ResponsiveComponentsManager.register(ResponsiveTabs, $$(root), Tab.ID, component, options);
}
public handleResizeEvent(): void {
if (this.needSmallMode() && !ResponsiveComponentsUtils.isSmallTabsActivated(this.coveoRoot)) {
this.changeToSmallMode();
} else if (!this.needSmallMode() && ResponsiveComponentsUtils.isSmallTabsActivated(this.coveoRoot)) {
this.changeToLargeMode();
}
if (this.shouldAddTabsToDropdown()) {
this.addTabsToDropdown();
} else if (this.shouldRemoveTabsFromDropdown()) {
this.removeTabsFromDropdown();
}
if (this.isDropdownOpen()) {
this.positionPopup();
}
}
private needSmallMode(): boolean {
// Ignore everything if the responsiveMode is not auto.
if (!this.searchInterface) {
return this.shouldAutoModeResolveToSmall();
}
switch (this.searchInterface.responsiveComponents.getResponsiveMode()) {
case 'small':
case 'medium':
return true;
case 'auto':
default:
return this.shouldAutoModeResolveToSmall();
}
}
private shouldAutoModeResolveToSmall() {
const mediumWidth = this.searchInterface
? this.searchInterface.responsiveComponents.getMediumScreenWidth()
: new ResponsiveComponents().getMediumScreenWidth();
if (this.coveoRoot.width() <= mediumWidth) {
return true;
} else if (!ResponsiveComponentsUtils.isSmallTabsActivated(this.coveoRoot)) {
return this.isOverflowing(this.tabSection.el);
} else {
return this.isLargeFormatOverflowing();
}
}
private changeToSmallMode(): void {
ResponsiveComponentsUtils.activateSmallTabs(this.coveoRoot);
}
private changeToLargeMode(): void {
this.emptyDropdown();
this.cleanUpDropdown();
ResponsiveComponentsUtils.deactivateSmallTabs(this.coveoRoot);
}
private shouldAddTabsToDropdown(): boolean {
return (
(this.isOverflowing(this.tabSection.el) || this.tabSection.el.clientWidth === 0) &&
ResponsiveComponentsUtils.isSmallTabsActivated(this.coveoRoot)
);
}
private addTabsToDropdown(): void {
let currentTab;
if (!this.tabSection.find(`.${ResponsiveTabs.TAB_IN_DROPDOWN_HEADER_CSS_CLASS}`)) {
const facetDropdownHeader = this.tabSection.find('.coveo-facet-dropdown-header');
if (facetDropdownHeader) {
this.dropdownHeader.insertBefore(facetDropdownHeader);
} else {
this.tabSection.el.appendChild(this.dropdownHeader.el);
}
}
for (let i = this.initialTabOrder.length - 1; i >= 0; i--) {
currentTab = this.initialTabOrder[i];
if (this.tabIsSelected(currentTab) && i > 0) {
currentTab = this.initialTabOrder[--i];
}
this.addToDropdownIfNeeded(currentTab);
if (!this.isOverflowing(this.tabSection.el)) {
break;
}
}
}
private shouldRemoveTabsFromDropdown(): boolean {
return (
!this.isOverflowing(this.tabSection.el) &&
this.tabSection.el.clientWidth !== 0 &&
ResponsiveComponentsUtils.isSmallTabsActivated(this.coveoRoot) &&
!this.isDropdownEmpty()
);
}
private removeTabsFromDropdown() {
const dropdownTabs = this.tabsInTabDropdown;
let current: HTMLElement;
while (!this.isOverflowing(this.tabSection.el) && !this.isDropdownEmpty()) {
current = dropdownTabs.shift();
this.removeFromDropdownIfNeeded(current);
this.fromDropdownToTabSection($$(current));
}
if (this.isOverflowing(this.tabSection.el)) {
const unselectedTabs = filter(this.tabsInTabSection, tab => !this.tabIsSelected(tab));
this.addToDropdownIfNeeded(unselectedTabs.pop());
}
if (this.isDropdownEmpty()) {
this.cleanUpDropdown();
}
}
private emptyDropdown(): void {
if (!this.isDropdownEmpty()) {
const dropdownTabs = this.tabsInTabDropdown;
while (!this.isDropdownEmpty()) {
const current = dropdownTabs.shift();
this.removeFromDropdownIfNeeded(current);
}
this.initialTabOrder.forEach(tab => this.tabSection.append(tab));
}
}
private isLargeFormatOverflowing(): boolean {
const virtualTabSection = $$(<HTMLElement>this.tabSection.el.cloneNode(true));
const dropdownHeader = virtualTabSection.find(`.${ResponsiveTabs.TAB_IN_DROPDOWN_HEADER_CSS_CLASS}`);
if (dropdownHeader) {
virtualTabSection.el.removeChild(dropdownHeader);
}
virtualTabSection.el.style.position = 'absolute';
virtualTabSection.el.style.visibility = 'hidden';
if (!this.isDropdownEmpty()) {
each(this.dropdownContent.findAll('.CoveoTab'), tab => {
virtualTabSection.el.appendChild(tab.cloneNode(true));
});
}
virtualTabSection.insertBefore(this.tabSection.el);
ResponsiveComponentsUtils.deactivateSmallTabs(this.coveoRoot);
const isOverflowing = this.isOverflowing(this.tabSection.el) || this.isOverflowing(virtualTabSection.el);
ResponsiveComponentsUtils.activateSmallTabs(this.coveoRoot);
virtualTabSection.detach();
return isOverflowing;
}
private isOverflowing(el: HTMLElement) {
return el.clientWidth < el.scrollWidth;
}
private buildDropdownHeader(): Dom {
const dropdownHeader = $$('a', { className: `coveo-dropdown-header ${ResponsiveTabs.TAB_IN_DROPDOWN_HEADER_CSS_CLASS}` });
const content = $$('p');
content.text(this.dropdownHeaderLabel);
const icon = $$('span', { className: 'coveo-more-tabs' }, SVGIcons.icons.arrowDown);
SVGDom.addClassToSVGInContainer(icon.el, 'coveo-more-tabs-svg');
content.el.appendChild(icon.el);
dropdownHeader.el.appendChild(content.el);
return dropdownHeader;
}
private bindDropdownHeaderEvents() {
const toggle = (event: Event) => {
if (this.isDropdownOpen()) {
this.closeDropdown();
} else {
this.openDropdown();
}
if (event.type === 'click') {
this.ignoreNextDocumentClick = true;
}
};
new AccessibleButton()
.withElement(this.dropdownHeader)
.withSelectAction(toggle)
.withLabel(this.getDropdownHeaderLabel())
.build();
}
private buildDropdownContent() {
const dropdownContent = $$('div', {
className: 'coveo-tab-list-container ' + SearchInterface.SMALL_INTERFACE_CLASS_NAME
});
const contentList = $$('ol', { className: 'coveo-tab-list' });
dropdownContent.el.appendChild(contentList.el);
return dropdownContent;
}
private bindDropdownContentEvents() {
this.dropdownClickListener = () => {
if (this.isDropdownOpen()) {
this.ignoreNextDocumentClick = true;
}
};
this.documentClickListener = event => {
if (!this.ignoreNextDocumentClick) {
this.closeDropdown();
}
this.ignoreNextDocumentClick = false;
};
$$(this.dropdownHeader).on('click', this.dropdownClickListener);
$$(this.dropdownContent).on('click', this.dropdownClickListener);
}
private isDropdownOpen(): boolean {
return this.dropdownHeader.hasClass(ResponsiveTabs.ACTIVE_DROPDOWN_CSS_CLASS);
}
private closeDropdown(): void {
$$(document.documentElement).off('click', this.documentClickListener);
this.dropdownContent.detach();
this.dropdownHeader.removeClass(ResponsiveTabs.ACTIVE_DROPDOWN_CSS_CLASS);
}
private openDropdown(): void {
$$(document.documentElement).on('click', this.documentClickListener);
this.positionPopup();
this.dropdownHeader.addClass(ResponsiveTabs.ACTIVE_DROPDOWN_CSS_CLASS);
}
private addToDropdownIfNeeded(tab: HTMLElement) {
if (!this.canAddTabToDropdown(tab)) {
return;
}
$$(tab).addClass(ResponsiveTabs.TAB_IN_DROPDOWN_CSS_CLASS);
const list = $$(this.dropdownContent.find('ol'));
const listElement = $$('li', null, tab);
list.prepend(listElement.el);
}
private removeFromDropdownIfNeeded(tab: HTMLElement) {
if (!this.canRemoveTabFromDropdown(tab)) {
return;
}
$$(tab as HTMLElement).removeClass(ResponsiveTabs.TAB_IN_DROPDOWN_CSS_CLASS);
$$(tab.parentElement).detach();
}
private canAddTabToDropdown(tab: HTMLElement) {
return tab && !this.tabIsInDropdown(tab) && this.dropdownHeader;
}
private canRemoveTabFromDropdown(tab: HTMLElement) {
return tab && this.tabIsInDropdown(tab) && this.dropdownContent;
}
private cleanUpDropdown() {
this.dropdownHeader.removeClass(ResponsiveTabs.ACTIVE_DROPDOWN_CSS_CLASS);
this.dropdownHeader.detach();
this.dropdownContent.detach();
}
private isDropdownEmpty(): boolean {
if (this.dropdownContent) {
const tabs = this.dropdownContent.findAll('.CoveoTab');
return tabs.length == 0;
}
return false;
}
private manageTabSwapping() {
each(this.coveoRoot.findAll('.' + Component.computeCssClassNameForType(this.ID)), tabElement => {
const tab = $$(tabElement);
const fadeOutFadeIn = event => {
const lastTabInSection = this.tabsInTabSection.pop();
if (event.propertyName == 'opacity') {
if (tab.el.style.opacity == '0') {
$$(lastTabInSection).addClass(ResponsiveTabs.TAB_IN_DROPDOWN_CSS_CLASS);
tab.replaceWith(lastTabInSection);
tab.removeClass(ResponsiveTabs.TAB_IN_DROPDOWN_CSS_CLASS);
this.fromDropdownToTabSection(tab);
// Because of the DOM manipulation, sometimes the animation will not trigger. Accessing the computed styles makes sure
// the animation will happen.
window.getComputedStyle(tab.el).opacity;
window.getComputedStyle(lastTabInSection).opacity;
tab.el.style.opacity = lastTabInSection.style.opacity = '1';
} else if (tab.el.style.opacity == '1') {
this.closeDropdown();
EventsUtils.removePrefixedEvent(tab.el, 'TransitionEnd', fadeOutFadeIn);
this.handleResizeEvent();
}
}
};
const swapOnSelect = () => {
if (this.tabIsInDropdown(tab)) {
let lastTabInSection = this.tabsInTabSection.pop();
if (lastTabInSection) {
EventsUtils.addPrefixedEvent(tab.el, 'TransitionEnd', fadeOutFadeIn);
tab.el.style.opacity = lastTabInSection.style.opacity = '0';
}
}
};
tab.on('click', () => swapOnSelect());
tab.on('keyup', KeyboardUtils.keypressAction(KEYBOARD.ENTER, swapOnSelect));
tab.on('blur', (e: FocusEvent) => {
if (e.relatedTarget && !this.tabIsInDropdown(e.relatedTarget as HTMLElement)) {
this.closeDropdown();
}
});
});
}
private bindNukeEvents() {
$$(this.coveoRoot).on(InitializationEvents.nuke, () => {
$$(document.documentElement).off('click', this.documentClickListener);
});
}
private positionPopup() {
this.dropdownContent.insertAfter(this.dropdownHeader.el);
new Popper(this.dropdownHeader.el, this.dropdownContent.el, {
modifiers: {
preventOverflow: {
boundariesElement: this.coveoRoot.el
}
}
});
}
private fromDropdownToTabSection(tab: Dom) {
const lastTabInTabSection = last(this.tabsInTabSection);
if (!lastTabInTabSection) {
this.tabSection.prepend(tab.el);
return;
}
const comesAfterInitialTabOrder = this.initialTabOrder.indexOf(tab.el) > this.initialTabOrder.indexOf(lastTabInTabSection);
if (comesAfterInitialTabOrder) {
tab.insertAfter(lastTabInTabSection);
} else {
tab.insertBefore(lastTabInTabSection);
}
}
private getDropdownHeaderLabel() {
let dropdownHeaderLabel: string;
each($$(this.coveoRoot.find('.coveo-tab-section')).findAll('.' + Component.computeCssClassName(Tab)), tabElement => {
const tab = <Tab>Component.get(tabElement, Tab);
if (!dropdownHeaderLabel && tab && tab.options.dropdownHeaderLabel) {
dropdownHeaderLabel = tab.options.dropdownHeaderLabel;
}
});
if (!dropdownHeaderLabel) {
dropdownHeaderLabel = l(ResponsiveTabs.DROPDOWN_HEADER_LABEL_DEFAULT_VALUE);
}
return dropdownHeaderLabel;
}
private tabIsSelected(tab: Dom | HTMLElement) {
return $$(tab as HTMLElement).hasClass('coveo-selected');
}
private tabIsInDropdown(tab: Dom | HTMLElement) {
return $$(tab as HTMLElement).hasClass(ResponsiveTabs.TAB_IN_DROPDOWN_CSS_CLASS);
}
private get tabsInTabSection(): HTMLElement[] {
const tabsInSection = [];
each(this.tabSection.children(), childElement => {
if (Utils.isHtmlElement(childElement)) {
const child = $$(childElement);
const childHasTabCssClassName = child.hasClass(Component.computeCssClassNameForType(this.ID));
if (!this.tabIsInDropdown(child) && childHasTabCssClassName) {
tabsInSection.push(child.el);
}
}
});
return tabsInSection;
}
private get tabsInTabDropdown(): HTMLElement[] {
if (!this.dropdownContent) {
return [];
}
return this.dropdownContent.findAll(`.${ResponsiveTabs.TAB_IN_DROPDOWN_CSS_CLASS}`);
}
}