editor.js/src/components/utils/popover/components/popover-item/popover-item.ts
Tatiana Fomina 5125f015dc
feat: nested popover (#2649)
* Move popover types to separate file

* tmp

* open top

* Fix bug with keyboard navigation

* Fix bug with scroll

* Fix mobile

* Add popover header class

* Display nested items on mobile

* Refactor history

* Fix positioning on desktop

* Fix tests

* Fix child popover indent left

* Fix ts errors in popover files

* Move files

* Rename cn to bem

* Clarify comments and rename method

* Refactor popover css classes

* Rename cls to css

* Split popover desktop and mobile classes

* Add ability to open popover to the left if not enough space to open to the right

* Add nested popover test

* Add popover test for mobile screens

* Fix tests

* Add union type for both popovers

* Add global window resize event

* Multiple fixes

* Move nodes initialization to constructor

* Rename handleShowingNestedItems to showNestedItems

* Replace WindowResize with EditorMobileLayoutToggled

* New doze of fixes

* Review fixes

* Fixes

* Fixes

* Make each nested popover decide itself if it should open top

* Update changelog

* Update changelog

* Update changelog
2024-04-13 17:34:26 +00:00

313 lines
7.8 KiB
TypeScript

import Dom from '../../../../dom';
import { IconDotCircle, IconChevronRight } from '@codexteam/icons';
import { PopoverItem as PopoverItemParams } from '../../../../../../types';
import { css } from './popover-item.const';
/**
* Represents sigle popover item node
*
* @todo move nodes initialization to constructor
* @todo replace multiple make() usages with constructing separate instaces
* @todo split regular popover item and popover item with confirmation to separate classes
*/
export class PopoverItem {
/**
* True if item is disabled and hence not clickable
*/
public get isDisabled(): boolean {
return this.params.isDisabled === true;
}
/**
* Exposes popover item toggle parameter
*/
public get toggle(): boolean | string | undefined {
return this.params.toggle;
}
/**
* Item title
*/
public get title(): string | undefined {
return this.params.title;
}
/**
* True if popover should close once item is activated
*/
public get closeOnActivate(): boolean | undefined {
return this.params.closeOnActivate;
}
/**
* True if confirmation state is enabled for popover item
*/
public get isConfirmationStateEnabled(): boolean {
return this.confirmationState !== null;
}
/**
* True if item is focused in keyboard navigation process
*/
public get isFocused(): boolean {
if (this.nodes.root === null) {
return false;
}
return this.nodes.root.classList.contains(css.focused);
}
/**
* Item html elements
*/
private nodes: {
root: null | HTMLElement,
icon: null | HTMLElement
} = {
root: null,
icon: null,
};
/**
* Popover item params
*/
private params: PopoverItemParams;
/**
* If item is in confirmation state, stores confirmation params such as icon, label, onActivate callback and so on
*/
private confirmationState: PopoverItemParams | null = null;
/**
* Constructs popover item instance
*
* @param params - popover item construction params
*/
constructor(params: PopoverItemParams) {
this.params = params;
this.nodes.root = this.make(params);
}
/**
* Returns popover item root element
*/
public getElement(): HTMLElement | null {
return this.nodes.root;
}
/**
* Called on popover item click
*/
public handleClick(): void {
if (this.isConfirmationStateEnabled && this.confirmationState !== null) {
this.activateOrEnableConfirmationMode(this.confirmationState);
return;
}
this.activateOrEnableConfirmationMode(this.params);
}
/**
* Toggles item active state
*
* @param isActive - true if item should strictly should become active
*/
public toggleActive(isActive?: boolean): void {
this.nodes.root?.classList.toggle(css.active, isActive);
}
/**
* Toggles item hidden state
*
* @param isHidden - true if item should be hidden
*/
public toggleHidden(isHidden: boolean): void {
this.nodes.root?.classList.toggle(css.hidden, isHidden);
}
/**
* Resets popover item to its original state
*/
public reset(): void {
if (this.isConfirmationStateEnabled) {
this.disableConfirmationMode();
}
}
/**
* Method called once item becomes focused during keyboard navigation
*/
public onFocus(): void {
this.disableSpecialHoverAndFocusBehavior();
}
/**
* Returns list of item children
*/
public get children(): PopoverItemParams[] {
return 'children' in this.params && this.params.children?.items !== undefined ? this.params.children.items : [];
}
/**
* Constructs HTML element corresponding to popover item params
*
* @param params - item construction params
*/
private make(params: PopoverItemParams): HTMLElement {
const el = Dom.make('div', css.container);
if (params.name) {
el.dataset.itemName = params.name;
}
this.nodes.icon = Dom.make('div', [css.icon, css.iconTool], {
innerHTML: params.icon || IconDotCircle,
});
el.appendChild(this.nodes.icon);
el.appendChild(Dom.make('div', css.title, {
innerHTML: params.title || '',
}));
if (params.secondaryLabel) {
el.appendChild(Dom.make('div', css.secondaryTitle, {
textContent: params.secondaryLabel,
}));
}
if (this.children.length > 0) {
el.appendChild(Dom.make('div', [css.icon, css.iconChevronRight], {
innerHTML: IconChevronRight,
}));
}
if (params.isActive) {
el.classList.add(css.active);
}
if (params.isDisabled) {
el.classList.add(css.disabled);
}
return el;
}
/**
* Activates confirmation mode for the item.
*
* @param newState - new popover item params that should be applied
*/
private enableConfirmationMode(newState: PopoverItemParams): void {
if (this.nodes.root === null) {
return;
}
const params = {
...this.params,
...newState,
confirmation: newState.confirmation,
} as PopoverItemParams;
const confirmationEl = this.make(params);
this.nodes.root.innerHTML = confirmationEl.innerHTML;
this.nodes.root.classList.add(css.confirmationState);
this.confirmationState = newState;
this.enableSpecialHoverAndFocusBehavior();
}
/**
* Returns item to its original state
*/
private disableConfirmationMode(): void {
if (this.nodes.root === null) {
return;
}
const itemWithOriginalParams = this.make(this.params);
this.nodes.root.innerHTML = itemWithOriginalParams.innerHTML;
this.nodes.root.classList.remove(css.confirmationState);
this.confirmationState = null;
this.disableSpecialHoverAndFocusBehavior();
}
/**
* 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.
*/
private enableSpecialHoverAndFocusBehavior(): void {
this.nodes.root?.classList.add(css.noHover);
this.nodes.root?.classList.add(css.noFocus);
this.nodes.root?.addEventListener('mouseleave', this.removeSpecialHoverBehavior, { once: true });
}
/**
* Disables special focus and hover behavior
*/
private disableSpecialHoverAndFocusBehavior(): void {
this.removeSpecialFocusBehavior();
this.removeSpecialHoverBehavior();
this.nodes.root?.removeEventListener('mouseleave', this.removeSpecialHoverBehavior);
}
/**
* Removes class responsible for special focus behavior on an item
*/
private removeSpecialFocusBehavior = (): void => {
this.nodes.root?.classList.remove(css.noFocus);
};
/**
* Removes class responsible for special hover behavior on an item
*/
private removeSpecialHoverBehavior = (): void => {
this.nodes.root?.classList.remove(css.noHover);
};
/**
* Executes item's onActivate callback if the item has no confirmation configured
*
* @param item - item to activate or bring to confirmation mode
*/
private activateOrEnableConfirmationMode(item: PopoverItemParams): void {
if (item.confirmation === undefined) {
try {
item.onActivate?.(item);
this.disableConfirmationMode();
} catch {
this.animateError();
}
} else {
this.enableConfirmationMode(item.confirmation);
}
}
/**
* Animates item which symbolizes that error occured while executing 'onActivate()' callback
*/
private animateError(): void {
if (this.nodes.icon?.classList.contains(css.wobbleAnimation)) {
return;
}
this.nodes.icon?.classList.add(css.wobbleAnimation);
this.nodes.icon?.addEventListener('animationend', this.onErrorAnimationEnd);
}
/**
* Handles finish of error animation
*/
private onErrorAnimationEnd = (): void => {
this.nodes.icon?.classList.remove(css.wobbleAnimation);
this.nodes.icon?.removeEventListener('animationend', this.onErrorAnimationEnd);
};
}