mirror of
https://github.com/codex-team/editor.js
synced 2024-06-10 09:52:36 +02:00
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
This commit is contained in:
parent
ecdd73347c
commit
5125f015dc
|
@ -1,5 +1,9 @@
|
|||
# Changelog
|
||||
|
||||
### 2.30.1
|
||||
|
||||
– `New` – Block Tunes now supports nesting items
|
||||
|
||||
### 2.30.0
|
||||
|
||||
- `Improvement` — The ability to merge blocks of different types (if both tools provide the conversionConfig)
|
||||
|
|
|
@ -52,11 +52,13 @@ export default class Dom {
|
|||
* @param {object} [attributes] - any attributes
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
public static make(tagName: string, classNames: string | string[] | null = null, attributes: object = {}): HTMLElement {
|
||||
public static make(tagName: string, classNames: string | (string | undefined)[] | null = null, attributes: object = {}): HTMLElement {
|
||||
const el = document.createElement(tagName);
|
||||
|
||||
if (Array.isArray(classNames)) {
|
||||
el.classList.add(...classNames);
|
||||
const validClassnames = classNames.filter(className => className !== undefined) as string[];
|
||||
|
||||
el.classList.add(...validClassnames);
|
||||
} else if (classNames) {
|
||||
el.classList.add(classNames);
|
||||
}
|
||||
|
|
15
src/components/events/EditorMobileLayoutToggled.ts
Normal file
15
src/components/events/EditorMobileLayoutToggled.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Fired when editor mobile layout toggled
|
||||
*/
|
||||
export const EditorMobileLayoutToggled = 'editor mobile layout toggled';
|
||||
|
||||
/**
|
||||
* Payload that will be passed with the event
|
||||
*/
|
||||
export interface EditorMobileLayoutToggledPayload {
|
||||
/**
|
||||
* True, if mobile layout enabled
|
||||
*/
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { BlockChanged, BlockChangedPayload } from './BlockChanged';
|
|||
import { BlockHovered, BlockHoveredPayload } from './BlockHovered';
|
||||
import { FakeCursorAboutToBeToggled, FakeCursorAboutToBeToggledPayload } from './FakeCursorAboutToBeToggled';
|
||||
import { FakeCursorHaveBeenSet, FakeCursorHaveBeenSetPayload } from './FakeCursorHaveBeenSet';
|
||||
import { EditorMobileLayoutToggled, EditorMobileLayoutToggledPayload } from './EditorMobileLayoutToggled';
|
||||
|
||||
/**
|
||||
* Events fired by Editor Event Dispatcher
|
||||
|
@ -11,7 +12,8 @@ export {
|
|||
RedactorDomChanged,
|
||||
BlockChanged,
|
||||
FakeCursorAboutToBeToggled,
|
||||
FakeCursorHaveBeenSet
|
||||
FakeCursorHaveBeenSet,
|
||||
EditorMobileLayoutToggled
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -23,4 +25,5 @@ export interface EditorEventMap {
|
|||
[BlockChanged]: BlockChangedPayload;
|
||||
[FakeCursorAboutToBeToggled]: FakeCursorAboutToBeToggledPayload;
|
||||
[FakeCursorHaveBeenSet]: FakeCursorHaveBeenSetPayload;
|
||||
[EditorMobileLayoutToggled]: EditorMobileLayoutToggledPayload
|
||||
}
|
||||
|
|
|
@ -49,15 +49,11 @@ export default class Flipper {
|
|||
|
||||
/**
|
||||
* Instance of flipper iterator
|
||||
*
|
||||
* @type {DomIterator|null}
|
||||
*/
|
||||
private readonly iterator: DomIterator = null;
|
||||
private readonly iterator: DomIterator | null = null;
|
||||
|
||||
/**
|
||||
* Flag that defines activation status
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
private activated = false;
|
||||
|
||||
|
@ -77,7 +73,7 @@ export default class Flipper {
|
|||
private flipCallbacks: Array<() => void> = [];
|
||||
|
||||
/**
|
||||
* @param {FlipperOptions} options - different constructing settings
|
||||
* @param options - different constructing settings
|
||||
*/
|
||||
constructor(options: FlipperOptions) {
|
||||
this.iterator = new DomIterator(options.items, options.focusedItemClass);
|
||||
|
@ -110,7 +106,6 @@ export default class Flipper {
|
|||
*/
|
||||
public activate(items?: HTMLElement[], cursorPosition?: number): void {
|
||||
this.activated = true;
|
||||
|
||||
if (items) {
|
||||
this.iterator.setItems(items);
|
||||
}
|
||||
|
|
|
@ -7,7 +7,10 @@ import { I18nInternalNS } from '../../i18n/namespace-internal';
|
|||
import Flipper from '../../flipper';
|
||||
import { TunesMenuConfigItem } from '../../../../types/tools';
|
||||
import { resolveAliases } from '../../utils/resolve-aliases';
|
||||
import Popover, { PopoverEvent } from '../../utils/popover';
|
||||
import { type Popover, PopoverDesktop, PopoverMobile } from '../../utils/popover';
|
||||
import { PopoverEvent } from '../../utils/popover/popover.types';
|
||||
import { isMobileScreen } from '../../utils';
|
||||
import { EditorMobileLayoutToggled } from '../../events';
|
||||
|
||||
/**
|
||||
* HTML Elements that used for BlockSettings
|
||||
|
@ -27,8 +30,6 @@ interface BlockSettingsNodes {
|
|||
export default class BlockSettings extends Module<BlockSettingsNodes> {
|
||||
/**
|
||||
* Module Events
|
||||
*
|
||||
* @returns {{opened: string, closed: string}}
|
||||
*/
|
||||
public get events(): { opened: string; closed: string } {
|
||||
return {
|
||||
|
@ -56,8 +57,12 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
|
|||
*
|
||||
* @todo remove once BlockSettings becomes standalone non-module class
|
||||
*/
|
||||
public get flipper(): Flipper {
|
||||
return this.popover?.flipper;
|
||||
public get flipper(): Flipper | undefined {
|
||||
if (this.popover === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return 'flipper' in this.popover ? this.popover?.flipper : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -67,9 +72,9 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
|
|||
|
||||
/**
|
||||
* Popover instance. There is a util for vertical lists.
|
||||
* Null until popover is not initialized
|
||||
*/
|
||||
private popover: Popover | undefined;
|
||||
|
||||
private popover: Popover | null = null;
|
||||
|
||||
/**
|
||||
* Panel with block settings with 2 sections:
|
||||
|
@ -82,6 +87,8 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
|
|||
if (import.meta.env.MODE === 'test') {
|
||||
this.nodes.wrapper.setAttribute('data-cy', 'block-tunes');
|
||||
}
|
||||
|
||||
this.eventsDispatcher.on(EditorMobileLayoutToggled, this.close);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -89,6 +96,8 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
|
|||
*/
|
||||
public destroy(): void {
|
||||
this.removeAllNodes();
|
||||
this.listeners.destroy();
|
||||
this.eventsDispatcher.off(EditorMobileLayoutToggled, this.close);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -118,7 +127,10 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
|
|||
|
||||
/** Tell to subscribers that block settings is opened */
|
||||
this.eventsDispatcher.emit(this.events.opened);
|
||||
this.popover = new Popover({
|
||||
|
||||
const PopoverClass = isMobileScreen() ? PopoverMobile : PopoverDesktop;
|
||||
|
||||
this.popover = new PopoverClass({
|
||||
searchable: true,
|
||||
items: tunesItems.map(tune => this.resolveTuneAliases(tune)),
|
||||
customContent: customHtmlTunesContainer,
|
||||
|
@ -132,7 +144,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
|
|||
|
||||
this.popover.on(PopoverEvent.Close, this.onPopoverClose);
|
||||
|
||||
this.nodes.wrapper.append(this.popover.getElement());
|
||||
this.nodes.wrapper?.append(this.popover.getElement());
|
||||
|
||||
this.popover.show();
|
||||
}
|
||||
|
@ -140,14 +152,14 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
|
|||
/**
|
||||
* Returns root block settings element
|
||||
*/
|
||||
public getElement(): HTMLElement {
|
||||
public getElement(): HTMLElement | undefined {
|
||||
return this.nodes.wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close Block Settings pane
|
||||
*/
|
||||
public close(): void {
|
||||
public close = (): void => {
|
||||
if (!this.opened) {
|
||||
return;
|
||||
}
|
||||
|
@ -183,7 +195,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
|
|||
this.popover.getElement().remove();
|
||||
this.popover = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles popover close event
|
||||
|
|
|
@ -220,6 +220,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
|||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Toggles read-only mode
|
||||
*
|
||||
|
@ -479,9 +480,10 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
|||
}
|
||||
});
|
||||
|
||||
return this.toolboxInstance.make();
|
||||
return this.toolboxInstance.getElement();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handler for Plus Button
|
||||
*/
|
||||
|
|
|
@ -16,6 +16,7 @@ import { mobileScreenBreakpoint } from '../utils';
|
|||
import styles from '../../styles/main.css?inline';
|
||||
import { BlockHovered } from '../events/BlockHovered';
|
||||
import { selectionChangeDebounceTimeout } from '../constants';
|
||||
import { EditorMobileLayoutToggled } from '../events';
|
||||
/**
|
||||
* HTML Elements used for UI
|
||||
*/
|
||||
|
@ -121,7 +122,7 @@ export default class UI extends Module<UINodes> {
|
|||
/**
|
||||
* Detect mobile version
|
||||
*/
|
||||
this.checkIsMobile();
|
||||
this.setIsMobile();
|
||||
|
||||
/**
|
||||
* Make main UI elements
|
||||
|
@ -234,10 +235,21 @@ export default class UI extends Module<UINodes> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Check for mobile mode and cache a result
|
||||
* Check for mobile mode and save the result
|
||||
*/
|
||||
private checkIsMobile(): void {
|
||||
this.isMobile = window.innerWidth < mobileScreenBreakpoint;
|
||||
private setIsMobile(): void {
|
||||
const isMobile = window.innerWidth < mobileScreenBreakpoint;
|
||||
|
||||
if (isMobile !== this.isMobile) {
|
||||
/**
|
||||
* Dispatch global event
|
||||
*/
|
||||
this.eventsDispatcher.emit(EditorMobileLayoutToggled, {
|
||||
isEnabled: this.isMobile,
|
||||
});
|
||||
}
|
||||
|
||||
this.isMobile = isMobile;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -426,7 +438,7 @@ export default class UI extends Module<UINodes> {
|
|||
/**
|
||||
* Detect mobile version
|
||||
*/
|
||||
this.checkIsMobile();
|
||||
this.setIsMobile();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -5,9 +5,13 @@ import BlockTool from '../tools/block';
|
|||
import ToolsCollection from '../tools/collection';
|
||||
import { API, BlockToolData, ToolboxConfigEntry, PopoverItem, BlockAPI } from '../../../types';
|
||||
import EventsDispatcher from '../utils/events';
|
||||
import Popover, { PopoverEvent } from '../utils/popover';
|
||||
import I18n from '../i18n';
|
||||
import { I18nInternalNS } from '../i18n/namespace-internal';
|
||||
import { PopoverEvent } from '../utils/popover/popover.types';
|
||||
import Listeners from '../utils/listeners';
|
||||
import Dom from '../dom';
|
||||
import { Popover, PopoverDesktop, PopoverMobile } from '../utils/popover';
|
||||
import { EditorMobileLayoutToggled } from '../events';
|
||||
|
||||
/**
|
||||
* @todo the first Tab on the Block — focus Plus Button, the second — focus Block Tunes Toggler, the third — focus next Block
|
||||
|
@ -75,6 +79,11 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
|
|||
*/
|
||||
public opened = false;
|
||||
|
||||
/**
|
||||
* Listeners util instance
|
||||
*/
|
||||
protected listeners: Listeners = new Listeners();
|
||||
|
||||
/**
|
||||
* Editor API
|
||||
*/
|
||||
|
@ -82,8 +91,9 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
|
|||
|
||||
/**
|
||||
* Popover instance. There is a util for vertical lists.
|
||||
* Null until initialized
|
||||
*/
|
||||
private popover: Popover | undefined;
|
||||
private popover: Popover | null = null;
|
||||
|
||||
/**
|
||||
* List of Tools available. Some of them will be shown in the Toolbox
|
||||
|
@ -99,9 +109,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
|
|||
* Current module HTML Elements
|
||||
*/
|
||||
private nodes: {
|
||||
toolbox: HTMLElement | null;
|
||||
} = {
|
||||
toolbox: null,
|
||||
toolbox: HTMLElement;
|
||||
} ;
|
||||
|
||||
/**
|
||||
|
@ -128,36 +136,26 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
|
|||
this.api = api;
|
||||
this.tools = tools;
|
||||
this.i18nLabels = i18nLabels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the Toolbox
|
||||
*/
|
||||
public make(): Element {
|
||||
this.popover = new Popover({
|
||||
scopeElement: this.api.ui.nodes.redactor,
|
||||
searchable: true,
|
||||
messages: {
|
||||
nothingFound: this.i18nLabels.nothingFound,
|
||||
search: this.i18nLabels.filter,
|
||||
},
|
||||
items: this.toolboxItemsToBeDisplayed,
|
||||
});
|
||||
|
||||
this.popover.on(PopoverEvent.Close, this.onPopoverClose);
|
||||
|
||||
/**
|
||||
* Enable tools shortcuts
|
||||
*/
|
||||
this.enableShortcuts();
|
||||
|
||||
this.nodes.toolbox = this.popover.getElement();
|
||||
this.nodes.toolbox.classList.add(Toolbox.CSS.toolbox);
|
||||
this.nodes = {
|
||||
toolbox: Dom.make('div', Toolbox.CSS.toolbox),
|
||||
};
|
||||
|
||||
this.initPopover();
|
||||
|
||||
if (import.meta.env.MODE === 'test') {
|
||||
this.nodes.toolbox.setAttribute('data-cy', 'toolbox');
|
||||
}
|
||||
|
||||
this.api.events.on(EditorMobileLayoutToggled, this.handleMobileLayoutToggle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns root block settings element
|
||||
*/
|
||||
public getElement(): HTMLElement | null {
|
||||
return this.nodes.toolbox;
|
||||
}
|
||||
|
||||
|
@ -165,7 +163,11 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
|
|||
* Returns true if the Toolbox has the Flipper activated and the Flipper has selected button
|
||||
*/
|
||||
public hasFocus(): boolean | undefined {
|
||||
return this.popover?.hasFocus();
|
||||
if (this.popover === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return 'hasFocus' in this.popover ? this.popover.hasFocus() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -176,11 +178,12 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
|
|||
|
||||
if (this.nodes && this.nodes.toolbox) {
|
||||
this.nodes.toolbox.remove();
|
||||
this.nodes.toolbox = null;
|
||||
}
|
||||
|
||||
this.removeAllShortcuts();
|
||||
this.popover?.off(PopoverEvent.Close, this.onPopoverClose);
|
||||
this.listeners.destroy();
|
||||
this.api.events.off(EditorMobileLayoutToggled, this.handleMobileLayoutToggle);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -226,6 +229,50 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys existing popover instance and contructs the new one.
|
||||
*/
|
||||
public handleMobileLayoutToggle = (): void => {
|
||||
this.destroyPopover();
|
||||
this.initPopover();
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates toolbox popover and appends it inside wrapper element
|
||||
*/
|
||||
private initPopover(): void {
|
||||
const PopoverClass = _.isMobileScreen() ? PopoverMobile : PopoverDesktop;
|
||||
|
||||
this.popover = new PopoverClass({
|
||||
scopeElement: this.api.ui.nodes.redactor,
|
||||
searchable: true,
|
||||
messages: {
|
||||
nothingFound: this.i18nLabels.nothingFound,
|
||||
search: this.i18nLabels.filter,
|
||||
},
|
||||
items: this.toolboxItemsToBeDisplayed,
|
||||
});
|
||||
|
||||
this.popover.on(PopoverEvent.Close, this.onPopoverClose);
|
||||
this.nodes.toolbox?.append(this.popover.getElement());
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys popover instance and removes it from DOM
|
||||
*/
|
||||
private destroyPopover(): void {
|
||||
if (this.popover !== null) {
|
||||
this.popover.hide();
|
||||
this.popover.off(PopoverEvent.Close, this.onPopoverClose);
|
||||
this.popover.destroy();
|
||||
this.popover = null;
|
||||
}
|
||||
|
||||
if (this.nodes.toolbox !== null) {
|
||||
this.nodes.toolbox.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles popover close event
|
||||
*/
|
||||
|
|
25
src/components/utils/bem.ts
Normal file
25
src/components/utils/bem.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
const ELEMENT_DELIMITER = '__';
|
||||
const MODIFIER_DELIMITER = '--';
|
||||
|
||||
/**
|
||||
* Utility function that allows to construct class names from block and element names
|
||||
*
|
||||
* @example bem('ce-popover)() -> 'ce-popover'
|
||||
* @example bem('ce-popover)('container') -> 'ce-popover__container'
|
||||
* @example bem('ce-popover)('container', 'hidden') -> 'ce-popover__container--hidden'
|
||||
* @example bem('ce-popover)(null, 'hidden') -> 'ce-popover--hidden'
|
||||
* @param blockName - string with block name
|
||||
* @param elementName - string with element name
|
||||
* @param modifier - modifier to be appended
|
||||
*/
|
||||
export function bem(blockName: string) {
|
||||
return (elementName?: string, modifier?: string) => {
|
||||
const className = [blockName, elementName]
|
||||
.filter(x => !!x)
|
||||
.join(ELEMENT_DELIMITER);
|
||||
|
||||
return [className, modifier]
|
||||
.filter(x => !!x)
|
||||
.join(MODIFIER_DELIMITER);
|
||||
};
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * from './popover-header';
|
||||
export * from './popover-header.types';
|
|
@ -0,0 +1,15 @@
|
|||
import { bem } from '../../../bem';
|
||||
|
||||
/**
|
||||
* Popover header block CSS class constructor
|
||||
*/
|
||||
const className = bem('ce-popover-header');
|
||||
|
||||
/**
|
||||
* CSS class names to be used in popover header class
|
||||
*/
|
||||
export const css = {
|
||||
root: className(),
|
||||
text: className('text'),
|
||||
backButton: className('back-button'),
|
||||
};
|
|
@ -0,0 +1,71 @@
|
|||
import { PopoverHeaderParams } from './popover-header.types';
|
||||
import Dom from '../../../../dom';
|
||||
import { css } from './popover-header.const';
|
||||
import { IconChevronLeft } from '@codexteam/icons';
|
||||
import Listeners from '../../../listeners';
|
||||
|
||||
/**
|
||||
* Represents popover header ui element
|
||||
*/
|
||||
export class PopoverHeader {
|
||||
/**
|
||||
* Listeners util instance
|
||||
*/
|
||||
private listeners = new Listeners();
|
||||
|
||||
/**
|
||||
* Header html elements
|
||||
*/
|
||||
private nodes: {
|
||||
root: HTMLElement,
|
||||
text: HTMLElement,
|
||||
backButton: HTMLElement
|
||||
};
|
||||
|
||||
/**
|
||||
* Text displayed inside header
|
||||
*/
|
||||
private readonly text: string;
|
||||
|
||||
/**
|
||||
* Back button click handler
|
||||
*/
|
||||
private readonly onBackButtonClick: () => void;
|
||||
|
||||
/**
|
||||
* Constructs the instance
|
||||
*
|
||||
* @param params - popover header params
|
||||
*/
|
||||
constructor({ text, onBackButtonClick }: PopoverHeaderParams) {
|
||||
this.text = text;
|
||||
this.onBackButtonClick = onBackButtonClick;
|
||||
|
||||
this.nodes = {
|
||||
root: Dom.make('div', [ css.root ]),
|
||||
backButton: Dom.make('button', [ css.backButton ]),
|
||||
text: Dom.make('div', [ css.text ]),
|
||||
};
|
||||
this.nodes.backButton.innerHTML = IconChevronLeft;
|
||||
this.nodes.root.appendChild(this.nodes.backButton);
|
||||
this.listeners.on(this.nodes.backButton, 'click', this.onBackButtonClick);
|
||||
|
||||
this.nodes.text.innerText = this.text;
|
||||
this.nodes.root.appendChild(this.nodes.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns popover header root html element
|
||||
*/
|
||||
public getElement(): HTMLElement | null {
|
||||
return this.nodes.root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the instance
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.nodes.root.remove();
|
||||
this.listeners.destroy();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Popover header params
|
||||
*/
|
||||
export interface PopoverHeaderParams {
|
||||
/**
|
||||
* Text to be displayed inside header
|
||||
*/
|
||||
text: string;
|
||||
|
||||
/**
|
||||
* Back button click handler
|
||||
*/
|
||||
onBackButtonClick: () => void;
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * from './popover-item';
|
||||
export * from './popover-item.const';
|
|
@ -0,0 +1,26 @@
|
|||
import { bem } from '../../../bem';
|
||||
|
||||
/**
|
||||
* Popover item block CSS class constructor
|
||||
*/
|
||||
const className = bem('ce-popover-item');
|
||||
|
||||
/**
|
||||
* CSS class names to be used in popover item class
|
||||
*/
|
||||
export const css = {
|
||||
container: className(),
|
||||
active: className(null, 'active'),
|
||||
disabled: className(null, 'disabled'),
|
||||
focused: className(null, 'focused'),
|
||||
hidden: className(null, 'hidden'),
|
||||
confirmationState: className(null, 'confirmation'),
|
||||
noHover: className(null, 'no-hover'),
|
||||
noFocus: className(null, 'no-focus'),
|
||||
title: className('title'),
|
||||
secondaryTitle: className('secondary-title'),
|
||||
icon: className('icon'),
|
||||
iconTool: className('icon', 'tool'),
|
||||
iconChevronRight: className('icon', 'chevron-right'),
|
||||
wobbleAnimation: bem('wobble')(),
|
||||
};
|
|
@ -1,16 +1,21 @@
|
|||
import Dom from '../../dom';
|
||||
import { IconDotCircle } from '@codexteam/icons';
|
||||
import { PopoverItem as PopoverItemParams } from '../../../../types';
|
||||
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;
|
||||
return this.params.isDisabled === true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -45,7 +50,11 @@ export class PopoverItem {
|
|||
* True if item is focused in keyboard navigation process
|
||||
*/
|
||||
public get isFocused(): boolean {
|
||||
return this.nodes.root.classList.contains(PopoverItem.CSS.focused);
|
||||
if (this.nodes.root === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.nodes.root.classList.contains(css.focused);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -69,39 +78,6 @@ export class PopoverItem {
|
|||
*/
|
||||
private confirmationState: PopoverItemParams | null = null;
|
||||
|
||||
/**
|
||||
* Popover item CSS classes
|
||||
*/
|
||||
public static get CSS(): {
|
||||
container: string,
|
||||
title: string,
|
||||
secondaryTitle: string,
|
||||
icon: string,
|
||||
active: string,
|
||||
disabled: string,
|
||||
focused: string,
|
||||
hidden: string,
|
||||
confirmationState: string,
|
||||
noHover: string,
|
||||
noFocus: string,
|
||||
wobbleAnimation: string
|
||||
} {
|
||||
return {
|
||||
container: 'ce-popover-item',
|
||||
title: 'ce-popover-item__title',
|
||||
secondaryTitle: 'ce-popover-item__secondary-title',
|
||||
icon: 'ce-popover-item__icon',
|
||||
active: 'ce-popover-item--active',
|
||||
disabled: 'ce-popover-item--disabled',
|
||||
focused: 'ce-popover-item--focused',
|
||||
hidden: 'ce-popover-item--hidden',
|
||||
confirmationState: 'ce-popover-item--confirmation',
|
||||
noHover: 'ce-popover-item--no-hover',
|
||||
noFocus: 'ce-popover-item--no-focus',
|
||||
wobbleAnimation: 'wobble',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs popover item instance
|
||||
*
|
||||
|
@ -115,7 +91,7 @@ export class PopoverItem {
|
|||
/**
|
||||
* Returns popover item root element
|
||||
*/
|
||||
public getElement(): HTMLElement {
|
||||
public getElement(): HTMLElement | null {
|
||||
return this.nodes.root;
|
||||
}
|
||||
|
||||
|
@ -123,7 +99,7 @@ export class PopoverItem {
|
|||
* Called on popover item click
|
||||
*/
|
||||
public handleClick(): void {
|
||||
if (this.isConfirmationStateEnabled) {
|
||||
if (this.isConfirmationStateEnabled && this.confirmationState !== null) {
|
||||
this.activateOrEnableConfirmationMode(this.confirmationState);
|
||||
|
||||
return;
|
||||
|
@ -138,7 +114,7 @@ export class PopoverItem {
|
|||
* @param isActive - true if item should strictly should become active
|
||||
*/
|
||||
public toggleActive(isActive?: boolean): void {
|
||||
this.nodes.root.classList.toggle(PopoverItem.CSS.active, isActive);
|
||||
this.nodes.root?.classList.toggle(css.active, isActive);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -147,7 +123,7 @@ export class PopoverItem {
|
|||
* @param isHidden - true if item should be hidden
|
||||
*/
|
||||
public toggleHidden(isHidden: boolean): void {
|
||||
this.nodes.root.classList.toggle(PopoverItem.CSS.hidden, isHidden);
|
||||
this.nodes.root?.classList.toggle(css.hidden, isHidden);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -166,40 +142,53 @@ export class PopoverItem {
|
|||
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', PopoverItem.CSS.container);
|
||||
const el = Dom.make('div', css.container);
|
||||
|
||||
if (params.name) {
|
||||
el.dataset.itemName = params.name;
|
||||
}
|
||||
|
||||
this.nodes.icon = Dom.make('div', PopoverItem.CSS.icon, {
|
||||
this.nodes.icon = Dom.make('div', [css.icon, css.iconTool], {
|
||||
innerHTML: params.icon || IconDotCircle,
|
||||
});
|
||||
|
||||
el.appendChild(this.nodes.icon);
|
||||
|
||||
el.appendChild(Dom.make('div', PopoverItem.CSS.title, {
|
||||
el.appendChild(Dom.make('div', css.title, {
|
||||
innerHTML: params.title || '',
|
||||
}));
|
||||
|
||||
if (params.secondaryLabel) {
|
||||
el.appendChild(Dom.make('div', PopoverItem.CSS.secondaryTitle, {
|
||||
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(PopoverItem.CSS.active);
|
||||
el.classList.add(css.active);
|
||||
}
|
||||
|
||||
if (params.isDisabled) {
|
||||
el.classList.add(PopoverItem.CSS.disabled);
|
||||
el.classList.add(css.disabled);
|
||||
}
|
||||
|
||||
return el;
|
||||
|
@ -211,6 +200,10 @@ export class PopoverItem {
|
|||
* @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,
|
||||
|
@ -219,7 +212,7 @@ export class PopoverItem {
|
|||
const confirmationEl = this.make(params);
|
||||
|
||||
this.nodes.root.innerHTML = confirmationEl.innerHTML;
|
||||
this.nodes.root.classList.add(PopoverItem.CSS.confirmationState);
|
||||
this.nodes.root.classList.add(css.confirmationState);
|
||||
|
||||
this.confirmationState = newState;
|
||||
|
||||
|
@ -230,10 +223,13 @@ export class PopoverItem {
|
|||
* 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(PopoverItem.CSS.confirmationState);
|
||||
this.nodes.root.classList.remove(css.confirmationState);
|
||||
|
||||
this.confirmationState = null;
|
||||
|
||||
|
@ -245,10 +241,10 @@ export class PopoverItem {
|
|||
* This is needed to prevent item from being highlighted as hovered/focused just after click.
|
||||
*/
|
||||
private enableSpecialHoverAndFocusBehavior(): void {
|
||||
this.nodes.root.classList.add(PopoverItem.CSS.noHover);
|
||||
this.nodes.root.classList.add(PopoverItem.CSS.noFocus);
|
||||
this.nodes.root?.classList.add(css.noHover);
|
||||
this.nodes.root?.classList.add(css.noFocus);
|
||||
|
||||
this.nodes.root.addEventListener('mouseleave', this.removeSpecialHoverBehavior, { once: true });
|
||||
this.nodes.root?.addEventListener('mouseleave', this.removeSpecialHoverBehavior, { once: true });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -258,21 +254,21 @@ export class PopoverItem {
|
|||
this.removeSpecialFocusBehavior();
|
||||
this.removeSpecialHoverBehavior();
|
||||
|
||||
this.nodes.root.removeEventListener('mouseleave', 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(PopoverItem.CSS.noFocus);
|
||||
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(PopoverItem.CSS.noHover);
|
||||
this.nodes.root?.classList.remove(css.noHover);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -283,7 +279,7 @@ export class PopoverItem {
|
|||
private activateOrEnableConfirmationMode(item: PopoverItemParams): void {
|
||||
if (item.confirmation === undefined) {
|
||||
try {
|
||||
item.onActivate(item);
|
||||
item.onActivate?.(item);
|
||||
this.disableConfirmationMode();
|
||||
} catch {
|
||||
this.animateError();
|
||||
|
@ -297,20 +293,20 @@ export class PopoverItem {
|
|||
* Animates item which symbolizes that error occured while executing 'onActivate()' callback
|
||||
*/
|
||||
private animateError(): void {
|
||||
if (this.nodes.icon.classList.contains(PopoverItem.CSS.wobbleAnimation)) {
|
||||
if (this.nodes.icon?.classList.contains(css.wobbleAnimation)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.nodes.icon.classList.add(PopoverItem.CSS.wobbleAnimation);
|
||||
this.nodes.icon?.classList.add(css.wobbleAnimation);
|
||||
|
||||
this.nodes.icon.addEventListener('animationend', this.onErrorAnimationEnd);
|
||||
this.nodes.icon?.addEventListener('animationend', this.onErrorAnimationEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles finish of error animation
|
||||
*/
|
||||
private onErrorAnimationEnd = (): void => {
|
||||
this.nodes.icon.classList.remove(PopoverItem.CSS.wobbleAnimation);
|
||||
this.nodes.icon.removeEventListener('animationend', this.onErrorAnimationEnd);
|
||||
this.nodes.icon?.classList.remove(css.wobbleAnimation);
|
||||
this.nodes.icon?.removeEventListener('animationend', this.onErrorAnimationEnd);
|
||||
};
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * from './search-input';
|
||||
export * from './search-input.types';
|
|
@ -0,0 +1,15 @@
|
|||
import { bem } from '../../../bem';
|
||||
|
||||
/**
|
||||
* Popover search input block CSS class constructor
|
||||
*/
|
||||
const className = bem('cdx-search-field');
|
||||
|
||||
/**
|
||||
* CSS class names to be used in popover search input class
|
||||
*/
|
||||
export const css = {
|
||||
wrapper: className(),
|
||||
icon: className('icon'),
|
||||
input: className('input'),
|
||||
};
|
|
@ -1,18 +1,13 @@
|
|||
import Dom from '../../dom';
|
||||
import Listeners from '../listeners';
|
||||
import Dom from '../../../../dom';
|
||||
import Listeners from '../../../listeners';
|
||||
import { IconSearch } from '@codexteam/icons';
|
||||
|
||||
/**
|
||||
* Item that could be searched
|
||||
*/
|
||||
interface SearchableItem {
|
||||
title?: string;
|
||||
}
|
||||
import { SearchableItem } from './search-input.types';
|
||||
import { css } from './search-input.const';
|
||||
|
||||
/**
|
||||
* Provides search input element and search logic
|
||||
*/
|
||||
export default class SearchInput {
|
||||
export class SearchInput {
|
||||
/**
|
||||
* Input wrapper element
|
||||
*/
|
||||
|
@ -36,28 +31,13 @@ export default class SearchInput {
|
|||
/**
|
||||
* Current search query
|
||||
*/
|
||||
private searchQuery: string;
|
||||
private searchQuery: string | undefined;
|
||||
|
||||
/**
|
||||
* Externally passed callback for the search
|
||||
*/
|
||||
private readonly onSearch: (query: string, items: SearchableItem[]) => void;
|
||||
|
||||
/**
|
||||
* Styles
|
||||
*/
|
||||
private static get CSS(): {
|
||||
input: string;
|
||||
icon: string;
|
||||
wrapper: string;
|
||||
} {
|
||||
return {
|
||||
wrapper: 'cdx-search-field',
|
||||
icon: 'cdx-search-field__icon',
|
||||
input: 'cdx-search-field__input',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param options - available config
|
||||
* @param options.items - searchable items list
|
||||
|
@ -67,13 +47,37 @@ export default class SearchInput {
|
|||
constructor({ items, onSearch, placeholder }: {
|
||||
items: SearchableItem[];
|
||||
onSearch: (query: string, items: SearchableItem[]) => void;
|
||||
placeholder: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
this.listeners = new Listeners();
|
||||
this.items = items;
|
||||
this.onSearch = onSearch;
|
||||
|
||||
this.render(placeholder);
|
||||
/** Build ui */
|
||||
this.wrapper = Dom.make('div', css.wrapper);
|
||||
|
||||
const iconWrapper = Dom.make('div', css.icon, {
|
||||
innerHTML: IconSearch,
|
||||
});
|
||||
|
||||
this.input = Dom.make('input', css.input, {
|
||||
placeholder,
|
||||
/**
|
||||
* Used to prevent focusing on the input by Tab key
|
||||
* (Popover in the Toolbar lays below the blocks,
|
||||
* so Tab in the last block will focus this hidden input if this property is not set)
|
||||
*/
|
||||
tabIndex: -1,
|
||||
}) as HTMLInputElement;
|
||||
|
||||
this.wrapper.appendChild(iconWrapper);
|
||||
this.wrapper.appendChild(this.input);
|
||||
|
||||
this.listeners.on(this.input, 'input', () => {
|
||||
this.searchQuery = this.input.value;
|
||||
|
||||
this.onSearch(this.searchQuery, this.foundItems);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -96,6 +100,7 @@ export default class SearchInput {
|
|||
public clear(): void {
|
||||
this.input.value = '';
|
||||
this.searchQuery = '';
|
||||
|
||||
this.onSearch('', this.foundItems);
|
||||
}
|
||||
|
||||
|
@ -106,38 +111,6 @@ export default class SearchInput {
|
|||
this.listeners.removeAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the search field
|
||||
*
|
||||
* @param placeholder - input placeholder
|
||||
*/
|
||||
private render(placeholder: string): void {
|
||||
this.wrapper = Dom.make('div', SearchInput.CSS.wrapper);
|
||||
|
||||
const iconWrapper = Dom.make('div', SearchInput.CSS.icon, {
|
||||
innerHTML: IconSearch,
|
||||
});
|
||||
|
||||
this.input = Dom.make('input', SearchInput.CSS.input, {
|
||||
placeholder,
|
||||
/**
|
||||
* Used to prevent focusing on the input by Tab key
|
||||
* (Popover in the Toolbar lays below the blocks,
|
||||
* so Tab in the last block will focus this hidden input if this property is not set)
|
||||
*/
|
||||
tabIndex: -1,
|
||||
}) as HTMLInputElement;
|
||||
|
||||
this.wrapper.appendChild(iconWrapper);
|
||||
this.wrapper.appendChild(this.input);
|
||||
|
||||
this.listeners.on(this.input, 'input', () => {
|
||||
this.searchQuery = this.input.value;
|
||||
|
||||
this.onSearch(this.searchQuery, this.foundItems);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list of found items for the current search query
|
||||
*/
|
||||
|
@ -152,8 +125,8 @@ export default class SearchInput {
|
|||
*/
|
||||
private checkItem(item: SearchableItem): boolean {
|
||||
const text = item.title?.toLowerCase() || '';
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
const query = this.searchQuery?.toLowerCase();
|
||||
|
||||
return text.includes(query);
|
||||
return query !== undefined ? text.includes(query) : false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Item that could be searched
|
||||
*/
|
||||
export interface SearchableItem {
|
||||
/**
|
||||
* Items title
|
||||
*/
|
||||
title?: string;
|
||||
}
|
|
@ -1,525 +1,10 @@
|
|||
import { PopoverItem } from './popover-item';
|
||||
import Dom from '../../dom';
|
||||
import { cacheable, keyCodes, isMobileScreen } from '../../utils';
|
||||
import Flipper from '../../flipper';
|
||||
import { PopoverItem as PopoverItemParams } from '../../../../types';
|
||||
import SearchInput from './search-input';
|
||||
import EventsDispatcher from '../events';
|
||||
import Listeners from '../listeners';
|
||||
import ScrollLocker from '../scroll-locker';
|
||||
import { PopoverDesktop } from './popover-desktop';
|
||||
import { PopoverMobile } from './popover-mobile';
|
||||
export * from './popover.types';
|
||||
|
||||
/**
|
||||
* Params required to render popover
|
||||
* Union type for all popovers
|
||||
*/
|
||||
interface PopoverParams {
|
||||
/**
|
||||
* Popover items config
|
||||
*/
|
||||
items: PopoverItemParams[];
|
||||
export type Popover = PopoverDesktop | PopoverMobile;
|
||||
|
||||
/**
|
||||
* Element of the page that creates 'scope' of the popover
|
||||
*/
|
||||
scopeElement?: HTMLElement;
|
||||
|
||||
/**
|
||||
* Arbitrary html element to be inserted before items list
|
||||
*/
|
||||
customContent?: HTMLElement;
|
||||
|
||||
/**
|
||||
* List of html elements inside custom content area that should be available for keyboard navigation
|
||||
*/
|
||||
customContentFlippableItems?: HTMLElement[];
|
||||
|
||||
/**
|
||||
* True if popover should contain search field
|
||||
*/
|
||||
searchable?: boolean;
|
||||
|
||||
/**
|
||||
* Popover texts overrides
|
||||
*/
|
||||
messages?: PopoverMessages
|
||||
}
|
||||
|
||||
/**
|
||||
* Texts used inside popover
|
||||
*/
|
||||
interface PopoverMessages {
|
||||
/** Text displayed when search has no results */
|
||||
nothingFound?: string;
|
||||
|
||||
/** Search input label */
|
||||
search?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Event that can be triggered by the Popover
|
||||
*/
|
||||
export enum PopoverEvent {
|
||||
/**
|
||||
* When popover closes
|
||||
*/
|
||||
Close = 'close'
|
||||
}
|
||||
|
||||
/**
|
||||
* Events fired by the Popover
|
||||
*/
|
||||
interface PopoverEventMap {
|
||||
[PopoverEvent.Close]: undefined;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Class responsible for rendering popover and handling its behaviour
|
||||
*/
|
||||
export default class Popover extends EventsDispatcher<PopoverEventMap> {
|
||||
/**
|
||||
* Flipper - module for keyboard iteration between elements
|
||||
*/
|
||||
public flipper: Flipper;
|
||||
|
||||
/**
|
||||
* List of popover items
|
||||
*/
|
||||
private items: PopoverItem[];
|
||||
|
||||
/**
|
||||
* Element of the page that creates 'scope' of the popover.
|
||||
* If possible, popover will not cross specified element's borders when opening.
|
||||
*/
|
||||
private scopeElement: HTMLElement = document.body;
|
||||
|
||||
/**
|
||||
* List of html elements inside custom content area that should be available for keyboard navigation
|
||||
*/
|
||||
private customContentFlippableItems: HTMLElement[] | undefined;
|
||||
|
||||
/**
|
||||
* Instance of the Search Input
|
||||
*/
|
||||
private search: SearchInput | undefined;
|
||||
|
||||
/**
|
||||
* Listeners util instance
|
||||
*/
|
||||
private listeners: Listeners = new Listeners();
|
||||
|
||||
/**
|
||||
* ScrollLocker instance
|
||||
*/
|
||||
private scrollLocker = new ScrollLocker();
|
||||
|
||||
/**
|
||||
* Popover CSS classes
|
||||
*/
|
||||
private static get CSS(): {
|
||||
popover: string;
|
||||
popoverOpenTop: string;
|
||||
popoverOpened: string;
|
||||
search: string;
|
||||
nothingFoundMessage: string;
|
||||
nothingFoundMessageDisplayed: string;
|
||||
customContent: string;
|
||||
customContentHidden: string;
|
||||
items: string;
|
||||
overlay: string;
|
||||
overlayHidden: string;
|
||||
} {
|
||||
return {
|
||||
popover: 'ce-popover',
|
||||
popoverOpenTop: 'ce-popover--open-top',
|
||||
popoverOpened: 'ce-popover--opened',
|
||||
search: 'ce-popover__search',
|
||||
nothingFoundMessage: 'ce-popover__nothing-found-message',
|
||||
nothingFoundMessageDisplayed: 'ce-popover__nothing-found-message--displayed',
|
||||
customContent: 'ce-popover__custom-content',
|
||||
customContentHidden: 'ce-popover__custom-content--hidden',
|
||||
items: 'ce-popover__items',
|
||||
overlay: 'ce-popover__overlay',
|
||||
overlayHidden: 'ce-popover__overlay--hidden',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refs to created HTML elements
|
||||
*/
|
||||
private nodes: {
|
||||
wrapper: HTMLElement | null;
|
||||
popover: HTMLElement | null;
|
||||
nothingFoundMessage: HTMLElement | null;
|
||||
customContent: HTMLElement | null;
|
||||
items: HTMLElement | null;
|
||||
overlay: HTMLElement | null;
|
||||
} = {
|
||||
wrapper: null,
|
||||
popover: null,
|
||||
nothingFoundMessage: null,
|
||||
customContent: null,
|
||||
items: null,
|
||||
overlay: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Messages that will be displayed in popover
|
||||
*/
|
||||
private messages: PopoverMessages = {
|
||||
nothingFound: 'Nothing found',
|
||||
search: 'Search',
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructs the instance
|
||||
*
|
||||
* @param params - popover construction params
|
||||
*/
|
||||
constructor(params: PopoverParams) {
|
||||
super();
|
||||
|
||||
this.items = params.items.map(item => new PopoverItem(item));
|
||||
|
||||
if (params.scopeElement !== undefined) {
|
||||
this.scopeElement = params.scopeElement;
|
||||
}
|
||||
|
||||
if (params.messages) {
|
||||
this.messages = {
|
||||
...this.messages,
|
||||
...params.messages,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.customContentFlippableItems) {
|
||||
this.customContentFlippableItems = params.customContentFlippableItems;
|
||||
}
|
||||
|
||||
this.make();
|
||||
|
||||
if (params.customContent) {
|
||||
this.addCustomContent(params.customContent);
|
||||
}
|
||||
|
||||
if (params.searchable) {
|
||||
this.addSearch();
|
||||
}
|
||||
|
||||
|
||||
this.initializeFlipper();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns HTML element corresponding to the popover
|
||||
*/
|
||||
public getElement(): HTMLElement {
|
||||
return this.nodes.wrapper as HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if some item inside popover is focused
|
||||
*/
|
||||
public hasFocus(): boolean {
|
||||
return this.flipper.hasFocus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open popover
|
||||
*/
|
||||
public show(): void {
|
||||
if (!this.shouldOpenBottom) {
|
||||
this.nodes.popover.style.setProperty('--popover-height', this.height + 'px');
|
||||
this.nodes.popover.classList.add(Popover.CSS.popoverOpenTop);
|
||||
}
|
||||
|
||||
this.nodes.overlay.classList.remove(Popover.CSS.overlayHidden);
|
||||
this.nodes.popover.classList.add(Popover.CSS.popoverOpened);
|
||||
this.flipper.activate(this.flippableElements);
|
||||
|
||||
if (this.search !== undefined) {
|
||||
this.search?.focus();
|
||||
}
|
||||
|
||||
if (isMobileScreen()) {
|
||||
this.scrollLocker.lock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes popover
|
||||
*/
|
||||
public hide(): void {
|
||||
this.nodes.popover.classList.remove(Popover.CSS.popoverOpened);
|
||||
this.nodes.popover.classList.remove(Popover.CSS.popoverOpenTop);
|
||||
this.nodes.overlay.classList.add(Popover.CSS.overlayHidden);
|
||||
this.flipper.deactivate();
|
||||
this.items.forEach(item => item.reset());
|
||||
|
||||
if (this.search !== undefined) {
|
||||
this.search.clear();
|
||||
}
|
||||
|
||||
if (isMobileScreen()) {
|
||||
this.scrollLocker.unlock();
|
||||
}
|
||||
|
||||
this.emit(PopoverEvent.Close);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears memory
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.flipper.deactivate();
|
||||
this.listeners.removeAll();
|
||||
|
||||
if (isMobileScreen()) {
|
||||
this.scrollLocker.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs HTML element corresponding to popover
|
||||
*/
|
||||
private make(): void {
|
||||
this.nodes.popover = Dom.make('div', [ Popover.CSS.popover ]);
|
||||
|
||||
this.nodes.nothingFoundMessage = Dom.make('div', [ Popover.CSS.nothingFoundMessage ], {
|
||||
textContent: this.messages.nothingFound,
|
||||
});
|
||||
|
||||
this.nodes.popover.appendChild(this.nodes.nothingFoundMessage);
|
||||
this.nodes.items = Dom.make('div', [ Popover.CSS.items ]);
|
||||
|
||||
this.items.forEach(item => {
|
||||
this.nodes.items.appendChild(item.getElement());
|
||||
});
|
||||
|
||||
this.nodes.popover.appendChild(this.nodes.items);
|
||||
|
||||
this.listeners.on(this.nodes.popover, 'click', (event: PointerEvent) => {
|
||||
const item = this.getTargetItem(event);
|
||||
|
||||
if (item === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleItemClick(item);
|
||||
});
|
||||
|
||||
this.nodes.wrapper = Dom.make('div');
|
||||
this.nodes.overlay = Dom.make('div', [Popover.CSS.overlay, Popover.CSS.overlayHidden]);
|
||||
|
||||
this.listeners.on(this.nodes.overlay, 'click', () => {
|
||||
this.hide();
|
||||
});
|
||||
|
||||
this.nodes.wrapper.appendChild(this.nodes.overlay);
|
||||
this.nodes.wrapper.appendChild(this.nodes.popover);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds search to the popover
|
||||
*/
|
||||
private addSearch(): void {
|
||||
this.search = new SearchInput({
|
||||
items: this.items,
|
||||
placeholder: this.messages.search,
|
||||
onSearch: (query: string, result: PopoverItem[]): void => {
|
||||
this.items.forEach(item => {
|
||||
const isHidden = !result.includes(item);
|
||||
|
||||
item.toggleHidden(isHidden);
|
||||
});
|
||||
this.toggleNothingFoundMessage(result.length === 0);
|
||||
this.toggleCustomContent(query !== '');
|
||||
|
||||
/** List of elements available for keyboard navigation considering search query applied */
|
||||
const flippableElements = query === '' ? this.flippableElements : result.map(item => item.getElement());
|
||||
|
||||
if (this.flipper.isActivated) {
|
||||
/** Update flipper items with only visible */
|
||||
this.flipper.deactivate();
|
||||
this.flipper.activate(flippableElements);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const searchElement = this.search.getElement();
|
||||
|
||||
searchElement.classList.add(Popover.CSS.search);
|
||||
|
||||
this.nodes.popover.insertBefore(searchElement, this.nodes.popover.firstChild);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds custom html content to the popover
|
||||
*
|
||||
* @param content - html content to append
|
||||
*/
|
||||
private addCustomContent(content: HTMLElement): void {
|
||||
this.nodes.customContent = content;
|
||||
this.nodes.customContent.classList.add(Popover.CSS.customContent);
|
||||
this.nodes.popover.insertBefore(content, this.nodes.popover.firstChild);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves popover item that is the target of the specified event
|
||||
*
|
||||
* @param event - event to retrieve popover item from
|
||||
*/
|
||||
private getTargetItem(event: PointerEvent): PopoverItem | undefined {
|
||||
return this.items.find(el => event.composedPath().includes(el.getElement()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles item clicks
|
||||
*
|
||||
* @param item - item to handle click of
|
||||
*/
|
||||
private handleItemClick(item: PopoverItem): void {
|
||||
if (item.isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** Cleanup other items state */
|
||||
this.items.filter(x => x !== item).forEach(x => x.reset());
|
||||
|
||||
item.handleClick();
|
||||
|
||||
this.toggleItemActivenessIfNeeded(item);
|
||||
|
||||
if (item.closeOnActivate) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Flipper instance which allows to navigate between popover items via keyboard
|
||||
*/
|
||||
private initializeFlipper(): void {
|
||||
this.flipper = new Flipper({
|
||||
items: this.flippableElements,
|
||||
focusedItemClass: PopoverItem.CSS.focused,
|
||||
allowedKeys: [
|
||||
keyCodes.TAB,
|
||||
keyCodes.UP,
|
||||
keyCodes.DOWN,
|
||||
keyCodes.ENTER,
|
||||
],
|
||||
});
|
||||
|
||||
this.flipper.onFlip(this.onFlip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list of elements available for keyboard navigation.
|
||||
* Contains both usual popover items elements and custom html content.
|
||||
*/
|
||||
private get flippableElements(): HTMLElement[] {
|
||||
const popoverItemsElements = this.items.map(item => item.getElement());
|
||||
const customContentControlsElements = this.customContentFlippableItems || [];
|
||||
|
||||
/**
|
||||
* Combine elements inside custom content area with popover items elements
|
||||
*/
|
||||
return customContentControlsElements.concat(popoverItemsElements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helps to calculate height of popover while it is not displayed on screen.
|
||||
* Renders invisible clone of popover to get actual height.
|
||||
*/
|
||||
@cacheable
|
||||
private get height(): number {
|
||||
let height = 0;
|
||||
|
||||
if (this.nodes.popover === null) {
|
||||
return height;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if popover should be opened bottom.
|
||||
* It should happen when there is enough space below or not enough space above
|
||||
*/
|
||||
private get shouldOpenBottom(): boolean {
|
||||
const popoverRect = this.nodes.popover.getBoundingClientRect();
|
||||
const scopeElementRect = this.scopeElement.getBoundingClientRect();
|
||||
const popoverHeight = this.height;
|
||||
const popoverPotentialBottomEdge = popoverRect.top + popoverHeight;
|
||||
const popoverPotentialTopEdge = popoverRect.top - popoverHeight;
|
||||
const bottomEdgeForComparison = Math.min(window.innerHeight, scopeElementRect.bottom);
|
||||
|
||||
return popoverPotentialTopEdge < scopeElementRect.top || popoverPotentialBottomEdge <= bottomEdgeForComparison;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on flipper navigation
|
||||
*/
|
||||
private onFlip = (): void => {
|
||||
const focusedItem = this.items.find(item => item.isFocused);
|
||||
|
||||
focusedItem.onFocus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles nothing found message visibility
|
||||
*
|
||||
* @param isDisplayed - true if the message should be displayed
|
||||
*/
|
||||
private toggleNothingFoundMessage(isDisplayed: boolean): void {
|
||||
this.nodes.nothingFoundMessage.classList.toggle(Popover.CSS.nothingFoundMessageDisplayed, isDisplayed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles custom content visibility
|
||||
*
|
||||
* @param isDisplayed - true if custom content should be displayed
|
||||
*/
|
||||
private toggleCustomContent(isDisplayed: boolean): void {
|
||||
this.nodes.customContent?.classList.toggle(Popover.CSS.customContentHidden, isDisplayed);
|
||||
}
|
||||
|
||||
/**
|
||||
* - Toggles item active state, if clicked popover item has property 'toggle' set to true.
|
||||
*
|
||||
* - Performs radiobutton-like behavior if the item has property 'toggle' set to string key.
|
||||
* (All the other items with the same key get inactive, and the item gets active)
|
||||
*
|
||||
* @param clickedItem - popover item that was clicked
|
||||
*/
|
||||
private toggleItemActivenessIfNeeded(clickedItem: PopoverItem): void {
|
||||
if (clickedItem.toggle === true) {
|
||||
clickedItem.toggleActive();
|
||||
}
|
||||
|
||||
if (typeof clickedItem.toggle === 'string') {
|
||||
const itemsInToggleGroup = this.items.filter(item => item.toggle === clickedItem.toggle);
|
||||
|
||||
/** If there's only one item in toggle group, toggle it */
|
||||
if (itemsInToggleGroup.length === 1) {
|
||||
clickedItem.toggleActive();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** Set clicked item as active and the rest items with same toggle key value as inactive */
|
||||
itemsInToggleGroup.forEach(item => {
|
||||
item.toggleActive(item === clickedItem);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
export { PopoverDesktop, PopoverMobile };
|
||||
|
|
291
src/components/utils/popover/popover-abstract.ts
Normal file
291
src/components/utils/popover/popover-abstract.ts
Normal file
|
@ -0,0 +1,291 @@
|
|||
import { PopoverItem } from './components/popover-item';
|
||||
import Dom from '../../dom';
|
||||
import { SearchInput, SearchableItem } from './components/search-input';
|
||||
import EventsDispatcher from '../events';
|
||||
import Listeners from '../listeners';
|
||||
import { PopoverEventMap, PopoverMessages, PopoverParams, PopoverEvent, PopoverNodes } from './popover.types';
|
||||
import { css } from './popover.const';
|
||||
|
||||
/**
|
||||
* Class responsible for rendering popover and handling its behaviour
|
||||
*/
|
||||
export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes> extends EventsDispatcher<PopoverEventMap> {
|
||||
/**
|
||||
* List of popover items
|
||||
*/
|
||||
protected items: PopoverItem[];
|
||||
|
||||
/**
|
||||
* Listeners util instance
|
||||
*/
|
||||
protected listeners: Listeners = new Listeners();
|
||||
|
||||
/**
|
||||
* Refs to created HTML elements
|
||||
*/
|
||||
protected nodes: Nodes;
|
||||
|
||||
/**
|
||||
* Instance of the Search Input
|
||||
*/
|
||||
private search: SearchInput | undefined;
|
||||
|
||||
/**
|
||||
* Messages that will be displayed in popover
|
||||
*/
|
||||
private messages: PopoverMessages = {
|
||||
nothingFound: 'Nothing found',
|
||||
search: 'Search',
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructs the instance
|
||||
*
|
||||
* @param params - popover construction params
|
||||
*/
|
||||
constructor(protected readonly params: PopoverParams) {
|
||||
super();
|
||||
|
||||
this.items = params.items.map(item => new PopoverItem(item));
|
||||
|
||||
if (params.messages) {
|
||||
this.messages = {
|
||||
...this.messages,
|
||||
...params.messages,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build html elements */
|
||||
this.nodes = {} as Nodes;
|
||||
|
||||
this.nodes.popoverContainer = Dom.make('div', [ css.popoverContainer ]);
|
||||
|
||||
this.nodes.nothingFoundMessage = Dom.make('div', [ css.nothingFoundMessage ], {
|
||||
textContent: this.messages.nothingFound,
|
||||
});
|
||||
|
||||
this.nodes.popoverContainer.appendChild(this.nodes.nothingFoundMessage);
|
||||
this.nodes.items = Dom.make('div', [ css.items ]);
|
||||
|
||||
this.items.forEach(item => {
|
||||
const itemEl = item.getElement();
|
||||
|
||||
if (itemEl === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.nodes.items.appendChild(itemEl);
|
||||
});
|
||||
|
||||
this.nodes.popoverContainer.appendChild(this.nodes.items);
|
||||
|
||||
this.listeners.on(this.nodes.popoverContainer, 'click', (event: Event) => this.handleClick(event));
|
||||
|
||||
this.nodes.popover = Dom.make('div', [
|
||||
css.popover,
|
||||
this.params.class,
|
||||
]);
|
||||
|
||||
this.nodes.popover.appendChild(this.nodes.popoverContainer);
|
||||
|
||||
if (params.customContent) {
|
||||
this.addCustomContent(params.customContent);
|
||||
}
|
||||
|
||||
if (params.searchable) {
|
||||
this.addSearch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns HTML element corresponding to the popover
|
||||
*/
|
||||
public getElement(): HTMLElement {
|
||||
return this.nodes.popover as HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open popover
|
||||
*/
|
||||
public show(): void {
|
||||
this.nodes.popover.classList.add(css.popoverOpened);
|
||||
|
||||
if (this.search !== undefined) {
|
||||
this.search.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes popover
|
||||
*/
|
||||
public hide(): void {
|
||||
this.nodes.popover.classList.remove(css.popoverOpened);
|
||||
this.nodes.popover.classList.remove(css.popoverOpenTop);
|
||||
|
||||
this.items.forEach(item => item.reset());
|
||||
|
||||
if (this.search !== undefined) {
|
||||
this.search.clear();
|
||||
}
|
||||
|
||||
this.emit(PopoverEvent.Close);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears memory
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.listeners.removeAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles input inside search field
|
||||
*
|
||||
* @param query - search query text
|
||||
* @param result - search results
|
||||
*/
|
||||
protected onSearch = (query: string, result: SearchableItem[]): void => {
|
||||
this.items.forEach(item => {
|
||||
const isHidden = !result.includes(item);
|
||||
|
||||
item.toggleHidden(isHidden);
|
||||
});
|
||||
this.toggleNothingFoundMessage(result.length === 0);
|
||||
this.toggleCustomContent(query !== '');
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves popover item that is the target of the specified event
|
||||
*
|
||||
* @param event - event to retrieve popover item from
|
||||
*/
|
||||
protected getTargetItem(event: Event): PopoverItem | undefined {
|
||||
return this.items.find(el => {
|
||||
const itemEl = el.getElement();
|
||||
|
||||
if (itemEl === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return event.composedPath().includes(itemEl);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds search to the popover
|
||||
*/
|
||||
private addSearch(): void {
|
||||
this.search = new SearchInput({
|
||||
items: this.items,
|
||||
placeholder: this.messages.search,
|
||||
onSearch: this.onSearch,
|
||||
});
|
||||
|
||||
const searchElement = this.search.getElement();
|
||||
|
||||
searchElement.classList.add(css.search);
|
||||
|
||||
this.nodes.popoverContainer.insertBefore(searchElement, this.nodes.popoverContainer.firstChild);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds custom html content to the popover
|
||||
*
|
||||
* @param content - html content to append
|
||||
*/
|
||||
private addCustomContent(content: HTMLElement): void {
|
||||
this.nodes.customContent = content;
|
||||
this.nodes.customContent.classList.add(css.customContent);
|
||||
this.nodes.popoverContainer.insertBefore(content, this.nodes.popoverContainer.firstChild);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicks inside popover
|
||||
*
|
||||
* @param event - item to handle click of
|
||||
*/
|
||||
private handleClick(event: Event): void {
|
||||
const item = this.getTargetItem(event);
|
||||
|
||||
if (item === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.children.length > 0) {
|
||||
this.showNestedItems(item);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** Cleanup other items state */
|
||||
this.items.filter(x => x !== item).forEach(x => x.reset());
|
||||
|
||||
item.handleClick();
|
||||
|
||||
this.toggleItemActivenessIfNeeded(item);
|
||||
|
||||
if (item.closeOnActivate) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles nothing found message visibility
|
||||
*
|
||||
* @param isDisplayed - true if the message should be displayed
|
||||
*/
|
||||
private toggleNothingFoundMessage(isDisplayed: boolean): void {
|
||||
this.nodes.nothingFoundMessage.classList.toggle(css.nothingFoundMessageDisplayed, isDisplayed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles custom content visibility
|
||||
*
|
||||
* @param isDisplayed - true if custom content should be displayed
|
||||
*/
|
||||
private toggleCustomContent(isDisplayed: boolean): void {
|
||||
this.nodes.customContent?.classList.toggle(css.customContentHidden, isDisplayed);
|
||||
}
|
||||
|
||||
/**
|
||||
* - Toggles item active state, if clicked popover item has property 'toggle' set to true.
|
||||
*
|
||||
* - Performs radiobutton-like behavior if the item has property 'toggle' set to string key.
|
||||
* (All the other items with the same key get inactive, and the item gets active)
|
||||
*
|
||||
* @param clickedItem - popover item that was clicked
|
||||
*/
|
||||
private toggleItemActivenessIfNeeded(clickedItem: PopoverItem): void {
|
||||
if (clickedItem.toggle === true) {
|
||||
clickedItem.toggleActive();
|
||||
}
|
||||
|
||||
if (typeof clickedItem.toggle === 'string') {
|
||||
const itemsInToggleGroup = this.items.filter(item => item.toggle === clickedItem.toggle);
|
||||
|
||||
/** If there's only one item in toggle group, toggle it */
|
||||
if (itemsInToggleGroup.length === 1) {
|
||||
clickedItem.toggleActive();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** Set clicked item as active and the rest items with same toggle key value as inactive */
|
||||
itemsInToggleGroup.forEach(item => {
|
||||
item.toggleActive(item === clickedItem);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles displaying nested items for the item. Behaviour differs depending on platform.
|
||||
*
|
||||
* @param item – item to show nested popover for
|
||||
*/
|
||||
protected abstract showNestedItems(item: PopoverItem): void;
|
||||
}
|
356
src/components/utils/popover/popover-desktop.ts
Normal file
356
src/components/utils/popover/popover-desktop.ts
Normal file
|
@ -0,0 +1,356 @@
|
|||
import Flipper from '../../flipper';
|
||||
import { PopoverAbstract } from './popover-abstract';
|
||||
import { PopoverItem, css as popoverItemCls } from './components/popover-item';
|
||||
import { PopoverParams } from './popover.types';
|
||||
import { keyCodes } from '../../utils';
|
||||
import { css } from './popover.const';
|
||||
import { SearchableItem } from './components/search-input';
|
||||
import { cacheable } from '../../utils';
|
||||
|
||||
/**
|
||||
* Desktop popover.
|
||||
* On desktop devices popover behaves like a floating element. Nested popover appears at right or left side.
|
||||
*/
|
||||
export class PopoverDesktop extends PopoverAbstract {
|
||||
/**
|
||||
* Flipper - module for keyboard iteration between elements
|
||||
*/
|
||||
public flipper: Flipper;
|
||||
|
||||
/**
|
||||
* List of html elements inside custom content area that should be available for keyboard navigation
|
||||
*/
|
||||
private customContentFlippableItems: HTMLElement[] | undefined;
|
||||
|
||||
/**
|
||||
* Reference to nested popover if exists.
|
||||
* Undefined by default, PopoverDesktop when exists and null after destroyed.
|
||||
*/
|
||||
private nestedPopover: PopoverDesktop | undefined | null;
|
||||
|
||||
/**
|
||||
* Last hovered item inside popover.
|
||||
* Is used to determine if cursor is moving inside one item or already moved away to another one.
|
||||
* Helps prevent reopening nested popover while cursor is moving inside one item area.
|
||||
*/
|
||||
private previouslyHoveredItem: PopoverItem | null = null;
|
||||
|
||||
/**
|
||||
* Popover nesting level. 0 value means that it is a root popover
|
||||
*/
|
||||
private nestingLevel = 0;
|
||||
|
||||
/**
|
||||
* Element of the page that creates 'scope' of the popover.
|
||||
* If possible, popover will not cross specified element's borders when opening.
|
||||
*/
|
||||
private scopeElement: HTMLElement = document.body;
|
||||
|
||||
/**
|
||||
* Construct the instance
|
||||
*
|
||||
* @param params - popover params
|
||||
*/
|
||||
constructor(params: PopoverParams) {
|
||||
super(params);
|
||||
|
||||
if (params.nestingLevel !== undefined) {
|
||||
this.nestingLevel = params.nestingLevel;
|
||||
}
|
||||
|
||||
if (this.nestingLevel > 0) {
|
||||
this.nodes.popover.classList.add(css.popoverNested);
|
||||
}
|
||||
|
||||
if (params.customContentFlippableItems) {
|
||||
this.customContentFlippableItems = params.customContentFlippableItems;
|
||||
}
|
||||
|
||||
if (params.scopeElement !== undefined) {
|
||||
this.scopeElement = params.scopeElement;
|
||||
}
|
||||
|
||||
if (this.nodes.popoverContainer !== null) {
|
||||
this.listeners.on(this.nodes.popoverContainer, 'mouseover', (event: Event) => this.handleHover(event));
|
||||
}
|
||||
|
||||
this.flipper = new Flipper({
|
||||
items: this.flippableElements,
|
||||
focusedItemClass: popoverItemCls.focused,
|
||||
allowedKeys: [
|
||||
keyCodes.TAB,
|
||||
keyCodes.UP,
|
||||
keyCodes.DOWN,
|
||||
keyCodes.ENTER,
|
||||
],
|
||||
});
|
||||
|
||||
this.flipper.onFlip(this.onFlip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if some item inside popover is focused
|
||||
*/
|
||||
public hasFocus(): boolean {
|
||||
if (this.flipper === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.flipper.hasFocus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll position inside items container of the popover
|
||||
*/
|
||||
public get scrollTop(): number {
|
||||
if (this.nodes.items === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.nodes.items.scrollTop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns visible element offset top
|
||||
*/
|
||||
public get offsetTop(): number {
|
||||
if (this.nodes.popoverContainer === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.nodes.popoverContainer.offsetTop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open popover
|
||||
*/
|
||||
public show(): void {
|
||||
this.nodes.popover.style.setProperty('--popover-height', this.size.height + 'px');
|
||||
|
||||
if (!this.shouldOpenBottom) {
|
||||
this.nodes.popover.classList.add(css.popoverOpenTop);
|
||||
}
|
||||
|
||||
if (!this.shouldOpenRight) {
|
||||
this.nodes.popover.classList.add(css.popoverOpenLeft);
|
||||
}
|
||||
|
||||
super.show();
|
||||
this.flipper.activate(this.flippableElements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes popover
|
||||
*/
|
||||
public hide(): void {
|
||||
super.hide();
|
||||
|
||||
this.flipper.deactivate();
|
||||
|
||||
this.destroyNestedPopoverIfExists();
|
||||
|
||||
this.previouslyHoveredItem = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears memory
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.hide();
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles input inside search field
|
||||
*
|
||||
* @param query - search query text
|
||||
* @param result - search results
|
||||
*/
|
||||
protected override onSearch = (query: string, result: SearchableItem[]): void => {
|
||||
super.onSearch(query, result);
|
||||
|
||||
/** List of elements available for keyboard navigation considering search query applied */
|
||||
const flippableElements = query === '' ? this.flippableElements : result.map(item => (item as PopoverItem).getElement());
|
||||
|
||||
if (this.flipper.isActivated) {
|
||||
/** Update flipper items with only visible */
|
||||
this.flipper.deactivate();
|
||||
this.flipper.activate(flippableElements as HTMLElement[]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles displaying nested items for the item.
|
||||
*
|
||||
* @param item – item to show nested popover for
|
||||
*/
|
||||
protected override showNestedItems(item: PopoverItem): void {
|
||||
if (this.nestedPopover !== null && this.nestedPopover !== undefined) {
|
||||
return;
|
||||
}
|
||||
this.showNestedPopoverForItem(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if popover should be opened bottom.
|
||||
* It should happen when there is enough space below or not enough space above
|
||||
*/
|
||||
private get shouldOpenBottom(): boolean {
|
||||
if (this.nodes.popover === undefined || this.nodes.popover === null) {
|
||||
return false;
|
||||
}
|
||||
const popoverRect = this.nodes.popoverContainer.getBoundingClientRect();
|
||||
const scopeElementRect = this.scopeElement.getBoundingClientRect();
|
||||
const popoverHeight = this.size.height;
|
||||
const popoverPotentialBottomEdge = popoverRect.top + popoverHeight;
|
||||
const popoverPotentialTopEdge = popoverRect.top - popoverHeight;
|
||||
const bottomEdgeForComparison = Math.min(window.innerHeight, scopeElementRect.bottom);
|
||||
|
||||
return popoverPotentialTopEdge < scopeElementRect.top || popoverPotentialBottomEdge <= bottomEdgeForComparison;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if popover should be opened left.
|
||||
* It should happen when there is enough space in the right or not enough space in the left
|
||||
*/
|
||||
private get shouldOpenRight(): boolean {
|
||||
if (this.nodes.popover === undefined || this.nodes.popover === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const popoverRect = this.nodes.popover.getBoundingClientRect();
|
||||
const scopeElementRect = this.scopeElement.getBoundingClientRect();
|
||||
const popoverWidth = this.size.width;
|
||||
const popoverPotentialRightEdge = popoverRect.right + popoverWidth;
|
||||
const popoverPotentialLeftEdge = popoverRect.left - popoverWidth;
|
||||
const rightEdgeForComparison = Math.min(window.innerWidth, scopeElementRect.right);
|
||||
|
||||
return popoverPotentialLeftEdge < scopeElementRect.left || popoverPotentialRightEdge <= rightEdgeForComparison;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helps to calculate size of popover while it is not displayed on screen.
|
||||
* Renders invisible clone of popover to get actual size.
|
||||
*/
|
||||
@cacheable
|
||||
private get size(): {height: number; width: number} {
|
||||
const size = {
|
||||
height: 0,
|
||||
width: 0,
|
||||
};
|
||||
|
||||
if (this.nodes.popover === null) {
|
||||
return size;
|
||||
}
|
||||
|
||||
const popoverClone = this.nodes.popover.cloneNode(true) as HTMLElement;
|
||||
|
||||
popoverClone.style.visibility = 'hidden';
|
||||
popoverClone.style.position = 'absolute';
|
||||
popoverClone.style.top = '-1000px';
|
||||
|
||||
popoverClone.classList.add(css.popoverOpened);
|
||||
popoverClone.querySelector('.' + css.popoverNested)?.remove();
|
||||
document.body.appendChild(popoverClone);
|
||||
|
||||
const container = popoverClone.querySelector('.' + css.popoverContainer) as HTMLElement;
|
||||
|
||||
size.height = container.offsetHeight;
|
||||
size.width = container.offsetWidth;
|
||||
|
||||
popoverClone.remove();
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys existing nested popover
|
||||
*/
|
||||
private destroyNestedPopoverIfExists(): void {
|
||||
if (this.nestedPopover === undefined || this.nestedPopover === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.nestedPopover.hide();
|
||||
this.nestedPopover.destroy();
|
||||
this.nestedPopover.getElement().remove();
|
||||
this.nestedPopover = null;
|
||||
this.flipper.activate(this.flippableElements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list of elements available for keyboard navigation.
|
||||
* Contains both usual popover items elements and custom html content.
|
||||
*/
|
||||
private get flippableElements(): HTMLElement[] {
|
||||
const popoverItemsElements = this.items.map(item => item.getElement());
|
||||
const customContentControlsElements = this.customContentFlippableItems || [];
|
||||
|
||||
/**
|
||||
* Combine elements inside custom content area with popover items elements
|
||||
*/
|
||||
return customContentControlsElements.concat(popoverItemsElements as HTMLElement[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on flipper navigation
|
||||
*/
|
||||
private onFlip = (): void => {
|
||||
const focusedItem = this.items.find(item => item.isFocused);
|
||||
|
||||
focusedItem?.onFocus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates and displays nested popover for specified item.
|
||||
* Is used only on desktop
|
||||
*
|
||||
* @param item - item to display nested popover by
|
||||
*/
|
||||
private showNestedPopoverForItem(item: PopoverItem): void {
|
||||
this.nestedPopover = new PopoverDesktop({
|
||||
items: item.children,
|
||||
nestingLevel: this.nestingLevel + 1,
|
||||
});
|
||||
|
||||
const nestedPopoverEl = this.nestedPopover.getElement();
|
||||
|
||||
this.nodes.popover.appendChild(nestedPopoverEl);
|
||||
const itemEl = item.getElement();
|
||||
const itemOffsetTop = (itemEl ? itemEl.offsetTop : 0) - this.scrollTop;
|
||||
const topOffset = this.offsetTop + itemOffsetTop;
|
||||
|
||||
nestedPopoverEl.style.setProperty('--trigger-item-top', topOffset + 'px');
|
||||
nestedPopoverEl.style.setProperty('--nesting-level', this.nestedPopover.nestingLevel.toString());
|
||||
|
||||
this.nestedPopover.show();
|
||||
this.flipper.deactivate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles hover events inside popover items container
|
||||
*
|
||||
* @param event - hover event data
|
||||
*/
|
||||
private handleHover(event: Event): void {
|
||||
const item = this.getTargetItem(event);
|
||||
|
||||
if (item === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.previouslyHoveredItem === item) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.destroyNestedPopoverIfExists();
|
||||
|
||||
this.previouslyHoveredItem = item;
|
||||
|
||||
if (item.children.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showNestedPopoverForItem(item);
|
||||
}
|
||||
}
|
142
src/components/utils/popover/popover-mobile.ts
Normal file
142
src/components/utils/popover/popover-mobile.ts
Normal file
|
@ -0,0 +1,142 @@
|
|||
import { PopoverAbstract } from './popover-abstract';
|
||||
import ScrollLocker from '../scroll-locker';
|
||||
import { PopoverHeader } from './components/popover-header';
|
||||
import { PopoverStatesHistory } from './utils/popover-states-history';
|
||||
import { PopoverMobileNodes, PopoverParams } from './popover.types';
|
||||
import { PopoverItem } from './components/popover-item';
|
||||
import { PopoverItem as PopoverItemParams } from '../../../../types';
|
||||
import { css } from './popover.const';
|
||||
import Dom from '../../dom';
|
||||
|
||||
/**
|
||||
* Mobile Popover.
|
||||
* On mobile devices Popover behaves like a fixed panel at the bottom of screen. Nested item appears like "pages" with the "back" button
|
||||
*/
|
||||
export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
|
||||
/**
|
||||
* ScrollLocker instance
|
||||
*/
|
||||
private scrollLocker = new ScrollLocker();
|
||||
|
||||
/**
|
||||
* Reference to popover header if exists
|
||||
*/
|
||||
private header: PopoverHeader | undefined | null;
|
||||
|
||||
/**
|
||||
* History of popover states for back navigation.
|
||||
* Is used for mobile version of popover,
|
||||
* where we can not display nested popover of the screen and
|
||||
* have to render nested items in the same popover switching to new state
|
||||
*/
|
||||
private history = new PopoverStatesHistory();
|
||||
|
||||
/**
|
||||
* Construct the instance
|
||||
*
|
||||
* @param params - popover params
|
||||
*/
|
||||
constructor(params: PopoverParams) {
|
||||
super(params);
|
||||
|
||||
this.nodes.overlay = Dom.make('div', [css.overlay, css.overlayHidden]);
|
||||
this.nodes.popover.insertBefore(this.nodes.overlay, this.nodes.popover.firstChild);
|
||||
|
||||
this.listeners.on(this.nodes.overlay, 'click', () => {
|
||||
this.hide();
|
||||
});
|
||||
|
||||
/* Save state to history for proper navigation between nested and parent popovers */
|
||||
this.history.push({ items: params.items });
|
||||
}
|
||||
|
||||
/**
|
||||
* Open popover
|
||||
*/
|
||||
public show(): void {
|
||||
this.nodes.overlay.classList.remove(css.overlayHidden);
|
||||
|
||||
super.show();
|
||||
|
||||
this.scrollLocker.lock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes popover
|
||||
*/
|
||||
public hide(): void {
|
||||
super.hide();
|
||||
this.nodes.overlay.classList.add(css.overlayHidden);
|
||||
|
||||
this.scrollLocker.unlock();
|
||||
|
||||
this.history.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears memory
|
||||
*/
|
||||
public destroy(): void {
|
||||
super.destroy();
|
||||
|
||||
this.scrollLocker.unlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles displaying nested items for the item
|
||||
*
|
||||
* @param item – item to show nested popover for
|
||||
*/
|
||||
protected override showNestedItems(item: PopoverItem): void {
|
||||
/** Show nested items */
|
||||
this.updateItemsAndHeader(item.children, item.title);
|
||||
|
||||
this.history.push({
|
||||
title: item.title,
|
||||
items: item.children,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes rendered popover items and header and displays new ones
|
||||
*
|
||||
* @param title - new popover header text
|
||||
* @param items - new popover items
|
||||
*/
|
||||
private updateItemsAndHeader(items: PopoverItemParams[], title?: string ): void {
|
||||
/** Re-render header */
|
||||
if (this.header !== null && this.header !== undefined) {
|
||||
this.header.destroy();
|
||||
this.header = null;
|
||||
}
|
||||
if (title !== undefined) {
|
||||
this.header = new PopoverHeader({
|
||||
text: title,
|
||||
onBackButtonClick: () => {
|
||||
this.history.pop();
|
||||
|
||||
this.updateItemsAndHeader(this.history.currentItems, this.history.currentTitle);
|
||||
},
|
||||
});
|
||||
const headerEl = this.header.getElement();
|
||||
|
||||
if (headerEl !== null) {
|
||||
this.nodes.popoverContainer.insertBefore(headerEl, this.nodes.popoverContainer.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-render items */
|
||||
this.items.forEach(item => item.getElement()?.remove());
|
||||
|
||||
this.items = items.map(params => new PopoverItem(params));
|
||||
|
||||
this.items.forEach(item => {
|
||||
const itemEl = item.getElement();
|
||||
|
||||
if (itemEl === null) {
|
||||
return;
|
||||
}
|
||||
this.nodes.items?.appendChild(itemEl);
|
||||
});
|
||||
}
|
||||
}
|
27
src/components/utils/popover/popover.const.ts
Normal file
27
src/components/utils/popover/popover.const.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { bem } from '../bem';
|
||||
|
||||
/**
|
||||
* Popover block CSS class constructor
|
||||
*/
|
||||
const className = bem('ce-popover');
|
||||
|
||||
/**
|
||||
* CSS class names to be used in popover
|
||||
*/
|
||||
export const css = {
|
||||
popover: className(),
|
||||
popoverContainer: className('container'),
|
||||
popoverOpenTop: className(null, 'open-top'),
|
||||
popoverOpenLeft: className(null, 'open-left'),
|
||||
popoverOpened: className(null, 'opened'),
|
||||
search: className('search'),
|
||||
nothingFoundMessage: className('nothing-found-message'),
|
||||
nothingFoundMessageDisplayed: className('nothing-found-message', 'displayed'),
|
||||
customContent: className('custom-content'),
|
||||
customContentHidden: className('custom-content', 'hidden'),
|
||||
items: className('items'),
|
||||
overlay: className('overlay'),
|
||||
overlayHidden: className('overlay', 'hidden'),
|
||||
popoverNested: className(null, 'nested'),
|
||||
popoverHeader: className('header'),
|
||||
};
|
109
src/components/utils/popover/popover.types.ts
Normal file
109
src/components/utils/popover/popover.types.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import { PopoverItem as PopoverItemParams } from '../../../../types';
|
||||
|
||||
/**
|
||||
* Params required to render popover
|
||||
*/
|
||||
export interface PopoverParams {
|
||||
/**
|
||||
* Popover items config
|
||||
*/
|
||||
items: PopoverItemParams[];
|
||||
|
||||
/**
|
||||
* Element of the page that creates 'scope' of the popover.
|
||||
* Depending on its size popover position will be calculated
|
||||
*/
|
||||
scopeElement?: HTMLElement;
|
||||
|
||||
/**
|
||||
* Arbitrary html element to be inserted before items list
|
||||
*/
|
||||
customContent?: HTMLElement;
|
||||
|
||||
/**
|
||||
* List of html elements inside custom content area that should be available for keyboard navigation
|
||||
*/
|
||||
customContentFlippableItems?: HTMLElement[];
|
||||
|
||||
/**
|
||||
* True if popover should contain search field
|
||||
*/
|
||||
searchable?: boolean;
|
||||
|
||||
/**
|
||||
* Popover texts overrides
|
||||
*/
|
||||
messages?: PopoverMessages
|
||||
|
||||
/**
|
||||
* CSS class name for popover root element
|
||||
*/
|
||||
class?: string;
|
||||
|
||||
/**
|
||||
* Popover nesting level. 0 value means that it is a root popover
|
||||
*/
|
||||
nestingLevel?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Texts used inside popover
|
||||
*/
|
||||
export interface PopoverMessages {
|
||||
/** Text displayed when search has no results */
|
||||
nothingFound?: string;
|
||||
|
||||
/** Search input label */
|
||||
search?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Event that can be triggered by the Popover
|
||||
*/
|
||||
export enum PopoverEvent {
|
||||
/**
|
||||
* When popover closes
|
||||
*/
|
||||
Close = 'close'
|
||||
}
|
||||
|
||||
/**
|
||||
* Events fired by the Popover
|
||||
*/
|
||||
export interface PopoverEventMap {
|
||||
/**
|
||||
* Fired when popover closes
|
||||
*/
|
||||
[PopoverEvent.Close]: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML elements required to display popover
|
||||
*/
|
||||
export interface PopoverNodes {
|
||||
/** Root popover element */
|
||||
popover: HTMLElement;
|
||||
|
||||
/** Wraps all the visible popover elements, has background and rounded corners */
|
||||
popoverContainer: HTMLElement;
|
||||
|
||||
/** Message displayed when no items found while searching */
|
||||
nothingFoundMessage: HTMLElement;
|
||||
|
||||
/** Popover items wrapper */
|
||||
items: HTMLElement;
|
||||
|
||||
/** Custom html content area */
|
||||
customContent: HTMLElement | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML elements required to display mobile popover
|
||||
*/
|
||||
export interface PopoverMobileNodes extends PopoverNodes {
|
||||
/** Popover header element */
|
||||
header: HTMLElement;
|
||||
|
||||
/** Overlay, displayed under popover on mobile */
|
||||
overlay: HTMLElement;
|
||||
}
|
73
src/components/utils/popover/utils/popover-states-history.ts
Normal file
73
src/components/utils/popover/utils/popover-states-history.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { PopoverItem } from '../../../../../types';
|
||||
|
||||
/**
|
||||
* Represents single states history item
|
||||
*/
|
||||
interface PopoverStatesHistoryItem {
|
||||
/**
|
||||
* Popover title
|
||||
*/
|
||||
title?: string;
|
||||
|
||||
/**
|
||||
* Popover items
|
||||
*/
|
||||
items: PopoverItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages items history inside popover. Allows to navigate back in history
|
||||
*/
|
||||
export class PopoverStatesHistory {
|
||||
/**
|
||||
* Previous items states
|
||||
*/
|
||||
private history: PopoverStatesHistoryItem[] = [];
|
||||
|
||||
/**
|
||||
* Push new popover state
|
||||
*
|
||||
* @param state - new state
|
||||
*/
|
||||
public push(state: PopoverStatesHistoryItem): void {
|
||||
this.history.push(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop last popover state
|
||||
*/
|
||||
public pop(): PopoverStatesHistoryItem | undefined {
|
||||
return this.history.pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Title retrieved from the current state
|
||||
*/
|
||||
public get currentTitle(): string | undefined {
|
||||
if (this.history.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.history[this.history.length - 1].title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Items list retrieved from the current state
|
||||
*/
|
||||
public get currentItems(): PopoverItem[] {
|
||||
if (this.history.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.history[this.history.length - 1].items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns history to initial popover state
|
||||
*/
|
||||
public reset(): void {
|
||||
while (this.history.length > 1) {
|
||||
this.pop();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,8 @@
|
|||
/**
|
||||
* Popover styles
|
||||
*
|
||||
* @todo split into separate files popover styles
|
||||
* @todo make css variables work
|
||||
*/
|
||||
.ce-popover {
|
||||
--border-radius: 6px;
|
||||
|
@ -21,7 +24,15 @@
|
|||
--color-background-item-hover: #eff2f5;
|
||||
--color-background-item-confirm: #E24A4A;
|
||||
--color-background-item-confirm-hover: #CE4343;
|
||||
--popover-top: calc(100% + var(--offset-from-target));
|
||||
--popover-left: 0;
|
||||
--nested-popover-overlap: 4px;
|
||||
|
||||
--icon-size: 20px;
|
||||
--item-padding: 3px;
|
||||
--item-height: calc(var(--icon-size) + 2 * var(--item-padding));
|
||||
|
||||
&__container {
|
||||
min-width: var(--width);
|
||||
width: var(--width);
|
||||
max-height: var(--max-height);
|
||||
|
@ -30,8 +41,9 @@
|
|||
box-sizing: border-box;
|
||||
box-shadow: 0 3px 15px -3px var(--color-shadow);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + var(--offset-from-target));
|
||||
left: var(--popover-left);
|
||||
top: var(--popover-top);
|
||||
|
||||
background: var(--color-background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -42,8 +54,10 @@
|
|||
pointer-events: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&--opened {
|
||||
.ce-popover__container {
|
||||
opacity: 1;
|
||||
padding: var(--padding);
|
||||
max-height: var(--max-height);
|
||||
|
@ -56,6 +70,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&--open-top {
|
||||
.ce-popover__container {
|
||||
--popover-top: calc(-1 * (var(--offset-from-target) + var(--popover-height)));
|
||||
}
|
||||
}
|
||||
|
||||
&--open-left {
|
||||
.ce-popover__container {
|
||||
--popover-left: calc(-1 * var(--width) + 100%);
|
||||
}
|
||||
}
|
||||
|
||||
&__items {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
|
@ -81,11 +109,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
&--open-top {
|
||||
top: calc(-1 * (var(--offset-from-target) + var(--popover-height)));
|
||||
}
|
||||
|
||||
@media (--mobile) {
|
||||
|
||||
.ce-popover__container {
|
||||
--offset: 5px;
|
||||
|
||||
position: fixed;
|
||||
|
@ -96,6 +123,7 @@
|
|||
bottom: calc(var(--offset) + env(safe-area-inset-bottom));
|
||||
top: auto;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ce-popover__search {
|
||||
display: none;
|
||||
|
@ -134,6 +162,32 @@
|
|||
&__custom-content--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&--nested {
|
||||
.ce-popover__container {
|
||||
/* Variable --nesting-level is set via js in showNestedPopoverForItem() method */
|
||||
--popover-left: calc(var(--nesting-level) * (var(--width) - var(--nested-popover-overlap)));
|
||||
/* Variable --trigger-item-top is set via js in showNestedPopoverForItem() method */
|
||||
top: calc(var(--trigger-item-top) - var(--nested-popover-overlap));
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
&--open-top.ce-popover--nested {
|
||||
.ce-popover__container {
|
||||
/** Bottom edge of nested popover should not be lower than bottom edge of parent popover when opened upwards */
|
||||
top: calc(var(--trigger-item-top) - var(--popover-height) + var(--item-height) + var(--offset-from-target) + var(--nested-popover-overlap));
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
&--open-left {
|
||||
.ce-popover--nested {
|
||||
.ce-popover__container {
|
||||
--popover-left: calc(-1 * (var(--nesting-level) + 1) * var(--width) + 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -142,13 +196,10 @@
|
|||
*/
|
||||
.ce-popover-item {
|
||||
--border-radius: 6px;
|
||||
--icon-size: 20px;
|
||||
--icon-size-mobile: 28px;
|
||||
|
||||
border-radius: var(--border-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px;
|
||||
padding: var(--item-padding);
|
||||
color: var(--color-text-primary);
|
||||
user-select: none;
|
||||
|
||||
|
@ -161,15 +212,11 @@
|
|||
}
|
||||
|
||||
&__icon {
|
||||
border-radius: 5px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
box-shadow: 0 0 0 1px var(--color-border-icon);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 10px;
|
||||
|
||||
svg {
|
||||
width: var(--icon-size);
|
||||
|
@ -182,12 +229,19 @@
|
|||
border-radius: 8px;
|
||||
|
||||
svg {
|
||||
width: var(--icon-size-mobile);
|
||||
height: var(--icon-size-mobile);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__icon--tool {
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 0 1px var(--color-border-icon);
|
||||
background: #fff;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
|
@ -197,6 +251,8 @@
|
|||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
margin-right: auto;
|
||||
|
||||
@media (--mobile) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
@ -205,7 +261,6 @@
|
|||
&__secondary-title {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
letter-spacing: -0.1em;
|
||||
padding-right: 5px;
|
||||
|
@ -373,3 +428,32 @@
|
|||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover header styles
|
||||
*/
|
||||
.ce-popover-header {
|
||||
margin-bottom: 8px;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&__text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__back-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--color-text-primary);
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ describe('Slash keydown', function () {
|
|||
.click()
|
||||
.type('/');
|
||||
|
||||
cy.get('[data-cy="toolbox"] .ce-popover')
|
||||
cy.get('[data-cy="toolbox"] .ce-popover__container')
|
||||
.should('be.visible');
|
||||
});
|
||||
|
||||
|
@ -46,7 +46,7 @@ describe('Slash keydown', function () {
|
|||
.click()
|
||||
.type(`{${key}}/`);
|
||||
|
||||
cy.get('[data-cy="toolbox"] .ce-popover')
|
||||
cy.get('[data-cy="toolbox"] .ce-popover__container')
|
||||
.should('not.be.visible');
|
||||
});
|
||||
});
|
||||
|
@ -72,7 +72,7 @@ describe('Slash keydown', function () {
|
|||
.click()
|
||||
.type('/');
|
||||
|
||||
cy.get('[data-cy="toolbox"] .ce-popover')
|
||||
cy.get('[data-cy="toolbox"] .ce-popover__container')
|
||||
.should('not.be.visible');
|
||||
|
||||
/**
|
||||
|
@ -106,7 +106,7 @@ describe('CMD+Slash keydown', function () {
|
|||
.click()
|
||||
.type('{cmd}/');
|
||||
|
||||
cy.get('[data-cy="block-tunes"] .ce-popover')
|
||||
cy.get('[data-cy="block-tunes"] .ce-popover__container')
|
||||
.should('be.visible');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import Popover from '../../../../src/components/utils/popover';
|
||||
import { PopoverDesktop as Popover } from '../../../../src/components/utils/popover';
|
||||
import { PopoverItem } from '../../../../types';
|
||||
import { TunesMenuConfig } from '../../../../types/tools';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
|
||||
|
@ -257,4 +258,187 @@ describe('Popover', () => {
|
|||
cy.get('[data-cy-name=customContent]');
|
||||
});
|
||||
});
|
||||
|
||||
it('should display nested popover (desktop)', () => {
|
||||
/** Tool class to test how it is displayed inside block tunes popover */
|
||||
class TestTune {
|
||||
public static isTune = true;
|
||||
|
||||
/** Tool data displayed in block tunes popover */
|
||||
public render(): TunesMenuConfig {
|
||||
return {
|
||||
icon: 'Icon',
|
||||
title: 'Title',
|
||||
toggle: 'key',
|
||||
name: 'test-item',
|
||||
children: {
|
||||
items: [
|
||||
{
|
||||
icon: 'Icon',
|
||||
title: 'Title',
|
||||
name: 'nested-test-item',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Create editor instance */
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
testTool: TestTune,
|
||||
},
|
||||
tunes: [ 'testTool' ],
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Hello',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/** Open block tunes menu */
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('.cdx-block')
|
||||
.click();
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('.ce-toolbar__settings-btn')
|
||||
.click();
|
||||
|
||||
/** Check item with children has arrow icon */
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('[data-item-name="test-item"]')
|
||||
.get('.ce-popover-item__icon--chevron-right')
|
||||
.should('be.visible');
|
||||
|
||||
/** Click the item */
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('[data-item-name="test-item"]')
|
||||
.click();
|
||||
|
||||
/** Check nested popover opened */
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('.ce-popover--nested .ce-popover__container')
|
||||
.should('be.visible');
|
||||
|
||||
/** Check child item displayed */
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('.ce-popover--nested .ce-popover__container')
|
||||
.get('[data-item-name="nested-test-item"]')
|
||||
.should('be.visible');
|
||||
});
|
||||
|
||||
|
||||
it('should display children items, back button and item header and correctly switch between parent and child states (mobile)', () => {
|
||||
/** Tool class to test how it is displayed inside block tunes popover */
|
||||
class TestTune {
|
||||
public static isTune = true;
|
||||
|
||||
/** Tool data displayed in block tunes popover */
|
||||
public render(): TunesMenuConfig {
|
||||
return {
|
||||
icon: 'Icon',
|
||||
title: 'Tune',
|
||||
toggle: 'key',
|
||||
name: 'test-item',
|
||||
children: {
|
||||
items: [
|
||||
{
|
||||
icon: 'Icon',
|
||||
title: 'Title',
|
||||
name: 'nested-test-item',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
cy.viewport('iphone-6+');
|
||||
|
||||
|
||||
/** Create editor instance */
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
testTool: TestTune,
|
||||
},
|
||||
tunes: [ 'testTool' ],
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Hello',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/** Open block tunes menu */
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('.cdx-block')
|
||||
.click();
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('.ce-toolbar__settings-btn')
|
||||
.click();
|
||||
|
||||
/** Check item with children has arrow icon */
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('[data-item-name="test-item"]')
|
||||
.get('.ce-popover-item__icon--chevron-right')
|
||||
.should('be.visible');
|
||||
|
||||
/** Click the item */
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('[data-item-name="test-item"]')
|
||||
.click();
|
||||
|
||||
/** Check child item displayed */
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('.ce-popover__container')
|
||||
.get('[data-item-name="nested-test-item"]')
|
||||
.should('be.visible');
|
||||
|
||||
/** Check header displayed */
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('.ce-popover-header')
|
||||
.should('have.text', 'Tune');
|
||||
|
||||
/** Check back button displayed */
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('.ce-popover__container')
|
||||
.get('.ce-popover-header__back-button')
|
||||
.should('be.visible');
|
||||
|
||||
/** Click back button */
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('.ce-popover__container')
|
||||
.get('.ce-popover-header__back-button')
|
||||
.click();
|
||||
|
||||
/** Check child item is not displayed */
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('.ce-popover__container')
|
||||
.get('[data-item-name="nested-test-item"]')
|
||||
.should('not.exist');
|
||||
|
||||
/** Check back button is not displayed */
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('.ce-popover__container')
|
||||
.get('.ce-popover-header__back-button')
|
||||
.should('not.exist');
|
||||
|
||||
/** Check header is not displayed */
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('.ce-popover-header')
|
||||
.should('not.exist');
|
||||
});
|
||||
});
|
||||
|
|
21
types/configs/popover.d.ts
vendored
21
types/configs/popover.d.ts
vendored
|
@ -60,7 +60,7 @@ export interface PopoverItemWithConfirmation extends PopoverItemBase {
|
|||
}
|
||||
|
||||
/**
|
||||
* Represents default popover item without confirmation state configuration
|
||||
* Represents popover item without confirmation state configuration
|
||||
*/
|
||||
export interface PopoverItemWithoutConfirmation extends PopoverItemBase {
|
||||
confirmation?: never;
|
||||
|
@ -72,10 +72,27 @@ export interface PopoverItemWithoutConfirmation extends PopoverItemBase {
|
|||
* @param event - event that initiated item activation
|
||||
*/
|
||||
onActivate: (item: PopoverItem, event?: PointerEvent) => void;
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Represents popover item with children (nested popover items)
|
||||
*/
|
||||
export interface PopoverItemWithChildren extends PopoverItemBase {
|
||||
confirmation?: never;
|
||||
onActivate?: never;
|
||||
|
||||
/**
|
||||
* Items of nested popover that should be open on the current item hover/click (depending on platform)
|
||||
*/
|
||||
children?: {
|
||||
items: PopoverItem[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents single popover item
|
||||
*/
|
||||
export type PopoverItem = PopoverItemWithConfirmation | PopoverItemWithoutConfirmation
|
||||
export type PopoverItem = PopoverItemWithConfirmation | PopoverItemWithoutConfirmation | PopoverItemWithChildren
|
||||
|
||||
|
|
Loading…
Reference in a new issue