diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e0bafdc42..6da828b7a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +### 2.27.0 + +- `Refactoring` — Popover class refactored. +- `Improvement` — *Toolbox* — Number of `close()` method calls optimized. + ### 2.26.5 - `Fix` — *Types* — Remove unnecessary import that creates a dependency on the `cypress`. diff --git a/package.json b/package.json index d34d9fd0b..2ed6dba16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.26.5", + "version": "2.27.0-rc.0", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editor.js", "types": "./types/index.d.ts", diff --git a/src/components/block-tunes/block-tune-move-down.ts b/src/components/block-tunes/block-tune-move-down.ts index d97523de6..5809c0a07 100644 --- a/src/components/block-tunes/block-tune-move-down.ts +++ b/src/components/block-tunes/block-tune-move-down.ts @@ -5,7 +5,6 @@ */ import { API, BlockTune } from '../../../types'; -import Popover from '../utils/popover'; import { IconChevronDown } from '@codexteam/icons'; import { TunesMenuConfig } from '../../../types/tools'; @@ -49,34 +48,21 @@ export default class MoveDownTune implements BlockTune { return { icon: IconChevronDown, title: this.api.i18n.t('Move down'), - onActivate: (item, event): void => this.handleClick(event), + onActivate: (): void => this.handleClick(), name: 'move-down', }; } /** * Handle clicks on 'move down' button - * - * @param event - click event */ - public handleClick(event: MouseEvent): void { + public handleClick(): void { const currentBlockIndex = this.api.blocks.getCurrentBlockIndex(); const nextBlock = this.api.blocks.getBlockByIndex(currentBlockIndex + 1); // If Block is last do nothing if (!nextBlock) { - const button = (event.target as HTMLElement) - .closest('.' + Popover.CSS.item) - .querySelector('.' + Popover.CSS.itemIcon); - - button.classList.add(this.CSS.animation); - - window.setTimeout(() => { - button.classList.remove(this.CSS.animation); - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - }, 500); - - return; + throw new Error('Unable to move Block down since it is already the last'); } const nextBlockElement = nextBlock.holder; diff --git a/src/components/block-tunes/block-tune-move-up.ts b/src/components/block-tunes/block-tune-move-up.ts index 1bdb09166..242cd83f8 100644 --- a/src/components/block-tunes/block-tune-move-up.ts +++ b/src/components/block-tunes/block-tune-move-up.ts @@ -4,7 +4,6 @@ * @copyright 2018 */ import { API, BlockTune } from '../../../types'; -import Popover from '../../components/utils/popover'; import { IconChevronUp } from '@codexteam/icons'; import { TunesMenuConfig } from '../../../types/tools'; @@ -47,34 +46,21 @@ export default class MoveUpTune implements BlockTune { return { icon: IconChevronUp, title: this.api.i18n.t('Move up'), - onActivate: (item, e): void => this.handleClick(e), + onActivate: (): void => this.handleClick(), name: 'move-up', }; } /** * Move current block up - * - * @param {MouseEvent} event - click event */ - public handleClick(event: MouseEvent): void { + public handleClick(): void { const currentBlockIndex = this.api.blocks.getCurrentBlockIndex(); const currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex); const previousBlock = this.api.blocks.getBlockByIndex(currentBlockIndex - 1); if (currentBlockIndex === 0 || !currentBlock || !previousBlock) { - const button = (event.target as HTMLElement) - .closest('.' + Popover.CSS.item) - .querySelector('.' + Popover.CSS.itemIcon); - - button.classList.add(this.CSS.animation); - - window.setTimeout(() => { - button.classList.remove(this.CSS.animation); - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - }, 500); - - return; + throw new Error('Unable to move Block up since it is already the first'); } const currentBlockElement = currentBlock.holder; diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts index a1fe4b502..e7c531146 100644 --- a/src/components/modules/toolbar/blockSettings.ts +++ b/src/components/modules/toolbar/blockSettings.ts @@ -2,12 +2,12 @@ import Module from '../../__module'; import $ from '../../dom'; import SelectionUtils from '../../selection'; import Block from '../../block'; -import Popover, { PopoverEvent } from '../../utils/popover'; import I18n from '../../i18n'; 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'; /** * HTML Elements that used for BlockSettings @@ -67,13 +67,14 @@ export default class BlockSettings extends Module { */ private popover: Popover | undefined; + /** * Panel with block settings with 2 sections: * - Tool's Settings * - Default Settings [Move, Remove, etc] */ public make(): void { - this.nodes.wrapper = $.make('div'); + this.nodes.wrapper = $.make('div', [ this.CSS.settings ]); } /** @@ -110,19 +111,19 @@ export default class BlockSettings extends Module { /** Tell to subscribers that block settings is opened */ this.eventsDispatcher.emit(this.events.opened); - this.popover = new Popover({ - className: this.CSS.settings, searchable: true, - filterLabel: I18n.ui(I18nInternalNS.ui.popover, 'Filter'), - nothingFoundLabel: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'), items: tunesItems.map(tune => this.resolveTuneAliases(tune)), customContent: customHtmlTunesContainer, customContentFlippableItems: this.getControls(customHtmlTunesContainer), scopeElement: this.Editor.API.methods.ui.nodes.redactor, + messages: { + nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'), + search: I18n.ui(I18nInternalNS.ui.popover, 'Filter'), + }, }); - this.popover.on(PopoverEvent.OverlayClicked, this.onOverlayClicked); - this.popover.on(PopoverEvent.Close, () => this.close()); + + this.popover.on(PopoverEvent.Close, this.onPopoverClose); this.nodes.wrapper.append(this.popover.getElement()); @@ -166,13 +167,20 @@ export default class BlockSettings extends Module { this.eventsDispatcher.emit(this.events.closed); if (this.popover) { - this.popover.off(PopoverEvent.OverlayClicked, this.onOverlayClicked); + this.popover.off(PopoverEvent.Close, this.onPopoverClose); this.popover.destroy(); this.popover.getElement().remove(); this.popover = null; } } + /** + * Handles popover close event + */ + private onPopoverClose = (): void => { + this.close(); + }; + /** * Returns list of buttons and inputs inside specified container * @@ -188,13 +196,6 @@ export default class BlockSettings extends Module { return Array.from(controls); } - /** - * Handles overlay click - */ - private onOverlayClicked = (): void => { - this.close(); - }; - /** * Resolves aliases in tunes menu items * diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts index c147df4d8..d1db467c6 100644 --- a/src/components/modules/toolbar/index.ts +++ b/src/components/modules/toolbar/index.ts @@ -228,8 +228,13 @@ export default class Toolbar extends Module { /** * Close Toolbox when we move toolbar */ - this.toolboxInstance.close(); - this.Editor.BlockSettings.close(); + if (this.toolboxInstance.opened) { + this.toolboxInstance.close(); + } + + if (this.Editor.BlockSettings.opened) { + this.Editor.BlockSettings.close(); + } /** * If no one Block selected as a Current @@ -468,7 +473,9 @@ export default class Toolbar extends Module { this.settingsTogglerClicked(); - this.toolboxInstance.close(); + if (this.toolboxInstance.opened) { + this.toolboxInstance.close(); + } this.tooltip.hide(true); }, true); diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index 5f5900ab5..8694a50b3 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -123,14 +123,15 @@ export default class Toolbox extends EventsDispatcher { public make(): Element { this.popover = new Popover({ scopeElement: this.api.ui.nodes.redactor, - className: Toolbox.CSS.toolbox, searchable: true, - filterLabel: this.i18nLabels.filter, - nothingFoundLabel: this.i18nLabels.nothingFound, + messages: { + nothingFound: this.i18nLabels.nothingFound, + search: this.i18nLabels.filter, + }, items: this.toolboxItemsToBeDisplayed, }); - this.popover.on(PopoverEvent.OverlayClicked, this.onOverlayClicked); + this.popover.on(PopoverEvent.Close, this.onPopoverClose); /** * Enable tools shortcuts @@ -138,6 +139,7 @@ export default class Toolbox extends EventsDispatcher { this.enableShortcuts(); this.nodes.toolbox = this.popover.getElement(); + this.nodes.toolbox.classList.add(Toolbox.CSS.toolbox); return this.nodes.toolbox; } @@ -161,7 +163,7 @@ export default class Toolbox extends EventsDispatcher { } this.removeAllShortcuts(); - this.popover?.off(PopoverEvent.OverlayClicked, this.onOverlayClicked); + this.popover?.off(PopoverEvent.Close, this.onPopoverClose); } /** @@ -208,10 +210,11 @@ export default class Toolbox extends EventsDispatcher { } /** - * Handles overlay click + * Handles popover close event */ - private onOverlayClicked = (): void => { - this.close(); + private onPopoverClose = (): void => { + this.opened = false; + this.emit(ToolboxEvent.Closed); }; /** diff --git a/src/components/utils/popover.ts b/src/components/utils/popover.ts deleted file mode 100644 index 245910dff..000000000 --- a/src/components/utils/popover.ts +++ /dev/null @@ -1,724 +0,0 @@ -import Dom from '../dom'; -import Listeners from './listeners'; -import Flipper from '../flipper'; -import SearchInput from './search-input'; -import EventsDispatcher from './events'; -import { isMobileScreen, keyCodes, cacheable } from '../utils'; -import ScrollLocker from './scroll-locker'; -import { PopoverItem, PopoverItemWithConfirmation } from '../../../types'; -import { IconDotCircle } from '@codexteam/icons'; - -/** - * Event that can be triggered by the Popover - */ -export enum PopoverEvent { - /** - * When popover overlay is clicked - */ - OverlayClicked = 'overlay-clicked', - - /** - * When popover closes - */ - Close = 'close' -} - -/** - * Popover is the UI element for displaying vertical lists - */ -export default class Popover extends EventsDispatcher { - /** - * Flipper - module for keyboard iteration between elements - */ - public flipper: Flipper; - - /** - * Items list to be displayed - */ - private readonly items: PopoverItem[]; - - /** - * Arbitrary html element to be inserted before items list - */ - private readonly customContent: HTMLElement; - - /** - * List of html elements inside custom content area that should be available for keyboard navigation - */ - private readonly customContentFlippableItems: HTMLElement[] = []; - - /** - * Stores the visibility state. - */ - private isShown = false; - - /** - * Created nodes - */ - private nodes: { - wrapper: HTMLElement; - popover: HTMLElement; - items: HTMLElement; - nothingFound: HTMLElement; - overlay: HTMLElement; - } = { - wrapper: null, - popover: null, - items: null, - nothingFound: null, - overlay: null, - }; - - /** - * Additional wrapper's class name - */ - private readonly className: string; - - /** - * Listeners util instance - */ - private listeners: Listeners; - - /** - * Pass true to enable local search field - */ - private readonly searchable: boolean; - - /** - * Instance of the Search Input - */ - private search: SearchInput; - - /** - * Label for the 'Filter' placeholder - */ - private readonly filterLabel: string; - - /** - * Label for the 'Nothing found' message - */ - private readonly nothingFoundLabel: string; - - /** - * Style classes - */ - public static get CSS(): { - popover: string; - popoverOpened: string; - itemsWrapper: string; - item: string; - itemHidden: string; - itemFocused: string; - itemActive: string; - itemDisabled: string; - itemLabel: string; - itemIcon: string; - itemSecondaryLabel: string; - itemConfirmation: string; - itemNoHover: string; - itemNoFocus: string; - noFoundMessage: string; - noFoundMessageShown: string; - popoverOverlay: string; - popoverOverlayHidden: string; - customContent: string; - customContentHidden: string; - } { - return { - popover: 'ce-popover', - popoverOpened: 'ce-popover--opened', - itemsWrapper: 'ce-popover__items', - item: 'ce-popover__item', - itemHidden: 'ce-popover__item--hidden', - itemFocused: 'ce-popover__item--focused', - itemActive: 'ce-popover__item--active', - itemDisabled: 'ce-popover__item--disabled', - itemConfirmation: 'ce-popover__item--confirmation', - itemNoHover: 'ce-popover__item--no-visible-hover', - itemNoFocus: 'ce-popover__item--no-visible-focus', - itemLabel: 'ce-popover__item-label', - itemIcon: 'ce-popover__item-icon', - itemSecondaryLabel: 'ce-popover__item-secondary-label', - noFoundMessage: 'ce-popover__no-found', - noFoundMessageShown: 'ce-popover__no-found--shown', - popoverOverlay: 'ce-popover__overlay', - popoverOverlayHidden: 'ce-popover__overlay--hidden', - customContent: 'ce-popover__custom-content', - customContentHidden: 'ce-popover__custom-content--hidden', - }; - } - - /** - * ScrollLocker instance - */ - private scrollLocker = new ScrollLocker(); - - /** - * Editor container element - */ - private scopeElement: HTMLElement; - - /** - * Stores data on popover items that are in confirmation state - */ - private itemsRequiringConfirmation: { [itemIndex: number]: PopoverItem } = {}; - - /** - * Creates the Popover - * - * @param options - config - * @param options.items - config for items to be displayed - * @param options.className - additional class name to be added to the popover wrapper - * @param options.filterLabel - label for the search Field - * @param options.nothingFoundLabel - label of the 'nothing found' message - * @param options.customContent - arbitrary html element to be inserted before items list - * @param options.customContentFlippableItems - list of html elements inside custom content area that should be available for keyboard navigation - * @param options.scopeElement - editor container element - */ - constructor({ items, className, searchable, filterLabel, nothingFoundLabel, customContent, customContentFlippableItems, scopeElement }: { - items: PopoverItem[]; - className?: string; - searchable?: boolean; - filterLabel: string; - nothingFoundLabel: string; - customContent?: HTMLElement; - customContentFlippableItems?: HTMLElement[]; - scopeElement: HTMLElement; - }) { - super(); - this.items = items; - this.customContent = customContent; - this.customContentFlippableItems = customContentFlippableItems; - this.className = className || ''; - this.searchable = searchable; - this.listeners = new Listeners(); - this.scopeElement = scopeElement; - - this.filterLabel = filterLabel; - this.nothingFoundLabel = nothingFoundLabel; - - this.render(); - this.enableFlipper(); - } - - /** - * Returns rendered wrapper - */ - public getElement(): HTMLElement { - return this.nodes.wrapper; - } - - /** - * Shows the Popover - */ - public show(): void { - /** - * Open the popover above the button - * if there is not enough available space below it - */ - if (!this.shouldOpenPopoverBottom) { - this.nodes.wrapper.style.setProperty('--popover-height', this.calculateHeight() + 'px'); - this.nodes.wrapper.classList.add(this.className + '--opened-top'); - } - - /** - * Clear search and items scrolling - */ - if (this.search) { - this.search.clear(); - } - - this.nodes.items.scrollTop = 0; - - this.nodes.popover.classList.add(Popover.CSS.popoverOpened); - this.nodes.overlay.classList.remove(Popover.CSS.popoverOverlayHidden); - this.flipper.activate(this.flippableElements); - - if (this.searchable) { - setTimeout(() => { - this.search.focus(); - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - }, 100); - } - - if (isMobileScreen()) { - this.scrollLocker.lock(); - } - - this.isShown = true; - } - - /** - * Hides the Popover - */ - public hide(): void { - /** - * If it's already hidden, do nothing - * to prevent extra DOM operations - */ - if (!this.isShown) { - return; - } - - this.nodes.popover.classList.remove(Popover.CSS.popoverOpened); - this.nodes.overlay.classList.add(Popover.CSS.popoverOverlayHidden); - this.flipper.deactivate(); - - if (isMobileScreen()) { - this.scrollLocker.unlock(); - } - - this.isShown = false; - this.nodes.wrapper.classList.remove(this.className + '--opened-top'); - - /** - * Remove confirmation state from items - */ - const confirmationStateItems = Array.from(this.nodes.items.querySelectorAll(`.${Popover.CSS.itemConfirmation}`)); - - confirmationStateItems.forEach((itemEl: HTMLElement) => this.cleanUpConfirmationStateForItem(itemEl)); - - this.disableSpecialHoverAndFocusBehavior(); - - this.emit(PopoverEvent.Close); - } - - /** - * Clears memory - */ - public destroy(): void { - this.flipper.deactivate(); - this.listeners.removeAll(); - this.disableSpecialHoverAndFocusBehavior(); - - if (isMobileScreen()) { - this.scrollLocker.unlock(); - } - } - - /** - * Returns true if some item is focused - */ - public hasFocus(): boolean { - return this.flipper.hasFocus(); - } - - /** - * Helps to calculate height of popover while it is not displayed on screen. - * Renders invisible clone of popover to get actual height. - */ - @cacheable - private calculateHeight(): number { - let height = 0; - const popoverClone = this.nodes.popover.cloneNode(true) as HTMLElement; - - popoverClone.style.visibility = 'hidden'; - popoverClone.style.position = 'absolute'; - popoverClone.style.top = '-1000px'; - popoverClone.classList.add(Popover.CSS.popoverOpened); - document.body.appendChild(popoverClone); - height = popoverClone.offsetHeight; - popoverClone.remove(); - - return height; - } - - /** - * Makes the UI - */ - private render(): void { - this.nodes.wrapper = Dom.make('div', this.className); - this.nodes.popover = Dom.make('div', Popover.CSS.popover); - this.nodes.wrapper.appendChild(this.nodes.popover); - - this.nodes.overlay = Dom.make('div', [Popover.CSS.popoverOverlay, Popover.CSS.popoverOverlayHidden]); - this.nodes.wrapper.appendChild(this.nodes.overlay); - - if (this.searchable) { - this.addSearch(this.nodes.popover); - } - - if (this.customContent) { - this.customContent.classList.add(Popover.CSS.customContent); - this.nodes.popover.appendChild(this.customContent); - } - - this.nodes.items = Dom.make('div', Popover.CSS.itemsWrapper); - this.items.forEach(item => { - this.nodes.items.appendChild(this.createItem(item)); - }); - - this.nodes.popover.appendChild(this.nodes.items); - this.nodes.nothingFound = Dom.make('div', [ Popover.CSS.noFoundMessage ], { - textContent: this.nothingFoundLabel, - }); - - this.nodes.popover.appendChild(this.nodes.nothingFound); - - this.listeners.on(this.nodes.popover, 'click', (event: PointerEvent) => { - const clickedItem = (event.target as HTMLElement).closest(`.${Popover.CSS.item}`) as HTMLElement; - - if (clickedItem) { - this.itemClicked(clickedItem, event as PointerEvent); - } - }); - - this.listeners.on(this.nodes.overlay, 'click', () => { - this.emit(PopoverEvent.OverlayClicked); - }); - } - - /** - * Adds the s4arch field to passed element - * - * @param holder - where to append search input - */ - private addSearch(holder: HTMLElement): void { - this.search = new SearchInput({ - items: this.items, - placeholder: this.filterLabel, - onSearch: (filteredItems): void => { - const searchResultElements = []; - - this.items.forEach((item, index) => { - const itemElement = this.nodes.items.children[index]; - - if (filteredItems.includes(item)) { - searchResultElements.push(itemElement); - itemElement.classList.remove(Popover.CSS.itemHidden); - } else { - itemElement.classList.add(Popover.CSS.itemHidden); - } - }); - - this.nodes.nothingFound.classList.toggle(Popover.CSS.noFoundMessageShown, searchResultElements.length === 0); - - /** - * In order to make keyboard navigation work correctly, flipper should be reactivated with only visible items. - * As custom html content is not displayed while search, it should be excluded from keyboard navigation. - */ - const allItemsDisplayed = filteredItems.length === this.items.length; - - /** - * Contains list of elements available for keyboard navigation considering search query applied - */ - const flippableElements = allItemsDisplayed ? this.flippableElements : searchResultElements; - - if (this.customContent) { - this.customContent.classList.toggle(Popover.CSS.customContentHidden, !allItemsDisplayed); - } - - if (this.flipper.isActivated) { - /** - * Update flipper items with only visible - */ - this.reactivateFlipper(flippableElements); - this.flipper.focusFirst(); - } - }, - }); - - const searchField = this.search.getElement(); - - holder.appendChild(searchField); - } - - /** - * Renders the single item - * - * @param item - item data to be rendered - */ - private createItem(item: PopoverItem): HTMLElement { - const el = Dom.make('div', Popover.CSS.item); - - if (item.name) { - el.dataset.itemName = item.name; - } - const label = Dom.make('div', Popover.CSS.itemLabel, { - innerHTML: item.title || '', - }); - - el.appendChild(Dom.make('div', Popover.CSS.itemIcon, { - innerHTML: item.icon || IconDotCircle, - })); - - el.appendChild(label); - - if (item.secondaryLabel) { - el.appendChild(Dom.make('div', Popover.CSS.itemSecondaryLabel, { - textContent: item.secondaryLabel, - })); - } - - if (item.isActive) { - el.classList.add(Popover.CSS.itemActive); - } - - if (item.isDisabled) { - el.classList.add(Popover.CSS.itemDisabled); - } - - return el; - } - - /** - * Item click handler - * - * @param itemEl - clicked item - * @param event - click event - */ - private itemClicked(itemEl: HTMLElement, event: PointerEvent): void { - const allItems = Array.from(this.nodes.items.children); - const itemIndex = allItems.indexOf(itemEl); - const clickedItem = this.items[itemIndex]; - - if (clickedItem.isDisabled) { - return; - } - - /** - * If there is any other item in confirmation state except the clicked one, clean it up - */ - allItems - .filter(item => item !== itemEl) - .forEach(item => { - this.cleanUpConfirmationStateForItem(item); - }); - - if (clickedItem.confirmation) { - this.enableConfirmationStateForItem(clickedItem as PopoverItemWithConfirmation, itemEl, itemIndex); - - return; - } - clickedItem.onActivate(clickedItem, event); - - this.toggleIfNeeded(itemIndex, allItems); - - if (clickedItem.closeOnActivate) { - this.hide(); - } - } - - /** - * - Toggles item active state, if the 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 unactive, and the item gets active) - * - * @param index - clicked item index - * @param itemEls - array of html elements representing popover items - */ - private toggleIfNeeded(index: number, itemEls: Element[]): void { - const clickedItem = this.items[index]; - - if (clickedItem.toggle === true) { - clickedItem.isActive = !clickedItem.isActive; - itemEls[index].classList.toggle(Popover.CSS.itemActive); - - return; - } - - 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.isActive = !clickedItem.isActive; - itemEls[index].classList.toggle(Popover.CSS.itemActive); - - return; - } - - /** Set clicked item as active and the rest items with same toggle key value as inactive */ - itemsInToggleGroup.forEach((item: PopoverItem) => { - const i = this.items.indexOf(item); - const newState = item === clickedItem; - - item.isActive = newState; - itemEls[i].classList.toggle(Popover.CSS.itemActive, newState); - }); - } - } - - /** - * Enables confirmation state for specified item. - * Replaces item element in popover so that is becomes highlighted in a special way - * - * @param item - item to enable confirmation state for - * @param itemEl - html element corresponding to the item - * @param itemIndex - index of the item in all items list - */ - private enableConfirmationStateForItem(item: PopoverItemWithConfirmation, itemEl: HTMLElement, itemIndex: number): void { - /** Save root item requiring confirmation to restore original state on popover hide */ - if (this.itemsRequiringConfirmation[itemIndex] === undefined) { - this.itemsRequiringConfirmation[itemIndex] = item; - } - const newItemData = { - ...item, - ...item.confirmation, - confirmation: item.confirmation.confirmation, - } as PopoverItem; - - this.items[itemIndex] = newItemData; - - const confirmationStateItemEl = this.createItem(newItemData as PopoverItem); - - confirmationStateItemEl.classList.add(Popover.CSS.itemConfirmation, ...Array.from(itemEl.classList)); - itemEl.parentElement.replaceChild(confirmationStateItemEl, itemEl); - - this.enableSpecialHoverAndFocusBehavior(confirmationStateItemEl); - - this.reactivateFlipper( - this.flippableElements, - this.flippableElements.indexOf(confirmationStateItemEl) - ); - } - - /** - * Brings specified element corresponding to popover item to its original state - * - * @param itemEl - item in confirmation state - */ - private cleanUpConfirmationStateForItem(itemEl: Element): void { - const allItems = Array.from(this.nodes.items.children); - const index = allItems.indexOf(itemEl); - - const originalItem = this.itemsRequiringConfirmation[index]; - - if (originalItem === undefined) { - return; - } - const originalStateItemEl = this.createItem(originalItem); - - itemEl.parentElement.replaceChild(originalStateItemEl, itemEl); - this.items[index] = originalItem; - - delete this.itemsRequiringConfirmation[index]; - - itemEl.removeEventListener('mouseleave', this.removeSpecialHoverBehavior); - this.disableSpecialHoverAndFocusBehavior(); - - this.reactivateFlipper( - this.flippableElements, - this.flippableElements.indexOf(originalStateItemEl) - ); - } - - /** - * Enables special focus and hover behavior for item in confirmation state. - * This is needed to prevent item from being highlighted as hovered/focused just after click. - * - * @param item - html element of the item to enable special behavior for - */ - private enableSpecialHoverAndFocusBehavior(item: HTMLElement): void { - item.classList.add(Popover.CSS.itemNoHover); - item.classList.add(Popover.CSS.itemNoFocus); - - item.addEventListener('mouseleave', this.removeSpecialHoverBehavior, { once: true }); - this.flipper.onFlip(this.onFlip); - } - - /** - * Disables special focus and hover behavior. - */ - private disableSpecialHoverAndFocusBehavior(): void { - this.removeSpecialFocusBehavior(); - this.removeSpecialHoverBehavior(); - - this.flipper.removeOnFlip(this.onFlip); - } - - /** - * Removes class responsible for special hover behavior on an item - */ - private removeSpecialHoverBehavior = (): void => { - const el = this.nodes.items.querySelector(`.${Popover.CSS.itemNoHover}`); - - if (!el) { - return; - } - - el.classList.remove(Popover.CSS.itemNoHover); - }; - - /** - * Removes class responsible for special focus behavior on an item - */ - private removeSpecialFocusBehavior(): void { - const el = this.nodes.items.querySelector(`.${Popover.CSS.itemNoFocus}`); - - if (!el) { - return; - } - - el.classList.remove(Popover.CSS.itemNoFocus); - } - - /** - * Called on flipper navigation - */ - private onFlip = (): void => { - this.disableSpecialHoverAndFocusBehavior(); - }; - - /** - * Reactivates flipper instance. - * Should be used if popover items html elements get replaced to preserve workability of keyboard navigation - * - * @param items - html elements to navigate through - * @param focusedIndex - index of element to be focused - */ - private reactivateFlipper(items: HTMLElement[], focusedIndex?: number): void { - this.flipper.deactivate(); - this.flipper.activate(items, focusedIndex); - } - - /** - * Creates Flipper instance to be able to leaf tools - */ - private enableFlipper(): void { - this.flipper = new Flipper({ - items: this.flippableElements, - focusedItemClass: Popover.CSS.itemFocused, - allowedKeys: [ - keyCodes.TAB, - keyCodes.UP, - keyCodes.DOWN, - keyCodes.ENTER, - ], - }); - } - - /** - * Returns list of elements available for keyboard navigation. - * Contains both usual popover items elements and custom html content. - */ - private get flippableElements(): HTMLElement[] { - /** - * Select html elements of popover items - */ - const popoverItemsElements = Array.from(this.nodes.wrapper.querySelectorAll(`.${Popover.CSS.item}`)) as HTMLElement[]; - - const customContentControlsElements = this.customContentFlippableItems || []; - - /** - * Combine elements inside custom content area with popover items elements - */ - return customContentControlsElements.concat(popoverItemsElements); - } - - /** - * Checks if popover should be opened bottom. - * It should happen when there is enough space below or not enough space above - */ - private get shouldOpenPopoverBottom(): boolean { - const toolboxRect = this.nodes.wrapper.getBoundingClientRect(); - const scopeElementRect = this.scopeElement.getBoundingClientRect(); - const popoverHeight = this.calculateHeight(); - const popoverPotentialBottomEdge = toolboxRect.top + popoverHeight; - const popoverPotentialTopEdge = toolboxRect.top - popoverHeight; - const bottomEdgeForComparison = Math.min(window.innerHeight, scopeElementRect.bottom); - - return popoverPotentialTopEdge < scopeElementRect.top || popoverPotentialBottomEdge <= bottomEdgeForComparison; - } -} diff --git a/src/components/utils/popover/index.ts b/src/components/utils/popover/index.ts new file mode 100644 index 000000000..5b61227ff --- /dev/null +++ b/src/components/utils/popover/index.ts @@ -0,0 +1,521 @@ +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'; + +/** + * Params required to render popover + */ +interface PopoverParams { + /** + * Popover items config + */ + items: PopoverItemParams[]; + + /** + * 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' +} + + +/** + * 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 correcponding to the popover + */ + public getElement(): HTMLElement | null { + return this.nodes.wrapper; + } + + /** + * 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) { + setTimeout(() => { + this.search.focus(); + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + }, 100); + } + + 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 seach 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 isDislayed - true if the message should be displayed + */ + private toggleNothingFoundMessage(isDislayed: boolean): void { + this.nodes.nothingFoundMessage.classList.toggle(Popover.CSS.nothingFoundMessageDisplayed, isDislayed); + } + + /** + * 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 unactive, 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); + }); + } + } +} diff --git a/src/components/utils/popover/popover-item.ts b/src/components/utils/popover/popover-item.ts new file mode 100644 index 000000000..9513a2c28 --- /dev/null +++ b/src/components/utils/popover/popover-item.ts @@ -0,0 +1,316 @@ +import Dom from '../../dom'; +import { IconDotCircle } from '@codexteam/icons'; +import { PopoverItem as PopoverItemParams } from '../../../../types'; + +/** + * Represents sigle popover item node + */ +export class PopoverItem { + /** + * True if item is disabled and hence not clickable + */ + public get isDisabled(): boolean { + return this.params.isDisabled; + } + + /** + * Exposes popover item toggle parameter + */ + public get toggle(): boolean | string | undefined { + return this.params.toggle; + } + + /** + * Item title + */ + public get title(): string | undefined { + return this.params.title; + } + + /** + * True if popover should close once item is activated + */ + public get closeOnActivate(): boolean | undefined { + return this.params.closeOnActivate; + } + + /** + * True if confirmation state is enabled for popover item + */ + public get isConfirmationStateEnabled(): boolean { + return this.confirmationState !== null; + } + + /** + * True if item is focused in keyboard navigation process + */ + public get isFocused(): boolean { + return this.nodes.root.classList.contains(PopoverItem.CSS.focused); + } + + /** + * Item html elements + */ + private nodes: { + root: null | HTMLElement, + icon: null | HTMLElement + } = { + root: null, + icon: null, + }; + + /** + * Popover item params + */ + private params: PopoverItemParams; + + /** + * If item is in confirmation state, stores confirmation params such as icon, label, onActivate callback and so on + */ + private confirmationState: PopoverItemParams | null = null; + + /** + * 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 + * + * @param params - popover item construction params + */ + constructor(params: PopoverItemParams) { + this.params = params; + this.nodes.root = this.make(params); + } + + /** + * Returns popover item root element + */ + public getElement(): HTMLElement { + return this.nodes.root; + } + + /** + * Called on popover item click + */ + public handleClick(): void { + if (this.isConfirmationStateEnabled) { + this.activateOrEnableConfirmationMode(this.confirmationState); + + return; + } + + this.activateOrEnableConfirmationMode(this.params); + } + + /** + * Toggles item active state + * + * @param isActive - true if item should strictly should become active + */ + public toggleActive(isActive?: boolean): void { + this.nodes.root.classList.toggle(PopoverItem.CSS.active, isActive); + } + + /** + * Toggles item hidden state + * + * @param isHidden - true if item should be hidden + */ + public toggleHidden(isHidden: boolean): void { + this.nodes.root.classList.toggle(PopoverItem.CSS.hidden, isHidden); + } + + /** + * Resets popover item to its original state + */ + public reset(): void { + if (this.isConfirmationStateEnabled) { + this.disableConfirmationMode(); + } + } + + /** + * Method called once item becomes focused during keyboard navigation + */ + public onFocus(): void { + this.disableSpecialHoverAndFocusBehavior(); + } + + /** + * 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); + + if (params.name) { + el.dataset.itemName = params.name; + } + + this.nodes.icon = Dom.make('div', PopoverItem.CSS.icon, { + innerHTML: params.icon || IconDotCircle, + }); + + el.appendChild(this.nodes.icon); + + el.appendChild(Dom.make('div', PopoverItem.CSS.title, { + innerHTML: params.title || '', + })); + + if (params.secondaryLabel) { + el.appendChild(Dom.make('div', PopoverItem.CSS.secondaryTitle, { + textContent: params.secondaryLabel, + })); + } + + if (params.isActive) { + el.classList.add(PopoverItem.CSS.active); + } + + if (params.isDisabled) { + el.classList.add(PopoverItem.CSS.disabled); + } + + return el; + } + + /** + * Activates confirmation mode for the item. + * + * @param newState - new popover item params that should be applied + */ + private enableConfirmationMode(newState: PopoverItemParams): void { + const params = { + ...this.params, + ...newState, + confirmation: newState.confirmation, + } as PopoverItemParams; + const confirmationEl = this.make(params); + + this.nodes.root.innerHTML = confirmationEl.innerHTML; + this.nodes.root.classList.add(PopoverItem.CSS.confirmationState); + + this.confirmationState = newState; + + this.enableSpecialHoverAndFocusBehavior(); + } + + /** + * Returns item to its original state + */ + private disableConfirmationMode(): void { + const itemWithOriginalParams = this.make(this.params); + + this.nodes.root.innerHTML = itemWithOriginalParams.innerHTML; + this.nodes.root.classList.remove(PopoverItem.CSS.confirmationState); + + this.confirmationState = null; + + this.disableSpecialHoverAndFocusBehavior(); + } + + /** + * Enables special focus and hover behavior for item in confirmation state. + * This is needed to prevent item from being highlighted as hovered/focused just after click. + */ + private enableSpecialHoverAndFocusBehavior(): void { + this.nodes.root.classList.add(PopoverItem.CSS.noHover); + this.nodes.root.classList.add(PopoverItem.CSS.noFocus); + + this.nodes.root.addEventListener('mouseleave', this.removeSpecialHoverBehavior, { once: true }); + } + + /** + * Disables special focus and hover behavior + */ + private disableSpecialHoverAndFocusBehavior(): void { + this.removeSpecialFocusBehavior(); + this.removeSpecialHoverBehavior(); + + this.nodes.root.removeEventListener('mouseleave', this.removeSpecialHoverBehavior); + } + + /** + * Removes class responsible for special focus behavior on an item + */ + private removeSpecialFocusBehavior = (): void => { + this.nodes.root.classList.remove(PopoverItem.CSS.noFocus); + }; + + /** + * Removes class responsible for special hover behavior on an item + */ + private removeSpecialHoverBehavior = (): void => { + this.nodes.root.classList.remove(PopoverItem.CSS.noHover); + }; + + /** + * Executes item's onActivate callback if the item has no confirmation configured + * + * @param item - item to activate or bring to confirmation mode + */ + private activateOrEnableConfirmationMode(item: PopoverItemParams): void { + if (item.confirmation === undefined) { + try { + item.onActivate(item); + this.disableConfirmationMode(); + } catch { + this.animateError(); + } + } else { + this.enableConfirmationMode(item.confirmation); + } + } + + /** + * Animates item which symbolizes that error occured while executing 'onActivate()' callback + */ + private animateError(): void { + if (this.nodes.icon.classList.contains(PopoverItem.CSS.wobbleAnimation)) { + return; + } + + this.nodes.icon.classList.add(PopoverItem.CSS.wobbleAnimation); + + 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); + }; +} diff --git a/src/components/utils/search-input.ts b/src/components/utils/popover/search-input.ts similarity index 90% rename from src/components/utils/search-input.ts rename to src/components/utils/popover/search-input.ts index 18d3b5463..231743ee2 100644 --- a/src/components/utils/search-input.ts +++ b/src/components/utils/popover/search-input.ts @@ -1,5 +1,5 @@ -import Dom from '../dom'; -import Listeners from './listeners'; +import Dom from '../../dom'; +import Listeners from '../listeners'; import { IconSearch } from '@codexteam/icons'; /** @@ -41,7 +41,7 @@ export default class SearchInput { /** * Externally passed callback for the search */ - private readonly onSearch: (items: SearchableItem[]) => void; + private readonly onSearch: (query: string, items: SearchableItem[]) => void; /** * Styles @@ -66,7 +66,7 @@ export default class SearchInput { */ constructor({ items, onSearch, placeholder }: { items: SearchableItem[]; - onSearch: (items: SearchableItem[]) => void; + onSearch: (query: string, items: SearchableItem[]) => void; placeholder: string; }) { this.listeners = new Listeners(); @@ -96,7 +96,7 @@ export default class SearchInput { public clear(): void { this.input.value = ''; this.searchQuery = ''; - this.onSearch(this.foundItems); + this.onSearch('', this.foundItems); } /** @@ -128,7 +128,7 @@ export default class SearchInput { this.listeners.on(this.input, 'input', () => { this.searchQuery = this.input.value; - this.onSearch(this.foundItems); + this.onSearch(this.searchQuery, this.foundItems); }); } diff --git a/src/styles/animations.css b/src/styles/animations.css index c81899025..872758f60 100644 --- a/src/styles/animations.css +++ b/src/styles/animations.css @@ -1,41 +1,3 @@ -.wobble { - animation-name: wobble; - animation-duration: 400ms; -} - -/** - * @author Nick Pettit - https://github.com/nickpettit/glide - */ -@keyframes wobble { - from { - transform: translate3d(0, 0, 0); - } - - 15% { - transform: translate3d(-5%, 0, 0) rotate3d(0, 0, 1, -5deg); - } - - 30% { - transform: translate3d(2%, 0, 0) rotate3d(0, 0, 1, 3deg); - } - - 45% { - transform: translate3d(-3%, 0, 0) rotate3d(0, 0, 1, -3deg); - } - - 60% { - transform: translate3d(2%, 0, 0) rotate3d(0, 0, 1, 2deg); - } - - 75% { - transform: translate3d(-1%, 0, 0) rotate3d(0, 0, 1, -1deg); - } - - to { - transform: translate3d(0, 0, 0); - } -} - @keyframes bounceIn { from, 20%, @@ -101,36 +63,3 @@ } } -@keyframes panelShowing { - from { - opacity: 0; - transform: translateY(-8px) scale(0.9); - } - - 70% { - opacity: 1; - transform: translateY(2px); - } - - to { - - transform: translateY(0); - } -} - -@keyframes panelShowingMobile { - from { - opacity: 0; - transform: translateY(14px) scale(0.98); - } - - 70% { - opacity: 1; - transform: translateY(-4px); - } - - to { - - transform: translateY(0); - } -} diff --git a/src/styles/main.css b/src/styles/main.css index e1adc48d4..250b1a83c 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -10,5 +10,6 @@ @import './export.css'; @import './stub.css'; @import './rtl.css'; -@import './popover.css'; @import './input.css'; +@import './popover.css'; + diff --git a/src/styles/popover.css b/src/styles/popover.css index ff7fae919..702d9f735 100644 --- a/src/styles/popover.css +++ b/src/styles/popover.css @@ -1,45 +1,88 @@ +/** + * Popover styles + */ .ce-popover { + --border-radius: 6px; + --width: 200px; + --max-height: 270px; + --padding: 6px; + --offset-from-target: 8px; + --color-border: #e8e8eb; + --color-shadow: rgba(13,20,33,0.13); + --color-background: white; + --color-text-primary: black; + --color-text-secondary: #707684; + --color-border-icon: rgb(201 201 204 / 48%); + --color-border-icon-disabled: #EFF0F1; + --color-text-icon-active: #388AE5; + --color-background-icon-active: rgba(56, 138, 229, 0.1); + --color-background-item-focus: rgba(34, 186, 255, 0.08); + --color-shadow-item-focus: rgba(7, 161, 227, 0.08); + --color-background-item-hover: #eff2f5; + --color-background-item-confirm: #E24A4A; + --color-background-item-confirm-hover: #CE4343; + + 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; - opacity: 0; - will-change: opacity, transform; + left: 0; + top: calc(100% + var(--offset-from-target)); + background: var(--color-background); display: flex; flex-direction: column; - padding: 6px; - min-width: 200px; - width: 200px; - overflow: hidden; - box-sizing: border-box; - flex-shrink: 0; + z-index: 4; + + opacity: 0; max-height: 0; pointer-events: none; - - @apply --overlay-pane; - - z-index: 4; - flex-wrap: nowrap; + padding: 0; + border: none; &--opened { - opacity: 1; - max-height: 270px; - pointer-events: auto; - animation: panelShowing 100ms ease; + 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; - } + &__items { + overflow-y: auto; + overscroll-behavior: contain; } - &::-webkit-scrollbar { - width: 7px; + &__overlay { + @media (--mobile) { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: var(--color-dark); + z-index: 3; + opacity: 0.5; + transition: opacity 0.12s ease-in; + will-change: opacity; + visibility: visible; + } + + &--hidden { + display: none; + } } - &::-webkit-scrollbar-thumb { - box-sizing: border-box; - box-shadow: inset 0 0 2px 2px var(--bg-light); - border: 3px solid transparent; - border-left-width: 0px; - border-top-width: 4px; - border-bottom-width: 4px; + &--open-top { + top: calc(-1 * (var(--offset-from-target) + var(--popover-height))); } @media (--mobile) { @@ -53,168 +96,280 @@ bottom: calc(var(--offset) + env(safe-area-inset-bottom)); top: auto; border-radius: 10px; + + .ce-popover__search { + display: none; + } } - &__items { - overflow-y: auto; - overscroll-behavior: contain; + &__search, &__custom-content:not(:empty) { + margin-bottom: 5px; + } + + &__nothing-found-message { + color: var(--grayText); + display: none; + cursor: default; + padding: 3px; + font-size: 14px; + line-height: 20px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &--displayed { + display: block; + } + } + + &__custom-content:not(:empty) { + padding: 4px; @media (--not-mobile) { - margin-top: 5px; + padding: 0; } } - &__item { - @apply --popover-button; + &__custom-content--hidden { + display: none; + } +} - @media (--can-hover) { - &:hover { - &:not(.ce-popover__item--no-visible-hover) { - background-color: var(--bg-light); - } +/** + * Popover item styles + */ +.ce-popover-item { + --border-radius: 6px; + --icon-size: 20px; + --icon-size-mobile: 28px; - .ce-popover__item-icon { - box-shadow: none; - } - } + border-radius: var(--border-radius); + display: flex; + align-items: center; + padding: 3px; + color: var(--color-text-primary); + user-select: none; + + @media (--mobile) { + padding: 4px; + } + + &:not(:last-of-type) { + margin-bottom: 1px; + } + + &__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); + height: var(--icon-size); } - &--disabled { - @apply --button-disabled; + @media (--mobile){ + width: 36px; + height: 36px; + border-radius: 8px; - .ce-popover__item-icon { - box-shadow: 0 0 0 1px var(--color-line-gray); + svg { + width: var(--icon-size-mobile); + height: var(--icon-size-mobile); } } + } - &--focused { - &:not(.ce-popover__item--no-visible-focus) { - @apply --button-focused; - } + &__title { + font-size: 14px; + line-height: 20px; + font-weight: 500; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + @media (--mobile) { + font-size: 16px; } + } - &--hidden { + &__secondary-title { + color: var(--color-text-secondary); + font-size: 12px; + margin-left: auto; + white-space: nowrap; + letter-spacing: -0.1em; + padding-right: 5px; + margin-bottom: -2px; + opacity: 0.6; + + @media (--mobile){ display: none; } + } + + &--active { + background: var(--color-background-icon-active); + color: var(--color-text-icon-active); + + .ce-popover-item__icon { + box-shadow: none; + } + } + + &--disabled { + color: var(--color-text-secondary); + cursor: default; + pointer-events: none; - &--active { - @apply --button-active; + .ce-popover-item__icon { + box-shadow: 0 0 0 1px var(--color-border-icon-disabled); } + } - &--confirmation { - background: var(--color-confirm); + &--focused { + &:not(.ce-popover-item--no-focus) { + box-shadow: inset 0 0 0px 1px var(--color-shadow-item-focus); + background: var(--color-background-item-focus) !important; + } + } - .ce-popover__item-icon { - color: var(--color-confirm); - } + &--hidden { + display: none; + } - .ce-popover__item-label { - color: white; + @media (--can-hover) { + &:hover { + cursor: pointer; + + &:not(.ce-popover-item--no-hover) { + background-color: var(--color-background-item-hover); } - &:not(.ce-popover__item--no-visible-hover) { - @media (--can-hover) { - &:hover { - background: var(--color-confirm-hover); - } - } + .ce-popover-item__icon { + box-shadow: none; } + } + } - &:not(.ce-popover__item--no-visible-focus) { - &.ce-popover__item--focused { - background: var(--color-confirm-hover) !important; - } - } + &--confirmation { + background: var(--color-background-item-confirm); + .ce-popover-item__icon { + color: var(--color-background-item-confirm); } - &-icon { - @apply --tool-icon; + .ce-popover-item__title { + color: white; } - &-label { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - &::after { - content: ''; - width: 25px; - display: inline-block; + /* confirmation hover */ + &:not(.ce-popover-item--no-hover) { + @media (--can-hover) { + &:hover { + background: var(--color-background-item-confirm-hover); + } } } - &-secondary-label { - color: var(--grayText); - font-size: 12px; - margin-left: auto; - white-space: nowrap; - letter-spacing: -0.1em; - padding-right: 5px; - margin-bottom: -2px; - opacity: 0.6; - - @media (--mobile){ - display: none; + /* confirmation focus */ + &:not(.ce-popover-item--no-focus) { + &.ce-popover-item--focused { + background: var(--color-background-item-confirm-hover) !important; } } - &--confirmation, &--active, &--focused { - .ce-popover__item-icon { - box-shadow: none; - } + } + + &--confirmation, &--active, &--focused { + .ce-popover-item__icon { + box-shadow: none; } } +} - &__no-found { - @apply --popover-button; - color: var(--grayText); - display: none; - cursor: default; +/** + * Animations + */ +@keyframes panelShowing { + from { + opacity: 0; + transform: translateY(-8px) scale(0.9); + } - &--shown { - display: block; - } + 70% { + opacity: 1; + transform: translateY(2px); } - @media (--mobile) { - &__overlay { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; - background: var(--color-dark); - opacity: 0.5; - z-index: 3; - transition: opacity 0.12s ease-in; - will-change: opacity; - visibility: visible; - } + to { - .cdx-search-field { - display: none; - } + transform: translateY(0); } +} - &__overlay--hidden { - z-index: 0; +@keyframes panelShowingMobile { + from { opacity: 0; - visibility: hidden; + transform: translateY(14px) scale(0.98); } - &__custom-content:not(:empty) { - padding: 4px; + 70% { + opacity: 1; + transform: translateY(-4px); + } - @media (--not-mobile) { - margin-top: 5px; - padding: 0; - } + to { + + transform: translateY(0); } +} - &__custom-content--hidden { - display: none; + +.wobble { + animation-name: wobble; + animation-duration: 400ms; +} + +/** + * @author Nick Pettit - https://github.com/nickpettit/glide + */ +@keyframes wobble { + from { + transform: translate3d(0, 0, 0); + } + + 15% { + transform: translate3d(-9%, 0, 0); + } + + 30% { + transform: translate3d(9%, 0, 0); + } + + 45% { + transform: translate3d(-4%, 0, 0); + } + + 60% { + transform: translate3d(4%, 0, 0); + } + + 75% { + transform: translate3d(-1%, 0, 0); + } + + to { + transform: translate3d(0, 0, 0); } } diff --git a/src/styles/settings.css b/src/styles/settings.css index 1906bda35..e15eaac5d 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -1,18 +1,4 @@ .ce-settings { - position: absolute; - z-index: 2; - --gap: 8px; - - @media (--not-mobile){ - position: absolute; - top: calc(var(--toolbox-buttons-size) + var(--gap)); - left: 0; - - &--opened-top { - top: calc(-1 * (var(--gap) + var(--popover-height))); - } - } - &__button { @apply --toolbar-button; @@ -36,3 +22,12 @@ } } } + +.codex-editor--narrow .ce-settings { + @media (--not-mobile){ + .ce-popover { + right: 0; + left: unset; + } + } +} diff --git a/src/styles/toolbox.css b/src/styles/toolbox.css index d8b602100..e0c548308 100644 --- a/src/styles/toolbox.css +++ b/src/styles/toolbox.css @@ -1,24 +1,12 @@ .ce-toolbox { - --gap: 8px; - @media (--not-mobile){ - position: absolute; - top: calc(var(--toolbox-buttons-size) + var(--gap)); - left: 0; - - &--opened-top { - top: calc(-1 * (var(--gap) + var(--popover-height))); - } - } } .codex-editor--narrow .ce-toolbox { @media (--not-mobile){ - left: auto; - right: 0; - .ce-popover { right: 0; + left: unset; } } } diff --git a/src/styles/variables.css b/src/styles/variables.css index 8c600cda3..8e73c5918 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -68,12 +68,6 @@ */ --block-padding-vertical: 0.4em; - /** - * Confirm deletion bg - */ - --color-confirm: #E24A4A; - --color-confirm-hover: #CE4343; - --color-line-gray: #EFF0F1; --overlay-pane: { @@ -146,7 +140,6 @@ animation: bounceIn 0.75s 1; animation-fill-mode: forwards; } - }; /** @@ -197,33 +190,6 @@ } }; - /** - * Element of the Toolbox. Has icon and label - */ - --popover-button: { - display: grid; - grid-template-columns: auto auto 1fr; - grid-template-rows: auto; - justify-content: start; - white-space: nowrap; - padding: 3px; - font-size: 14px; - line-height: 20px; - font-weight: 500; - cursor: pointer; - align-items: center; - border-radius: 6px; - - &:not(:last-of-type){ - margin-bottom: 1px; - } - - @media (--mobile) { - font-size: 16px; - padding: 4px; - } - }; - /** * Tool icon with border */ diff --git a/test/cypress/tests/api/tools.spec.ts b/test/cypress/tests/api/tools.spec.ts index 94a982a63..ce326cbc7 100644 --- a/test/cypress/tests/api/tools.spec.ts +++ b/test/cypress/tests/api/tools.spec.ts @@ -39,11 +39,11 @@ describe('Editor Tools Api', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-popover__item[data-item-name=testTool]') + .get('div.ce-popover-item[data-item-name=testTool]') .should('have.length', 1); cy.get('[data-cy=editorjs]') - .get('div.ce-popover__item[data-item-name=testTool] .ce-popover__item-icon') + .get('div.ce-popover-item[data-item-name=testTool] .ce-popover-item__icon') .should('contain.html', TestTool.toolbox.icon); }); @@ -84,16 +84,16 @@ describe('Editor Tools Api', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-popover__item[data-item-name=testTool]') + .get('div.ce-popover-item[data-item-name=testTool]') .should('have.length', 2); cy.get('[data-cy=editorjs]') - .get('div.ce-popover__item[data-item-name=testTool]') + .get('div.ce-popover-item[data-item-name=testTool]') .first() .should('contain.text', TestTool.toolbox[0].title); cy.get('[data-cy=editorjs]') - .get('div.ce-popover__item[data-item-name=testTool]') + .get('div.ce-popover-item[data-item-name=testTool]') .last() .should('contain.text', TestTool.toolbox[1].title); }); @@ -173,7 +173,7 @@ describe('Editor Tools Api', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-popover__item[data-item-name=testTool]') + .get('div.ce-popover-item[data-item-name=testTool]') .click(); cy.get('[data-cy=editorjs]') diff --git a/test/cypress/tests/api/tunes.spec.ts b/test/cypress/tests/api/tunes.spec.ts index 542aff89a..9cf760fe9 100644 --- a/test/cypress/tests/api/tunes.spec.ts +++ b/test/cypress/tests/api/tunes.spec.ts @@ -228,13 +228,13 @@ describe('Editor Tunes Api', () => { /** Check test tune is inserted at index 0 */ cy.get('[data-cy=editorjs]') - .get('.ce-settings .ce-popover__item') + .get('.ce-settings .ce-popover-item') .eq(0) .should('have.attr', 'data-item-name', 'test-tune' ); /** Check default Move Up tune is inserted below the test tune */ cy.get('[data-cy=editorjs]') - .get('.ce-settings .ce-popover__item') + .get('.ce-settings .ce-popover-item') .eq(1) .should('have.attr', 'data-item-name', 'move-up' ); }); diff --git a/test/cypress/tests/block-ids.spec.ts b/test/cypress/tests/block-ids.spec.ts index b570aa44f..7b6f5749d 100644 --- a/test/cypress/tests/block-ids.spec.ts +++ b/test/cypress/tests/block-ids.spec.ts @@ -33,7 +33,7 @@ describe('Block ids', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-popover__item[data-item-name=header]') + .get('div.ce-popover-item[data-item-name=header]') .click(); cy.get('[data-cy=editorjs]') diff --git a/test/cypress/tests/i18n.spec.ts b/test/cypress/tests/i18n.spec.ts index 5109521a7..621bd25ea 100644 --- a/test/cypress/tests/i18n.spec.ts +++ b/test/cypress/tests/i18n.spec.ts @@ -31,7 +31,7 @@ describe('Editor i18n', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-popover__item[data-item-name=header]') + .get('div.ce-popover-item[data-item-name=header]') .should('contain.text', toolNamesDictionary.Heading); }); @@ -85,12 +85,12 @@ describe('Editor i18n', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-popover__item[data-item-name=testTool]') + .get('div.ce-popover-item[data-item-name=testTool]') .first() .should('contain.text', toolNamesDictionary.Title1); cy.get('[data-cy=editorjs]') - .get('div.ce-popover__item[data-item-name=testTool]') + .get('div.ce-popover-item[data-item-name=testTool]') .last() .should('contain.text', toolNamesDictionary.Title2); }); @@ -137,7 +137,7 @@ describe('Editor i18n', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-popover__item[data-item-name=testTool]') + .get('div.ce-popover-item[data-item-name=testTool]') .should('contain.text', toolNamesDictionary.TestTool); }); }); diff --git a/test/cypress/tests/onchange.spec.ts b/test/cypress/tests/onchange.spec.ts index 4e275d85f..77d46ab00 100644 --- a/test/cypress/tests/onchange.spec.ts +++ b/test/cypress/tests/onchange.spec.ts @@ -131,7 +131,7 @@ describe('onChange callback', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-popover__item[data-item-name=delimiter]') + .get('div.ce-popover-item[data-item-name=delimiter]') .click(); cy.get('@onChange').should('be.calledThrice'); @@ -178,7 +178,7 @@ describe('onChange callback', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-popover__item[data-item-name=header]') + .get('div.ce-popover-item[data-item-name=header]') .click(); cy.get('@onChange').should('be.calledTwice'); diff --git a/test/cypress/tests/utils/flipper.spec.ts b/test/cypress/tests/utils/flipper.spec.ts index 3e6eae7a3..5a682610a 100644 --- a/test/cypress/tests/utils/flipper.spec.ts +++ b/test/cypress/tests/utils/flipper.spec.ts @@ -85,7 +85,7 @@ describe('Flipper', () => { * Check whether we focus the Delete Tune or not */ cy.get('[data-item-name="delete"]') - .should('have.class', 'ce-popover__item--focused'); + .should('have.class', 'ce-popover-item--focused'); cy.get('[data-cy=editorjs]') .get('.cdx-some-plugin') diff --git a/test/cypress/tests/utils/popover.spec.ts b/test/cypress/tests/utils/popover.spec.ts index 2df503645..3eeeb2ddb 100644 --- a/test/cypress/tests/utils/popover.spec.ts +++ b/test/cypress/tests/utils/popover.spec.ts @@ -31,20 +31,17 @@ describe('Popover', () => { const popover = new Popover({ items, - filterLabel: '', - nothingFoundLabel: '', - scopeElement: null, }); cy.document().then(doc => { doc.body.append(popover.getElement()); cy.get('[data-item-name=testItem]') - .get('.ce-popover__item-icon') + .get('.ce-popover-item__icon') .should('have.text', actionIcon); cy.get('[data-item-name=testItem]') - .get('.ce-popover__item-label') + .get('.ce-popover-item__title') .should('have.text', actionTitle); // First click on item @@ -52,12 +49,12 @@ describe('Popover', () => { // Check icon has changed cy.get('[data-item-name=testItem]') - .get('.ce-popover__item-icon') + .get('.ce-popover-item__icon') .should('have.text', confirmActionIcon); // Check label has changed cy.get('[data-item-name=testItem]') - .get('.ce-popover__item-label') + .get('.ce-popover-item__title') .should('have.text', confirmActionTitle); // Second click @@ -83,9 +80,6 @@ describe('Popover', () => { const popover = new Popover({ items, - filterLabel: '', - nothingFoundLabel: '', - scopeElement: null, }); cy.document().then(doc => { @@ -93,7 +87,7 @@ describe('Popover', () => { /* Check item has active class */ cy.get('[data-item-name=testItem]') - .should('have.class', 'ce-popover__item--active'); + .should('have.class', 'ce-popover-item--active'); }); }); @@ -110,9 +104,6 @@ describe('Popover', () => { const popover = new Popover({ items, - filterLabel: '', - nothingFoundLabel: '', - scopeElement: null, }); cy.document().then(doc => { @@ -120,7 +111,7 @@ describe('Popover', () => { /* Check item has disabled class */ cy.get('[data-item-name=testItem]') - .should('have.class', 'ce-popover__item--disabled') + .should('have.class', 'ce-popover-item--disabled') .click() .then(() => { // Check onActivate callback has never been called @@ -141,9 +132,6 @@ describe('Popover', () => { ]; const popover = new Popover({ items, - filterLabel: '', - nothingFoundLabel: '', - scopeElement: null, }); cy.spy(popover, 'hide'); @@ -171,9 +159,6 @@ describe('Popover', () => { ]; const popover = new Popover({ items, - filterLabel: '', - nothingFoundLabel: '', - scopeElement: null, }); cy.document().then(doc => { @@ -182,7 +167,7 @@ describe('Popover', () => { /* Check item has active class */ cy.get('[data-item-name=testItem]') .click() - .should('have.class', 'ce-popover__item--active'); + .should('have.class', 'ce-popover-item--active'); }); }); @@ -207,9 +192,6 @@ describe('Popover', () => { const popover = new Popover({ items, - filterLabel: '', - nothingFoundLabel: '', - scopeElement: null, }); cy.document().then(doc => { @@ -217,20 +199,20 @@ describe('Popover', () => { /** Check first item is active */ cy.get('[data-item-name=testItem1]') - .should('have.class', 'ce-popover__item--active'); + .should('have.class', 'ce-popover-item--active'); /** Check second item is not active */ cy.get('[data-item-name=testItem2]') - .should('not.have.class', 'ce-popover__item--active'); + .should('not.have.class', 'ce-popover-item--active'); /* Click second item and check it became active */ cy.get('[data-item-name=testItem2]') .click() - .should('have.class', 'ce-popover__item--active'); + .should('have.class', 'ce-popover-item--active'); /** Check first item became not active */ cy.get('[data-item-name=testItem1]') - .should('not.have.class', 'ce-popover__item--active'); + .should('not.have.class', 'ce-popover-item--active'); }); }); @@ -246,9 +228,6 @@ describe('Popover', () => { ]; const popover = new Popover({ items, - filterLabel: '', - nothingFoundLabel: '', - scopeElement: null, }); cy.document().then(doc => { @@ -257,7 +236,25 @@ describe('Popover', () => { /* Check item has active class */ cy.get('[data-item-name=testItem]') .click() - .should('have.class', 'ce-popover__item--active'); + .should('have.class', 'ce-popover-item--active'); + }); + }); + + it('should render custom html content', () => { + const customHtml = document.createElement('div'); + + customHtml.setAttribute('data-cy-name', 'customContent'); + customHtml.innerText = 'custom html content'; + const popover = new Popover({ + customContent: customHtml, + items: [], + }); + + cy.document().then(doc => { + doc.body.append(popover.getElement()); + + /* Check custom content exists in the popover */ + cy.get('[data-cy-name=customContent]'); }); }); });