From 5125f015dc87424d137ce4824863f0f64baf8eff Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Sat, 13 Apr 2024 20:34:26 +0300 Subject: [PATCH] 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 --- docs/CHANGELOG.md | 4 + src/components/dom.ts | 6 +- .../events/EditorMobileLayoutToggled.ts | 15 + src/components/events/index.ts | 5 +- src/components/flipper.ts | 9 +- .../modules/toolbar/blockSettings.ts | 36 +- src/components/modules/toolbar/index.ts | 4 +- src/components/modules/ui.ts | 22 +- src/components/ui/toolbox.ts | 107 +++- src/components/utils/bem.ts | 25 + .../components/popover-header/index.ts | 2 + .../popover-header/popover-header.const.ts | 15 + .../popover-header/popover-header.ts | 71 +++ .../popover-header/popover-header.types.ts | 14 + .../popover/components/popover-item/index.ts | 2 + .../popover-item/popover-item.const.ts | 26 + .../popover-item}/popover-item.ts | 120 ++-- .../popover/components/search-input/index.ts | 2 + .../search-input/search-input.const.ts | 15 + .../search-input}/search-input.ts | 97 ++-- .../search-input/search-input.types.ts | 9 + src/components/utils/popover/index.ts | 527 +----------------- .../utils/popover/popover-abstract.ts | 291 ++++++++++ .../utils/popover/popover-desktop.ts | 356 ++++++++++++ .../utils/popover/popover-mobile.ts | 142 +++++ src/components/utils/popover/popover.const.ts | 27 + src/components/utils/popover/popover.types.ts | 109 ++++ .../popover/utils/popover-states-history.ts | 73 +++ src/styles/popover.css | 184 ++++-- .../tests/modules/BlockEvents/Slash.cy.ts | 8 +- test/cypress/tests/utils/popover.cy.ts | 186 ++++++- types/configs/popover.d.ts | 21 +- 32 files changed, 1770 insertions(+), 760 deletions(-) create mode 100644 src/components/events/EditorMobileLayoutToggled.ts create mode 100644 src/components/utils/bem.ts create mode 100644 src/components/utils/popover/components/popover-header/index.ts create mode 100644 src/components/utils/popover/components/popover-header/popover-header.const.ts create mode 100644 src/components/utils/popover/components/popover-header/popover-header.ts create mode 100644 src/components/utils/popover/components/popover-header/popover-header.types.ts create mode 100644 src/components/utils/popover/components/popover-item/index.ts create mode 100644 src/components/utils/popover/components/popover-item/popover-item.const.ts rename src/components/utils/popover/{ => components/popover-item}/popover-item.ts (66%) create mode 100644 src/components/utils/popover/components/search-input/index.ts create mode 100644 src/components/utils/popover/components/search-input/search-input.const.ts rename src/components/utils/popover/{ => components/search-input}/search-input.ts (72%) create mode 100644 src/components/utils/popover/components/search-input/search-input.types.ts create mode 100644 src/components/utils/popover/popover-abstract.ts create mode 100644 src/components/utils/popover/popover-desktop.ts create mode 100644 src/components/utils/popover/popover-mobile.ts create mode 100644 src/components/utils/popover/popover.const.ts create mode 100644 src/components/utils/popover/popover.types.ts create mode 100644 src/components/utils/popover/utils/popover-states-history.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c98cd547..0d19eae3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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) diff --git a/src/components/dom.ts b/src/components/dom.ts index f7b653cc..1bf2b1c4 100644 --- a/src/components/dom.ts +++ b/src/components/dom.ts @@ -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); } diff --git a/src/components/events/EditorMobileLayoutToggled.ts b/src/components/events/EditorMobileLayoutToggled.ts new file mode 100644 index 00000000..cd4f953e --- /dev/null +++ b/src/components/events/EditorMobileLayoutToggled.ts @@ -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; +} + diff --git a/src/components/events/index.ts b/src/components/events/index.ts index 8d93d97d..15aac17d 100644 --- a/src/components/events/index.ts +++ b/src/components/events/index.ts @@ -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 } diff --git a/src/components/flipper.ts b/src/components/flipper.ts index 28998460..516e2b62 100644 --- a/src/components/flipper.ts +++ b/src/components/flipper.ts @@ -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); } diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts index 24df4447..e43a072e 100644 --- a/src/components/modules/toolbar/blockSettings.ts +++ b/src/components/modules/toolbar/blockSettings.ts @@ -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 { /** * 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 { * * @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 { /** * 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 { 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 { */ public destroy(): void { this.removeAllNodes(); + this.listeners.destroy(); + this.eventsDispatcher.off(EditorMobileLayoutToggled, this.close); } /** @@ -118,7 +127,10 @@ export default class BlockSettings extends Module { /** 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 { 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 { /** * 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 { this.popover.getElement().remove(); this.popover = null; } - } + }; /** * Handles popover close event diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts index aff4dc4f..aaeceba8 100644 --- a/src/components/modules/toolbar/index.ts +++ b/src/components/modules/toolbar/index.ts @@ -220,6 +220,7 @@ export default class Toolbar extends Module { }; } + /** * Toggles read-only mode * @@ -479,9 +480,10 @@ export default class Toolbar extends Module { } }); - return this.toolboxInstance.make(); + return this.toolboxInstance.getElement(); } + /** * Handler for Plus Button */ diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts index 70e6f2db..d9b96e74 100644 --- a/src/components/modules/ui.ts +++ b/src/components/modules/ui.ts @@ -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 { /** * Detect mobile version */ - this.checkIsMobile(); + this.setIsMobile(); /** * Make main UI elements @@ -234,10 +235,21 @@ export default class UI extends Module { } /** - * 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 { /** * Detect mobile version */ - this.checkIsMobile(); + this.setIsMobile(); } /** diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index 3984e36c..60b25bf8 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -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 { */ public opened = false; + /** + * Listeners util instance + */ + protected listeners: Listeners = new Listeners(); + /** * Editor API */ @@ -82,8 +91,9 @@ export default class Toolbox extends EventsDispatcher { /** * 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,10 +109,8 @@ export default class Toolbox extends EventsDispatcher { * Current module HTML Elements */ private nodes: { - toolbox: HTMLElement | null; - } = { - toolbox: null, - }; + toolbox: HTMLElement; + } ; /** * CSS styles @@ -128,36 +136,26 @@ export default class Toolbox extends EventsDispatcher { 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 { * 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 { 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 { } } + /** + * 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 */ diff --git a/src/components/utils/bem.ts b/src/components/utils/bem.ts new file mode 100644 index 00000000..eea146d7 --- /dev/null +++ b/src/components/utils/bem.ts @@ -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); + }; +} diff --git a/src/components/utils/popover/components/popover-header/index.ts b/src/components/utils/popover/components/popover-header/index.ts new file mode 100644 index 00000000..49c0175a --- /dev/null +++ b/src/components/utils/popover/components/popover-header/index.ts @@ -0,0 +1,2 @@ +export * from './popover-header'; +export * from './popover-header.types'; diff --git a/src/components/utils/popover/components/popover-header/popover-header.const.ts b/src/components/utils/popover/components/popover-header/popover-header.const.ts new file mode 100644 index 00000000..f39097ab --- /dev/null +++ b/src/components/utils/popover/components/popover-header/popover-header.const.ts @@ -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'), +}; diff --git a/src/components/utils/popover/components/popover-header/popover-header.ts b/src/components/utils/popover/components/popover-header/popover-header.ts new file mode 100644 index 00000000..edfe4e41 --- /dev/null +++ b/src/components/utils/popover/components/popover-header/popover-header.ts @@ -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(); + } +} diff --git a/src/components/utils/popover/components/popover-header/popover-header.types.ts b/src/components/utils/popover/components/popover-header/popover-header.types.ts new file mode 100644 index 00000000..38697f5a --- /dev/null +++ b/src/components/utils/popover/components/popover-header/popover-header.types.ts @@ -0,0 +1,14 @@ +/** + * Popover header params + */ +export interface PopoverHeaderParams { + /** + * Text to be displayed inside header + */ + text: string; + + /** + * Back button click handler + */ + onBackButtonClick: () => void; +} diff --git a/src/components/utils/popover/components/popover-item/index.ts b/src/components/utils/popover/components/popover-item/index.ts new file mode 100644 index 00000000..09b97e0d --- /dev/null +++ b/src/components/utils/popover/components/popover-item/index.ts @@ -0,0 +1,2 @@ +export * from './popover-item'; +export * from './popover-item.const'; diff --git a/src/components/utils/popover/components/popover-item/popover-item.const.ts b/src/components/utils/popover/components/popover-item/popover-item.const.ts new file mode 100644 index 00000000..515e0428 --- /dev/null +++ b/src/components/utils/popover/components/popover-item/popover-item.const.ts @@ -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')(), +}; diff --git a/src/components/utils/popover/popover-item.ts b/src/components/utils/popover/components/popover-item/popover-item.ts similarity index 66% rename from src/components/utils/popover/popover-item.ts rename to src/components/utils/popover/components/popover-item/popover-item.ts index 9513a2c2..5c72669b 100644 --- a/src/components/utils/popover/popover-item.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.ts @@ -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); }; } diff --git a/src/components/utils/popover/components/search-input/index.ts b/src/components/utils/popover/components/search-input/index.ts new file mode 100644 index 00000000..0b04671f --- /dev/null +++ b/src/components/utils/popover/components/search-input/index.ts @@ -0,0 +1,2 @@ +export * from './search-input'; +export * from './search-input.types'; diff --git a/src/components/utils/popover/components/search-input/search-input.const.ts b/src/components/utils/popover/components/search-input/search-input.const.ts new file mode 100644 index 00000000..531412c6 --- /dev/null +++ b/src/components/utils/popover/components/search-input/search-input.const.ts @@ -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'), +}; diff --git a/src/components/utils/popover/search-input.ts b/src/components/utils/popover/components/search-input/search-input.ts similarity index 72% rename from src/components/utils/popover/search-input.ts rename to src/components/utils/popover/components/search-input/search-input.ts index 6cf381bb..49db1061 100644 --- a/src/components/utils/popover/search-input.ts +++ b/src/components/utils/popover/components/search-input/search-input.ts @@ -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; } } diff --git a/src/components/utils/popover/components/search-input/search-input.types.ts b/src/components/utils/popover/components/search-input/search-input.types.ts new file mode 100644 index 00000000..bbe78f8f --- /dev/null +++ b/src/components/utils/popover/components/search-input/search-input.types.ts @@ -0,0 +1,9 @@ +/** + * Item that could be searched + */ +export interface SearchableItem { + /** + * Items title + */ + title?: string; +} diff --git a/src/components/utils/popover/index.ts b/src/components/utils/popover/index.ts index 34b483b3..6299dee9 100644 --- a/src/components/utils/popover/index.ts +++ b/src/components/utils/popover/index.ts @@ -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 { - /** - * 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 }; diff --git a/src/components/utils/popover/popover-abstract.ts b/src/components/utils/popover/popover-abstract.ts new file mode 100644 index 00000000..c97b08d2 --- /dev/null +++ b/src/components/utils/popover/popover-abstract.ts @@ -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 extends EventsDispatcher { + /** + * 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; +} diff --git a/src/components/utils/popover/popover-desktop.ts b/src/components/utils/popover/popover-desktop.ts new file mode 100644 index 00000000..df337349 --- /dev/null +++ b/src/components/utils/popover/popover-desktop.ts @@ -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); + } +} diff --git a/src/components/utils/popover/popover-mobile.ts b/src/components/utils/popover/popover-mobile.ts new file mode 100644 index 00000000..ac0e7ae1 --- /dev/null +++ b/src/components/utils/popover/popover-mobile.ts @@ -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 { + /** + * 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); + }); + } +} diff --git a/src/components/utils/popover/popover.const.ts b/src/components/utils/popover/popover.const.ts new file mode 100644 index 00000000..4fc693a7 --- /dev/null +++ b/src/components/utils/popover/popover.const.ts @@ -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'), +}; diff --git a/src/components/utils/popover/popover.types.ts b/src/components/utils/popover/popover.types.ts new file mode 100644 index 00000000..515ec436 --- /dev/null +++ b/src/components/utils/popover/popover.types.ts @@ -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; +} diff --git a/src/components/utils/popover/utils/popover-states-history.ts b/src/components/utils/popover/utils/popover-states-history.ts new file mode 100644 index 00000000..92975468 --- /dev/null +++ b/src/components/utils/popover/utils/popover-states-history.ts @@ -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(); + } + } +} diff --git a/src/styles/popover.css b/src/styles/popover.css index 702d9f73..a5982638 100644 --- a/src/styles/popover.css +++ b/src/styles/popover.css @@ -1,5 +1,8 @@ /** * Popover styles + * + * @todo split into separate files popover styles + * @todo make css variables work */ .ce-popover { --border-radius: 6px; @@ -21,38 +24,63 @@ --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; - min-width: var(--width); - width: var(--width); - max-height: var(--max-height); - border-radius: var(--border-radius); - overflow: hidden; - box-sizing: border-box; - box-shadow: 0 3px 15px -3px var(--color-shadow); - position: absolute; - left: 0; - top: calc(100% + var(--offset-from-target)); - background: var(--color-background); - display: flex; - flex-direction: column; - z-index: 4; + --icon-size: 20px; + --item-padding: 3px; + --item-height: calc(var(--icon-size) + 2 * var(--item-padding)); - opacity: 0; - max-height: 0; - pointer-events: none; - padding: 0; - border: none; + &__container { + min-width: var(--width); + width: var(--width); + max-height: var(--max-height); + border-radius: var(--border-radius); + overflow: hidden; + box-sizing: border-box; + box-shadow: 0 3px 15px -3px var(--color-shadow); + position: absolute; + left: var(--popover-left); + top: var(--popover-top); + + background: var(--color-background); + display: flex; + flex-direction: column; + z-index: 4; + + opacity: 0; + max-height: 0; + pointer-events: none; + padding: 0; + border: none; + } &--opened { - opacity: 1; - padding: var(--padding); - max-height: var(--max-height); - pointer-events: auto; - animation: panelShowing 100ms ease; - border: 1px solid var(--color-border); + .ce-popover__container { + opacity: 1; + padding: var(--padding); + max-height: var(--max-height); + pointer-events: auto; + animation: panelShowing 100ms ease; + border: 1px solid var(--color-border); + + @media (--mobile) { + animation: panelShowingMobile 250ms ease; + } + } - @media (--mobile) { - animation: panelShowingMobile 250ms ease; + } + + &--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%); } } @@ -81,21 +109,21 @@ } } - &--open-top { - top: calc(-1 * (var(--offset-from-target) + var(--popover-height))); - } @media (--mobile) { - --offset: 5px; - position: fixed; - max-width: none; - min-width: calc(100% - var(--offset) * 2); - left: var(--offset); - right: var(--offset); - bottom: calc(var(--offset) + env(safe-area-inset-bottom)); - top: auto; - border-radius: 10px; + .ce-popover__container { + --offset: 5px; + + position: fixed; + max-width: none; + min-width: calc(100% - var(--offset) * 2); + left: var(--offset); + right: var(--offset); + 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; + } + } +} diff --git a/test/cypress/tests/modules/BlockEvents/Slash.cy.ts b/test/cypress/tests/modules/BlockEvents/Slash.cy.ts index adf9a207..aca84cd1 100644 --- a/test/cypress/tests/modules/BlockEvents/Slash.cy.ts +++ b/test/cypress/tests/modules/BlockEvents/Slash.cy.ts @@ -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'); }); }); diff --git a/test/cypress/tests/utils/popover.cy.ts b/test/cypress/tests/utils/popover.cy.ts index 3eeeb2dd..1e5f2032 100644 --- a/test/cypress/tests/utils/popover.cy.ts +++ b/test/cypress/tests/utils/popover.cy.ts @@ -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'); + }); }); diff --git a/types/configs/popover.d.ts b/types/configs/popover.d.ts index 6689fce2..ab53e521 100644 --- a/types/configs/popover.d.ts +++ b/types/configs/popover.d.ts @@ -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