2022-04-25 17:28:58 +02:00
|
|
|
import Dom from '../dom';
|
|
|
|
import Listeners from './listeners';
|
|
|
|
import Flipper from '../flipper';
|
|
|
|
import SearchInput from './search-input';
|
|
|
|
import EventsDispatcher from './events';
|
|
|
|
import { isMobileScreen, keyCodes, cacheable } from '../utils';
|
2022-05-01 13:43:56 +02:00
|
|
|
import ScrollLocker from './scroll-locker';
|
2022-11-03 18:52:33 +01:00
|
|
|
import { PopoverItem, PopoverItemWithConfirmation } from '../../../types';
|
2022-04-25 17:28:58 +02:00
|
|
|
|
|
|
|
/**
|
2022-11-03 18:52:33 +01:00
|
|
|
* Event that can be triggered by the Popover
|
2022-04-25 17:28:58 +02:00
|
|
|
*/
|
2022-11-03 18:52:33 +01:00
|
|
|
export enum PopoverEvent {
|
2022-04-25 17:28:58 +02:00
|
|
|
/**
|
2022-11-03 18:52:33 +01:00
|
|
|
* When popover overlay is clicked
|
2022-04-25 17:28:58 +02:00
|
|
|
*/
|
2022-11-03 18:52:33 +01:00
|
|
|
OverlayClicked = 'overlay-clicked',
|
2022-04-25 17:28:58 +02:00
|
|
|
|
|
|
|
/**
|
2022-11-03 18:52:33 +01:00
|
|
|
* When popover closes
|
2022-04-25 17:28:58 +02:00
|
|
|
*/
|
2022-11-03 18:52:33 +01:00
|
|
|
Close = 'close'
|
|
|
|
}
|
2022-04-25 17:28:58 +02:00
|
|
|
|
2022-11-03 18:52:33 +01:00
|
|
|
/**
|
|
|
|
* Popover is the UI element for displaying vertical lists
|
|
|
|
*/
|
|
|
|
export default class Popover extends EventsDispatcher<PopoverEvent> {
|
2022-04-25 17:28:58 +02:00
|
|
|
/**
|
2022-11-03 18:52:33 +01:00
|
|
|
* Flipper - module for keyboard iteration between elements
|
2022-04-25 17:28:58 +02:00
|
|
|
*/
|
2022-11-03 18:52:33 +01:00
|
|
|
public flipper: Flipper;
|
2022-04-25 17:28:58 +02:00
|
|
|
|
|
|
|
/**
|
2022-11-03 18:52:33 +01:00
|
|
|
* Items list to be displayed
|
2022-04-25 17:28:58 +02:00
|
|
|
*/
|
2022-11-03 18:52:33 +01:00
|
|
|
private readonly items: PopoverItem[];
|
2022-04-25 17:28:58 +02:00
|
|
|
|
|
|
|
/**
|
2022-11-03 18:52:33 +01:00
|
|
|
* Arbitrary html element to be inserted before items list
|
2022-04-25 17:28:58 +02:00
|
|
|
*/
|
2022-11-03 18:52:33 +01:00
|
|
|
private readonly customContent: HTMLElement;
|
2022-04-25 17:28:58 +02:00
|
|
|
|
|
|
|
/**
|
2022-11-03 18:52:33 +01:00
|
|
|
* List of html elements inside custom content area that should be available for keyboard navigation
|
2022-04-25 17:28:58 +02:00
|
|
|
*/
|
2022-11-03 18:52:33 +01:00
|
|
|
private readonly customContentFlippableItems: HTMLElement[] = [];
|
2022-04-25 17:28:58 +02:00
|
|
|
|
2022-05-01 14:09:16 +02:00
|
|
|
/**
|
|
|
|
* Stores the visibility state.
|
|
|
|
*/
|
|
|
|
private isShown = false;
|
|
|
|
|
2022-04-25 17:28:58 +02:00
|
|
|
/**
|
|
|
|
* Created nodes
|
|
|
|
*/
|
|
|
|
private nodes: {
|
|
|
|
wrapper: HTMLElement;
|
|
|
|
popover: HTMLElement;
|
|
|
|
items: HTMLElement;
|
|
|
|
nothingFound: HTMLElement;
|
|
|
|
overlay: HTMLElement;
|
|
|
|
} = {
|
2022-11-25 18:56:50 +01:00
|
|
|
wrapper: null,
|
|
|
|
popover: null,
|
|
|
|
items: null,
|
|
|
|
nothingFound: null,
|
|
|
|
overlay: null,
|
|
|
|
};
|
2022-04-25 17:28:58 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Additional wrapper's class name
|
|
|
|
*/
|
|
|
|
private readonly className: string;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Listeners util instance
|
|
|
|
*/
|
|
|
|
private listeners: Listeners;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Pass true to enable local search field
|
|
|
|
*/
|
|
|
|
private readonly searchable: boolean;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Instance of the Search Input
|
|
|
|
*/
|
|
|
|
private search: SearchInput;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Label for the 'Filter' placeholder
|
|
|
|
*/
|
|
|
|
private readonly filterLabel: string;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Label for the 'Nothing found' message
|
|
|
|
*/
|
|
|
|
private readonly nothingFoundLabel: string;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Style classes
|
|
|
|
*/
|
2022-11-03 18:52:33 +01:00
|
|
|
public static get CSS(): {
|
2022-04-25 17:28:58 +02:00
|
|
|
popover: string;
|
|
|
|
popoverOpened: string;
|
|
|
|
itemsWrapper: string;
|
|
|
|
item: string;
|
|
|
|
itemHidden: string;
|
|
|
|
itemFocused: string;
|
2022-11-03 18:52:33 +01:00
|
|
|
itemActive: string;
|
|
|
|
itemDisabled: string;
|
2022-04-25 17:28:58 +02:00
|
|
|
itemLabel: string;
|
|
|
|
itemIcon: string;
|
|
|
|
itemSecondaryLabel: string;
|
2022-11-03 18:52:33 +01:00
|
|
|
itemConfirmation: string;
|
|
|
|
itemNoHover: string;
|
|
|
|
itemNoFocus: string;
|
2022-04-25 17:28:58 +02:00
|
|
|
noFoundMessage: string;
|
|
|
|
noFoundMessageShown: string;
|
|
|
|
popoverOverlay: string;
|
|
|
|
popoverOverlayHidden: string;
|
2022-11-03 18:52:33 +01:00
|
|
|
customContent: string;
|
|
|
|
customContentHidden: string;
|
2022-04-25 17:28:58 +02:00
|
|
|
} {
|
|
|
|
return {
|
|
|
|
popover: 'ce-popover',
|
|
|
|
popoverOpened: 'ce-popover--opened',
|
|
|
|
itemsWrapper: 'ce-popover__items',
|
|
|
|
item: 'ce-popover__item',
|
|
|
|
itemHidden: 'ce-popover__item--hidden',
|
|
|
|
itemFocused: 'ce-popover__item--focused',
|
2022-11-03 18:52:33 +01:00
|
|
|
itemActive: 'ce-popover__item--active',
|
|
|
|
itemDisabled: 'ce-popover__item--disabled',
|
|
|
|
itemConfirmation: 'ce-popover__item--confirmation',
|
|
|
|
itemNoHover: 'ce-popover__item--no-visible-hover',
|
|
|
|
itemNoFocus: 'ce-popover__item--no-visible-focus',
|
2022-04-25 17:28:58 +02:00
|
|
|
itemLabel: 'ce-popover__item-label',
|
|
|
|
itemIcon: 'ce-popover__item-icon',
|
|
|
|
itemSecondaryLabel: 'ce-popover__item-secondary-label',
|
|
|
|
noFoundMessage: 'ce-popover__no-found',
|
|
|
|
noFoundMessageShown: 'ce-popover__no-found--shown',
|
|
|
|
popoverOverlay: 'ce-popover__overlay',
|
|
|
|
popoverOverlayHidden: 'ce-popover__overlay--hidden',
|
2022-11-03 18:52:33 +01:00
|
|
|
customContent: 'ce-popover__custom-content',
|
|
|
|
customContentHidden: 'ce-popover__custom-content--hidden',
|
2022-04-25 17:28:58 +02:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2022-05-01 13:43:56 +02:00
|
|
|
/**
|
|
|
|
* ScrollLocker instance
|
|
|
|
*/
|
2022-11-25 18:56:50 +01:00
|
|
|
private scrollLocker = new ScrollLocker();
|
2022-05-01 13:43:56 +02:00
|
|
|
|
2022-11-03 18:52:33 +01:00
|
|
|
/**
|
|
|
|
* Editor container element
|
|
|
|
*/
|
|
|
|
private scopeElement: HTMLElement;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Stores data on popover items that are in confirmation state
|
|
|
|
*/
|
|
|
|
private itemsRequiringConfirmation: { [itemIndex: number]: PopoverItem } = {};
|
|
|
|
|
2022-04-25 17:28:58 +02:00
|
|
|
/**
|
|
|
|
* Creates the Popover
|
|
|
|
*
|
|
|
|
* @param options - config
|
|
|
|
* @param options.items - config for items to be displayed
|
|
|
|
* @param options.className - additional class name to be added to the popover wrapper
|
|
|
|
* @param options.filterLabel - label for the search Field
|
|
|
|
* @param options.nothingFoundLabel - label of the 'nothing found' message
|
2022-11-03 18:52:33 +01:00
|
|
|
* @param options.customContent - arbitrary html element to be inserted before items list
|
|
|
|
* @param options.customContentFlippableItems - list of html elements inside custom content area that should be available for keyboard navigation
|
|
|
|
* @param options.scopeElement - editor container element
|
2022-04-25 17:28:58 +02:00
|
|
|
*/
|
2022-11-03 18:52:33 +01:00
|
|
|
constructor({ items, className, searchable, filterLabel, nothingFoundLabel, customContent, customContentFlippableItems, scopeElement }: {
|
2022-04-25 17:28:58 +02:00
|
|
|
items: PopoverItem[];
|
|
|
|
className?: string;
|
|
|
|
searchable?: boolean;
|
|
|
|
filterLabel: string;
|
|
|
|
nothingFoundLabel: string;
|
2022-11-03 18:52:33 +01:00
|
|
|
customContent?: HTMLElement;
|
|
|
|
customContentFlippableItems?: HTMLElement[];
|
|
|
|
scopeElement: HTMLElement;
|
2022-04-25 17:28:58 +02:00
|
|
|
}) {
|
|
|
|
super();
|
|
|
|
this.items = items;
|
2022-11-03 18:52:33 +01:00
|
|
|
this.customContent = customContent;
|
|
|
|
this.customContentFlippableItems = customContentFlippableItems;
|
2022-04-25 17:28:58 +02:00
|
|
|
this.className = className || '';
|
|
|
|
this.searchable = searchable;
|
|
|
|
this.listeners = new Listeners();
|
2022-11-03 18:52:33 +01:00
|
|
|
this.scopeElement = scopeElement;
|
2022-04-25 17:28:58 +02:00
|
|
|
|
|
|
|
this.filterLabel = filterLabel;
|
|
|
|
this.nothingFoundLabel = nothingFoundLabel;
|
|
|
|
|
|
|
|
this.render();
|
|
|
|
this.enableFlipper();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns rendered wrapper
|
|
|
|
*/
|
|
|
|
public getElement(): HTMLElement {
|
|
|
|
return this.nodes.wrapper;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Shows the Popover
|
|
|
|
*/
|
|
|
|
public show(): void {
|
2022-11-03 18:52:33 +01:00
|
|
|
/**
|
|
|
|
* Open the popover above the button
|
|
|
|
* if there is not enough available space below it
|
|
|
|
*/
|
|
|
|
if (!this.shouldOpenPopoverBottom) {
|
|
|
|
this.nodes.wrapper.style.setProperty('--popover-height', this.calculateHeight() + 'px');
|
|
|
|
this.nodes.wrapper.classList.add(this.className + '--opened-top');
|
|
|
|
}
|
|
|
|
|
2022-05-01 14:09:16 +02:00
|
|
|
/**
|
|
|
|
* Clear search and items scrolling
|
|
|
|
*/
|
2022-11-03 18:52:33 +01:00
|
|
|
if (this.search) {
|
|
|
|
this.search.clear();
|
|
|
|
}
|
|
|
|
|
2022-05-01 14:09:16 +02:00
|
|
|
this.nodes.items.scrollTop = 0;
|
|
|
|
|
2022-04-25 17:28:58 +02:00
|
|
|
this.nodes.popover.classList.add(Popover.CSS.popoverOpened);
|
|
|
|
this.nodes.overlay.classList.remove(Popover.CSS.popoverOverlayHidden);
|
2022-11-03 18:52:33 +01:00
|
|
|
this.flipper.activate(this.flippableElements);
|
2022-04-25 17:28:58 +02:00
|
|
|
|
|
|
|
if (this.searchable) {
|
2022-11-03 18:52:33 +01:00
|
|
|
setTimeout(() => {
|
2022-04-25 17:28:58 +02:00
|
|
|
this.search.focus();
|
2022-11-25 18:56:50 +01:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
2022-11-03 18:52:33 +01:00
|
|
|
}, 100);
|
2022-04-25 17:28:58 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if (isMobileScreen()) {
|
2022-05-01 13:43:56 +02:00
|
|
|
this.scrollLocker.lock();
|
2022-04-25 17:28:58 +02:00
|
|
|
}
|
2022-05-01 14:09:16 +02:00
|
|
|
|
|
|
|
this.isShown = true;
|
2022-04-25 17:28:58 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Hides the Popover
|
|
|
|
*/
|
|
|
|
public hide(): void {
|
2022-05-01 14:09:16 +02:00
|
|
|
/**
|
|
|
|
* If it's already hidden, do nothing
|
|
|
|
* to prevent extra DOM operations
|
|
|
|
*/
|
|
|
|
if (!this.isShown) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2022-04-25 17:28:58 +02:00
|
|
|
this.nodes.popover.classList.remove(Popover.CSS.popoverOpened);
|
|
|
|
this.nodes.overlay.classList.add(Popover.CSS.popoverOverlayHidden);
|
|
|
|
this.flipper.deactivate();
|
|
|
|
|
|
|
|
if (isMobileScreen()) {
|
2022-05-01 13:43:56 +02:00
|
|
|
this.scrollLocker.unlock();
|
2022-04-25 17:28:58 +02:00
|
|
|
}
|
2022-05-01 14:09:16 +02:00
|
|
|
|
|
|
|
this.isShown = false;
|
2022-11-03 18:52:33 +01:00
|
|
|
this.nodes.wrapper.classList.remove(this.className + '--opened-top');
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Remove confirmation state from items
|
|
|
|
*/
|
|
|
|
const confirmationStateItems = Array.from(this.nodes.items.querySelectorAll(`.${Popover.CSS.itemConfirmation}`));
|
|
|
|
|
|
|
|
confirmationStateItems.forEach((itemEl: HTMLElement) => this.cleanUpConfirmationStateForItem(itemEl));
|
|
|
|
|
|
|
|
this.disableSpecialHoverAndFocusBehavior();
|
|
|
|
|
|
|
|
this.emit(PopoverEvent.Close);
|
2022-04-25 17:28:58 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Clears memory
|
|
|
|
*/
|
|
|
|
public destroy(): void {
|
2022-11-03 18:52:33 +01:00
|
|
|
this.flipper.deactivate();
|
2022-04-25 17:28:58 +02:00
|
|
|
this.listeners.removeAll();
|
2022-11-03 18:52:33 +01:00
|
|
|
this.disableSpecialHoverAndFocusBehavior();
|
|
|
|
|
|
|
|
if (isMobileScreen()) {
|
|
|
|
this.scrollLocker.unlock();
|
|
|
|
}
|
2022-04-25 17:28:58 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns true if some item is focused
|
|
|
|
*/
|
|
|
|
public hasFocus(): boolean {
|
|
|
|
return this.flipper.hasFocus();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Helps to calculate height of popover while it is not displayed on screen.
|
|
|
|
* Renders invisible clone of popover to get actual height.
|
|
|
|
*/
|
|
|
|
@cacheable
|
2022-11-03 18:52:33 +01:00
|
|
|
private calculateHeight(): number {
|
2022-04-25 17:28:58 +02:00
|
|
|
let height = 0;
|
|
|
|
const popoverClone = this.nodes.popover.cloneNode(true) as HTMLElement;
|
|
|
|
|
|
|
|
popoverClone.style.visibility = 'hidden';
|
|
|
|
popoverClone.style.position = 'absolute';
|
|
|
|
popoverClone.style.top = '-1000px';
|
|
|
|
popoverClone.classList.add(Popover.CSS.popoverOpened);
|
|
|
|
document.body.appendChild(popoverClone);
|
|
|
|
height = popoverClone.offsetHeight;
|
|
|
|
popoverClone.remove();
|
|
|
|
|
|
|
|
return height;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Makes the UI
|
|
|
|
*/
|
|
|
|
private render(): void {
|
|
|
|
this.nodes.wrapper = Dom.make('div', this.className);
|
|
|
|
this.nodes.popover = Dom.make('div', Popover.CSS.popover);
|
|
|
|
this.nodes.wrapper.appendChild(this.nodes.popover);
|
|
|
|
|
|
|
|
this.nodes.overlay = Dom.make('div', [Popover.CSS.popoverOverlay, Popover.CSS.popoverOverlayHidden]);
|
|
|
|
this.nodes.wrapper.appendChild(this.nodes.overlay);
|
|
|
|
|
|
|
|
if (this.searchable) {
|
|
|
|
this.addSearch(this.nodes.popover);
|
|
|
|
}
|
|
|
|
|
2022-11-03 18:52:33 +01:00
|
|
|
if (this.customContent) {
|
|
|
|
this.customContent.classList.add(Popover.CSS.customContent);
|
|
|
|
this.nodes.popover.appendChild(this.customContent);
|
|
|
|
}
|
|
|
|
|
2022-04-25 17:28:58 +02:00
|
|
|
this.nodes.items = Dom.make('div', Popover.CSS.itemsWrapper);
|
|
|
|
this.items.forEach(item => {
|
|
|
|
this.nodes.items.appendChild(this.createItem(item));
|
|
|
|
});
|
|
|
|
|
|
|
|
this.nodes.popover.appendChild(this.nodes.items);
|
|
|
|
this.nodes.nothingFound = Dom.make('div', [ Popover.CSS.noFoundMessage ], {
|
|
|
|
textContent: this.nothingFoundLabel,
|
|
|
|
});
|
|
|
|
|
|
|
|
this.nodes.popover.appendChild(this.nodes.nothingFound);
|
|
|
|
|
2022-11-03 18:52:33 +01:00
|
|
|
this.listeners.on(this.nodes.popover, 'click', (event: PointerEvent) => {
|
2022-04-25 17:28:58 +02:00
|
|
|
const clickedItem = (event.target as HTMLElement).closest(`.${Popover.CSS.item}`) as HTMLElement;
|
|
|
|
|
|
|
|
if (clickedItem) {
|
2022-11-03 18:52:33 +01:00
|
|
|
this.itemClicked(clickedItem, event as PointerEvent);
|
2022-04-25 17:28:58 +02:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
this.listeners.on(this.nodes.overlay, 'click', () => {
|
|
|
|
this.emit(PopoverEvent.OverlayClicked);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Adds the s4arch field to passed element
|
|
|
|
*
|
|
|
|
* @param holder - where to append search input
|
|
|
|
*/
|
|
|
|
private addSearch(holder: HTMLElement): void {
|
|
|
|
this.search = new SearchInput({
|
|
|
|
items: this.items,
|
|
|
|
placeholder: this.filterLabel,
|
|
|
|
onSearch: (filteredItems): void => {
|
2022-11-03 18:52:33 +01:00
|
|
|
const searchResultElements = [];
|
2022-04-25 17:28:58 +02:00
|
|
|
|
|
|
|
this.items.forEach((item, index) => {
|
|
|
|
const itemElement = this.nodes.items.children[index];
|
|
|
|
|
|
|
|
if (filteredItems.includes(item)) {
|
2022-11-03 18:52:33 +01:00
|
|
|
searchResultElements.push(itemElement);
|
2022-04-25 17:28:58 +02:00
|
|
|
itemElement.classList.remove(Popover.CSS.itemHidden);
|
|
|
|
} else {
|
|
|
|
itemElement.classList.add(Popover.CSS.itemHidden);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2022-11-03 18:52:33 +01:00
|
|
|
this.nodes.nothingFound.classList.toggle(Popover.CSS.noFoundMessageShown, searchResultElements.length === 0);
|
2022-04-25 17:28:58 +02:00
|
|
|
|
|
|
|
/**
|
2022-11-03 18:52:33 +01:00
|
|
|
* In order to make keyboard navigation work correctly, flipper should be reactivated with only visible items.
|
|
|
|
* As custom html content is not displayed while search, it should be excluded from keyboard navigation.
|
2022-04-25 17:28:58 +02:00
|
|
|
*/
|
2022-11-03 18:52:33 +01:00
|
|
|
const allItemsDisplayed = filteredItems.length === this.items.length;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Contains list of elements available for keyboard navigation considering search query applied
|
|
|
|
*/
|
|
|
|
const flippableElements = allItemsDisplayed ? this.flippableElements : searchResultElements;
|
|
|
|
|
|
|
|
if (this.customContent) {
|
|
|
|
this.customContent.classList.toggle(Popover.CSS.customContentHidden, !allItemsDisplayed);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.flipper.isActivated) {
|
|
|
|
/**
|
|
|
|
* Update flipper items with only visible
|
|
|
|
*/
|
|
|
|
this.reactivateFlipper(flippableElements);
|
|
|
|
this.flipper.focusFirst();
|
|
|
|
}
|
2022-04-25 17:28:58 +02:00
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
const searchField = this.search.getElement();
|
|
|
|
|
|
|
|
holder.appendChild(searchField);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Renders the single item
|
|
|
|
*
|
|
|
|
* @param item - item data to be rendered
|
|
|
|
*/
|
|
|
|
private createItem(item: PopoverItem): HTMLElement {
|
|
|
|
const el = Dom.make('div', Popover.CSS.item);
|
|
|
|
|
2022-11-03 18:52:33 +01:00
|
|
|
if (item.name) {
|
|
|
|
el.dataset.itemName = item.name;
|
|
|
|
}
|
2022-04-25 17:28:58 +02:00
|
|
|
const label = Dom.make('div', Popover.CSS.itemLabel, {
|
|
|
|
innerHTML: item.label,
|
|
|
|
});
|
|
|
|
|
|
|
|
if (item.icon) {
|
|
|
|
el.appendChild(Dom.make('div', Popover.CSS.itemIcon, {
|
|
|
|
innerHTML: item.icon,
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
|
|
|
el.appendChild(label);
|
|
|
|
|
|
|
|
if (item.secondaryLabel) {
|
|
|
|
el.appendChild(Dom.make('div', Popover.CSS.itemSecondaryLabel, {
|
|
|
|
textContent: item.secondaryLabel,
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
2022-11-03 18:52:33 +01:00
|
|
|
if (item.isActive) {
|
|
|
|
el.classList.add(Popover.CSS.itemActive);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (item.isDisabled) {
|
|
|
|
el.classList.add(Popover.CSS.itemDisabled);
|
|
|
|
}
|
|
|
|
|
2022-04-25 17:28:58 +02:00
|
|
|
return el;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Item click handler
|
|
|
|
*
|
|
|
|
* @param itemEl - clicked item
|
2022-11-03 18:52:33 +01:00
|
|
|
* @param event - click event
|
2022-04-25 17:28:58 +02:00
|
|
|
*/
|
2022-11-03 18:52:33 +01:00
|
|
|
private itemClicked(itemEl: HTMLElement, event: PointerEvent): void {
|
|
|
|
const allItems = Array.from(this.nodes.items.children);
|
|
|
|
const itemIndex = allItems.indexOf(itemEl);
|
2022-04-25 17:28:58 +02:00
|
|
|
const clickedItem = this.items[itemIndex];
|
|
|
|
|
2022-11-03 18:52:33 +01:00
|
|
|
if (clickedItem.isDisabled) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* If there is any other item in confirmation state except the clicked one, clean it up
|
|
|
|
*/
|
|
|
|
allItems
|
|
|
|
.filter(item => item !== itemEl)
|
|
|
|
.forEach(item => {
|
|
|
|
this.cleanUpConfirmationStateForItem(item);
|
|
|
|
});
|
|
|
|
|
|
|
|
if (clickedItem.confirmation) {
|
|
|
|
this.enableConfirmationStateForItem(clickedItem as PopoverItemWithConfirmation, itemEl, itemIndex);
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
clickedItem.onActivate(clickedItem, event);
|
|
|
|
|
|
|
|
if (clickedItem.toggle) {
|
|
|
|
clickedItem.isActive = !clickedItem.isActive;
|
|
|
|
itemEl.classList.toggle(Popover.CSS.itemActive);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (clickedItem.closeOnActivate) {
|
|
|
|
this.hide();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Enables confirmation state for specified item.
|
|
|
|
* Replaces item element in popover so that is becomes highlighted in a special way
|
|
|
|
*
|
|
|
|
* @param item - item to enable confirmation state for
|
|
|
|
* @param itemEl - html element corresponding to the item
|
|
|
|
* @param itemIndex - index of the item in all items list
|
|
|
|
*/
|
|
|
|
private enableConfirmationStateForItem(item: PopoverItemWithConfirmation, itemEl: HTMLElement, itemIndex: number): void {
|
|
|
|
/** Save root item requiring confirmation to restore original state on popover hide */
|
|
|
|
if (this.itemsRequiringConfirmation[itemIndex] === undefined) {
|
|
|
|
this.itemsRequiringConfirmation[itemIndex] = item;
|
|
|
|
}
|
|
|
|
const newItemData = {
|
|
|
|
...item,
|
|
|
|
...item.confirmation,
|
|
|
|
confirmation: item.confirmation.confirmation,
|
|
|
|
} as PopoverItem;
|
|
|
|
|
|
|
|
this.items[itemIndex] = newItemData;
|
|
|
|
|
|
|
|
const confirmationStateItemEl = this.createItem(newItemData as PopoverItem);
|
|
|
|
|
|
|
|
confirmationStateItemEl.classList.add(Popover.CSS.itemConfirmation, ...Array.from(itemEl.classList));
|
|
|
|
itemEl.parentElement.replaceChild(confirmationStateItemEl, itemEl);
|
|
|
|
|
|
|
|
this.enableSpecialHoverAndFocusBehavior(confirmationStateItemEl);
|
|
|
|
|
|
|
|
this.reactivateFlipper(
|
|
|
|
this.flippableElements,
|
|
|
|
this.flippableElements.indexOf(confirmationStateItemEl)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Brings specified element corresponding to popover item to its original state
|
|
|
|
*
|
|
|
|
* @param itemEl - item in confirmation state
|
|
|
|
*/
|
|
|
|
private cleanUpConfirmationStateForItem(itemEl: Element): void {
|
|
|
|
const allItems = Array.from(this.nodes.items.children);
|
|
|
|
const index = allItems.indexOf(itemEl);
|
|
|
|
|
|
|
|
const originalItem = this.itemsRequiringConfirmation[index];
|
|
|
|
|
|
|
|
if (originalItem === undefined) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const originalStateItemEl = this.createItem(originalItem);
|
|
|
|
|
|
|
|
itemEl.parentElement.replaceChild(originalStateItemEl, itemEl);
|
|
|
|
this.items[index] = originalItem;
|
|
|
|
|
|
|
|
delete this.itemsRequiringConfirmation[index];
|
|
|
|
|
|
|
|
itemEl.removeEventListener('mouseleave', this.removeSpecialHoverBehavior);
|
|
|
|
this.disableSpecialHoverAndFocusBehavior();
|
|
|
|
|
|
|
|
this.reactivateFlipper(
|
|
|
|
this.flippableElements,
|
|
|
|
this.flippableElements.indexOf(originalStateItemEl)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Enables special focus and hover behavior for item in confirmation state.
|
|
|
|
* This is needed to prevent item from being highlighted as hovered/focused just after click.
|
|
|
|
*
|
|
|
|
* @param item - html element of the item to enable special behavior for
|
|
|
|
*/
|
|
|
|
private enableSpecialHoverAndFocusBehavior(item: HTMLElement): void {
|
|
|
|
item.classList.add(Popover.CSS.itemNoHover);
|
|
|
|
item.classList.add(Popover.CSS.itemNoFocus);
|
|
|
|
|
|
|
|
item.addEventListener('mouseleave', this.removeSpecialHoverBehavior, { once: true });
|
|
|
|
this.flipper.onFlip(this.onFlip);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Disables special focus and hover behavior.
|
|
|
|
*/
|
|
|
|
private disableSpecialHoverAndFocusBehavior(): void {
|
|
|
|
this.removeSpecialFocusBehavior();
|
|
|
|
this.removeSpecialHoverBehavior();
|
|
|
|
|
|
|
|
this.flipper.removeOnFlip(this.onFlip);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Removes class responsible for special hover behavior on an item
|
|
|
|
*/
|
|
|
|
private removeSpecialHoverBehavior = (): void => {
|
|
|
|
const el = this.nodes.items.querySelector(`.${Popover.CSS.itemNoHover}`);
|
|
|
|
|
|
|
|
if (!el) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
el.classList.remove(Popover.CSS.itemNoHover);
|
2022-11-25 18:56:50 +01:00
|
|
|
};
|
2022-11-03 18:52:33 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Removes class responsible for special focus behavior on an item
|
|
|
|
*/
|
|
|
|
private removeSpecialFocusBehavior(): void {
|
|
|
|
const el = this.nodes.items.querySelector(`.${Popover.CSS.itemNoFocus}`);
|
|
|
|
|
|
|
|
if (!el) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
el.classList.remove(Popover.CSS.itemNoFocus);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Called on flipper navigation
|
|
|
|
*/
|
|
|
|
private onFlip = (): void => {
|
|
|
|
this.disableSpecialHoverAndFocusBehavior();
|
2022-11-25 18:56:50 +01:00
|
|
|
};
|
2022-11-03 18:52:33 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Reactivates flipper instance.
|
|
|
|
* Should be used if popover items html elements get replaced to preserve workability of keyboard navigation
|
|
|
|
*
|
|
|
|
* @param items - html elements to navigate through
|
|
|
|
* @param focusedIndex - index of element to be focused
|
|
|
|
*/
|
|
|
|
private reactivateFlipper(items: HTMLElement[], focusedIndex?: number): void {
|
|
|
|
this.flipper.deactivate();
|
|
|
|
this.flipper.activate(items, focusedIndex);
|
2022-04-25 17:28:58 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates Flipper instance to be able to leaf tools
|
|
|
|
*/
|
|
|
|
private enableFlipper(): void {
|
|
|
|
this.flipper = new Flipper({
|
2022-11-03 18:52:33 +01:00
|
|
|
items: this.flippableElements,
|
2022-04-25 17:28:58 +02:00
|
|
|
focusedItemClass: Popover.CSS.itemFocused,
|
|
|
|
allowedKeys: [
|
|
|
|
keyCodes.TAB,
|
|
|
|
keyCodes.UP,
|
|
|
|
keyCodes.DOWN,
|
|
|
|
keyCodes.ENTER,
|
|
|
|
],
|
|
|
|
});
|
|
|
|
}
|
2022-11-03 18:52:33 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns list of elements available for keyboard navigation.
|
|
|
|
* Contains both usual popover items elements and custom html content.
|
|
|
|
*/
|
|
|
|
private get flippableElements(): HTMLElement[] {
|
|
|
|
/**
|
|
|
|
* Select html elements of popover items
|
|
|
|
*/
|
|
|
|
const popoverItemsElements = Array.from(this.nodes.wrapper.querySelectorAll(`.${Popover.CSS.item}`)) as HTMLElement[];
|
|
|
|
|
|
|
|
const customContentControlsElements = this.customContentFlippableItems || [];
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Combine elements inside custom content area with popover items elements
|
|
|
|
*/
|
|
|
|
return customContentControlsElements.concat(popoverItemsElements);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Checks if popover should be opened bottom.
|
|
|
|
* It should happen when there is enough space below or not enough space above
|
|
|
|
*/
|
|
|
|
private get shouldOpenPopoverBottom(): boolean {
|
|
|
|
const toolboxRect = this.nodes.wrapper.getBoundingClientRect();
|
|
|
|
const scopeElementRect = this.scopeElement.getBoundingClientRect();
|
|
|
|
const popoverHeight = this.calculateHeight();
|
|
|
|
const popoverPotentialBottomEdge = toolboxRect.top + popoverHeight;
|
|
|
|
const popoverPotentialTopEdge = toolboxRect.top - popoverHeight;
|
|
|
|
const bottomEdgeForComparison = Math.min(window.innerHeight, scopeElementRect.bottom);
|
|
|
|
|
|
|
|
return popoverPotentialTopEdge < scopeElementRect.top || popoverPotentialBottomEdge <= bottomEdgeForComparison;
|
|
|
|
}
|
2022-04-25 17:28:58 +02:00
|
|
|
}
|