Compare commits

...

19 commits

Author SHA1 Message Date
Pascal Jufer 2d0fdf90ef
Merge branch 'next' into wrap-content 2024-05-18 00:08:02 +02:00
Tatiana Fomina d18eeb5dc8
feat(popover): Add hint support (#2711)
* Add custom item

* Remove customcontent parameter from popover

* Tests

* Cleanup

* Cleanup

* Lint

* Cleanup

* Rename custom to html, add enum with item types

* Fix tests

* Support hint

* Rename hint content to hint

* Align hint left

* Move types and exports

* Update changelog

* Cleanup

* Add todos

* Change the way hint is disabled for mobile

* Get rid of buildItems override

* Update comment
2024-05-16 15:26:25 +03:00
Tatiana Fomina 50f43bb35d
Change cypress preprocessor (#2712) 2024-05-04 21:23:36 +03:00
Tatiana Fomina f78972ee09
feat(popover): custom content becomes a popover item (#2707)
* Add custom item

* Remove customcontent parameter from popover

* Tests

* Cleanup

* Cleanup

* Lint

* Cleanup

* Rename custom to html, add enum with item types

* Fix tests

* Add order test

* Update jsdoc

* Update changelog

* Fix issue with html item not hiding on search

* Fix flipper issue

* Update changelog
2024-05-04 15:35:36 +00:00
github-actions[bot] bd1de56ef3
Bump version (#2705)
Co-authored-by: github-actions <action@github.com>
2024-05-01 21:00:22 +03:00
Peter Savchenko 8276daa5ca
fix changelog (#2704) 2024-05-01 20:59:33 +03:00
github-actions[bot] 238c909016
Bump version (#2701)
Co-authored-by: github-actions <action@github.com>
2024-04-29 22:28:45 +03:00
Peter Savchenko 23858e0025
fix(conversion): restore caret after conversion though the Inline Toolbar and API (#2699)
* fix caret loosing after caret

* Refactor convert method to return Promise in Blocks API

* changelog upd

* Fix missing semicolon in blocks.cy.ts and BlockTunes.cy.ts

* add test for inline toolbar conversion

* Fix missing semicolon in InlineToolbar.cy.ts

* add test for toolbox shortcut

* api caret.setToBlock now can accept block api or index or id

* eslint fix

* Refactor test descriptions in caret.cy.ts

* rm tsconfig change

* lint

* lint

* Update CHANGELOG.md
2024-04-29 22:24:31 +03:00
github-actions[bot] 5eafda5ec4
Bump version (#2698)
Co-authored-by: github-actions <action@github.com>
2024-04-27 21:22:12 +03:00
Peter Savchenko efa0a34f8e
fix caret loosing after caret (#2697) 2024-04-27 21:19:12 +03:00
Peter Savchenko c48fca1be3
fix ios shift (#2696) 2024-04-27 21:09:16 +03:00
Peter Savchenko 1028577521
fix(scroll): acidental scroll to top on iOS devices (#2695)
* fix scroll on ios typing

* Update tsconfig.json

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update package.json

* Fix popover hide method to use isHidden flag
2024-04-27 21:04:26 +03:00
github-actions[bot] 844272656e
Bump version (#2694)
Co-authored-by: github-actions <action@github.com>
2024-04-27 16:59:52 +03:00
Tatiana Fomina 7821e35302
feat(block tunes): Conversion Menu in Block Tunes (#2692)
* Support delimiter

* Rename types, move types to popover-item folder

* Fix ts errors

* Add tests

* Review fixes

* Review fixes 2

* Fix delimiter while search

* Fix flipper issue

* Fix block tunes types

* Fix types

* tmp

* Fixes

* Make search input emit event

* Fix types

* Rename delimiter to separator

* Update chengelog

* Add convert to to block tunes

* i18n

* Lint

* Fix tests

* Fix tests 2

* Tests

* Add caching

* Rename

* Fix for miltiple toolbox entries

* Update changelog

* Update changelog

* Fix popover test

* Fix flipper tests

* Fix popover tests

* Remove type: 'default'

* Create isSameBlockData util

* Add testcase
2024-04-27 16:57:52 +03:00
github-actions[bot] 4118dc3aea
Bump version (#2693)
Co-authored-by: github-actions <action@github.com>
2024-04-23 22:52:31 +03:00
Tatiana Fomina e1c70b4fb8
feat(popover): separator (#2690)
* Support delimiter

* Rename types, move types to popover-item folder

* Fix ts errors

* Add tests

* Review fixes

* Review fixes 2

* Fix delimiter while search

* Fix flipper issue

* Fix block tunes types

* Fix types

* Fixes

* Make search input emit event

* Fix types

* Rename delimiter to separator

* Update chengelog
2024-04-22 22:38:20 +03:00
github-actions[bot] 54c4c234a5
Bump version (#2659)
Co-authored-by: github-actions <action@github.com>
2024-04-13 23:07:20 +03:00
Tatiana Fomina 5125f015dc
feat: nested popover (#2649)
* Move popover types to separate file

* tmp

* open top

* Fix bug with keyboard navigation

* Fix bug with scroll

* Fix mobile

* Add popover header class

* Display nested items on mobile

* Refactor history

* Fix positioning on desktop

* Fix tests

* Fix child popover indent left

* Fix ts errors in popover files

* Move files

* Rename cn to bem

* Clarify comments and rename method

* Refactor popover css classes

* Rename cls to css

* Split popover desktop and mobile classes

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

* Add nested popover test

* Add popover test for mobile screens

* Fix tests

* Add union type for both popovers

* Add global window resize event

* Multiple fixes

* Move nodes initialization to constructor

* Rename handleShowingNestedItems to showNestedItems

* Replace WindowResize with EditorMobileLayoutToggled

* New doze of fixes

* Review fixes

* Fixes

* Fixes

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

* Update changelog

* Update changelog

* Update changelog
2024-04-13 17:34:26 +00:00
Peter Savchenko ecdd73347c
fix(dx): dev example page fixed (#2682)
* fix dev example

* embed goes to master
2024-04-11 17:00:48 +03:00
86 changed files with 3688 additions and 1038 deletions

View file

@ -12,6 +12,8 @@ export default defineConfig({
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
on('file:preprocessor', require('cypress-vite')(config));
/**
* Plugin for cypress that adds better terminal output for easier debugging.
* Prints cy commands, browser console logs, cy.request and cy.intercept data. Great for your pipelines.

View file

@ -2,11 +2,22 @@
### 2.30.0
- `New` Block Tunes now supports nesting items
- `New` Block Tunes now supports separator items
- `New` "Convert to" control is now also available in Block Tunes
- `Improvement` — The ability to merge blocks of different types (if both tools provide the conversionConfig)
- `Fix``onChange` will be called when removing the entire text within a descendant element of a block.
- `Fix` - Unexpected new line on Enter press with selected block without caret
- `Fix` - Search input autofocus loosing after Block Tunes opening
- `Fix` - Block removing while Enter press on Block Tunes
- `Fix` Unwanted scroll on first typing on iOS devices
- `Fix` - Unwanted soft line break on Enter press after period and space (". |") on iOS devices
- `Fix` - Caret lost after block conversion on mobile devices.
- `Improvement` - The API `blocks.convert()` now returns the new block API
- `Improvement` - The API `caret.setToBlock()` now can accept either BlockAPI or block index or block id
- `New` *Menu Config* New item type HTML
`Refactoring` Switched to Vite as Cypress bundler
`New` *Menu Config* Default and HTML items now support hints
### 2.29.1

View file

@ -89,22 +89,22 @@
Read more in Tool's README file. For example:
https://github.com/editor-js/header#installation
-->
<script src="./tools/header/dist/bundle.js" onload="document.getElementById('hint-tools').hidden = true"></script><!-- Header -->
<script src="./tools/simple-image/dist/bundle.js"></script><!-- Image -->
<script src="./tools/delimiter/dist/bundle.js"></script><!-- Delimiter -->
<!-- <script src="./tools/list/dist/bundle.js"></script> List-->
<script src="./tools/nested-list/dist/nested-list.js"></script><!-- Nested List -->
<script src="./tools/checklist/dist/bundle.js"></script><!-- Checklist -->
<script src="./tools/quote/dist/bundle.js"></script><!-- Quote -->
<script src="./tools/code/dist/bundle.js"></script><!-- Code -->
<script src="./tools/embed/dist/bundle.js"></script><!-- Embed -->
<script src="./tools/table/dist/table.js"></script><!-- Table -->
<script src="./tools/link/dist/bundle.js"></script><!-- Link -->
<script src="./tools/raw/dist/bundle.js"></script><!-- Raw -->
<script src="./tools/warning/dist/bundle.js"></script><!-- Warning -->
<script src="./tools/header/dist/header.umd.js" onload="document.getElementById('hint-tools').hidden = true"></script><!-- Header -->
<script src="./tools/simple-image/dist/simple-image.umd.js"></script><!-- Image -->
<script src="./tools/delimiter/dist/delimiter.umd.js"></script><!-- Delimiter -->
<!-- <script src="./tools/list/dist/list.umd.js"></script> List-->
<script src="./tools/nested-list/dist/nested-list.umd.js"></script><!-- Nested List -->
<script src="./tools/checklist/dist/checklist.umd.js"></script><!-- Checklist -->
<script src="./tools/quote/dist/quote.umd.js"></script><!-- Quote -->
<script src="./tools/code/dist/code.umd.js"></script><!-- Code -->
<script src="./tools/embed/dist/embed.umd.js"></script><!-- Embed -->
<script src="./tools/table/dist/table.umd.js"></script><!-- Table -->
<script src="./tools/link/dist/link.umd.js"></script><!-- Link -->
<script src="./tools/raw/dist/raw.umd.js"></script><!-- Raw -->
<script src="./tools/warning/dist/warning.umd.js"></script><!-- Warning -->
<script src="./tools/marker/dist/bundle.js"></script><!-- Marker -->
<script src="./tools/inline-code/dist/bundle.js"></script><!-- Inline Code -->
<script src="./tools/marker/dist/marker.umd.js"></script><!-- Marker -->
<script src="./tools/inline-code/dist/inline-code.umd.js"></script><!-- Inline Code -->
<!-- Load Editor.js's Core -->
<script src="../dist/editorjs.umd.js" onload="document.getElementById('hint-core').hidden = true;"></script>

@ -1 +1 @@
Subproject commit b1367277e070bbbf80b7b14b1963845ba9a71d8c
Subproject commit 1c116d5e09e19951948d6166047aa2f30877aaf9

@ -1 +1 @@
Subproject commit 193f5f6f00288679a97bfe620a4d811e5acd9b16
Subproject commit f281996f82c7ac676172757e45687cae27443427

@ -1 +1 @@
Subproject commit 86e8c5501dcbb8eaaeec756e1145db49b8339160
Subproject commit 4ca1c1c972261f47dd34f6b8754763a4a79a4866

@ -1 +1 @@
Subproject commit 23de06be69bb9e636a2278b0d54f8c2d85d7ae13
Subproject commit dfdbf2423d2777f7026a7df768c6582e1a409db7

@ -1 +1 @@
Subproject commit 80278ee75146ff461e9dcaeff1a337167ef97162
Subproject commit 5118ce87a752515fb6b31325f234f4ccd62f42c9

@ -1 +1 @@
Subproject commit 927ec04edae75fb2e9a83add24be38d439dc3a19
Subproject commit 25d46cd8d3930851b14ddc26ee80fb5b485e1496

@ -1 +1 @@
Subproject commit 7cc94718e4c20d6f9db2c236a60b119c39d389e0
Subproject commit dcd4c17740c9ba636140751596aff1e9f6ef6b01

@ -1 +1 @@
Subproject commit 861de29b1d553bb9377dcbaf451af605b28b57bd
Subproject commit aaa69d5408bad34027d6252a3892d40f9fa121be

@ -1 +1 @@
Subproject commit f0e9f0110983cd973a1345f2885b18db4fd54636
Subproject commit a6dc6a692b88c9eff3d87223b239e7517b160c67

@ -1 +1 @@
Subproject commit 13e0b1cf72cfa706dc236e617683a5e349a021f5
Subproject commit 8d6897fca43e387bcdf4a681380be975fe8f2a07

@ -1 +1 @@
Subproject commit c5c47395516cae0e456881a67a84fd69fec06c47
Subproject commit 95b37462dc93c19b83f0481f509034a40d436cf2

@ -1 +1 @@
Subproject commit 02e0db32a101ec5cfa61210de45be7de647c40c6
Subproject commit 9377ca713f552576b8b11f77cf371b67261ec00b

@ -1 +1 @@
Subproject commit b4164eac4d81259a15368d7681884e3554554662
Subproject commit cae470fded570ef9a82a45734526ccf45959e204

@ -1 +1 @@
Subproject commit 2d411a650afa04f0468f7648ee0b5a765362161c
Subproject commit 963883520c7bbe5040366335c9a37bbdc7cf60fd

@ -1 +1 @@
Subproject commit 605a73d2b7bec6438c7c0d5ab09eae86b5e9212e
Subproject commit 2948cd7595e632f7555e2dc09e6bac050a2b87ea

@ -1 +1 @@
Subproject commit 7e706b1cb67655db75d3a154038e4f11e2d00128
Subproject commit e63e91aa833d774be9bf4a76013b1025a009989d

View file

@ -1,6 +1,6 @@
{
"name": "@editorjs/editorjs",
"version": "2.30.0-rc.2",
"version": "2.30.0-rc.9",
"description": "Editor.js — Native JS, based on API and Open Source",
"main": "dist/editorjs.umd.js",
"module": "dist/editorjs.mjs",
@ -56,6 +56,7 @@
"cypress-intellij-reporter": "^0.0.7",
"cypress-plugin-tab": "^1.0.5",
"cypress-terminal-report": "^5.3.2",
"cypress-vite": "^1.5.0",
"eslint": "^8.37.0",
"eslint-config-codex": "^1.7.1",
"eslint-plugin-chai-friendly": "^0.7.2",

View file

@ -6,7 +6,7 @@ import {
SanitizerConfig,
ToolConfig,
ToolboxConfigEntry,
PopoverItem
PopoverItemParams
} from '../../../types';
import { SavedData } from '../../../types/data-formats';
@ -25,7 +25,8 @@ import { TunesMenuConfigItem } from '../../../types/tools';
import { isMutationBelongsToElement } from '../utils/mutations';
import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events';
import { RedactorDomChangedPayload } from '../events/RedactorDomChanged';
import { convertBlockDataToString } from '../utils/blocks';
import { convertBlockDataToString, isSameBlockData } from '../utils/blocks';
import { PopoverItemType } from '../utils/popover';
/**
* Interface describes Block class constructor argument
@ -229,7 +230,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
tunesData,
}: BlockConstructorOptions, eventBus?: EventsDispatcher<EditorEventMap>) {
super();
this.name = tool.name;
this.id = id;
this.settings = tool.settings;
@ -611,33 +611,54 @@ export default class Block extends EventsDispatcher<BlockEvents> {
}
/**
* Returns data to render in tunes menu.
* Splits block tunes settings into 2 groups: popover items and custom html.
* Returns data to render in Block Tunes menu.
* Splits block tunes into 2 groups: block specific tunes and common tunes
*/
public getTunes(): [PopoverItem[], HTMLElement] {
const customHtmlTunesContainer = document.createElement('div');
const tunesItems: TunesMenuConfigItem[] = [];
public getTunes(): {
toolTunes: PopoverItemParams[];
commonTunes: PopoverItemParams[];
} {
const toolTunesPopoverParams: TunesMenuConfigItem[] = [];
const commonTunesPopoverParams: TunesMenuConfigItem[] = [];
/** Tool's tunes: may be defined as return value of optional renderSettings method */
const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : [];
if ($.isElement(tunesDefinedInTool)) {
toolTunesPopoverParams.push({
type: PopoverItemType.Html,
element: tunesDefinedInTool,
});
} else if (Array.isArray(tunesDefinedInTool)) {
toolTunesPopoverParams.push(...tunesDefinedInTool);
} else {
toolTunesPopoverParams.push(tunesDefinedInTool);
}
/** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */
const commonTunes = [
...this.tunesInstances.values(),
...this.defaultTunesInstances.values(),
].map(tuneInstance => tuneInstance.render());
[tunesDefinedInTool, commonTunes].flat().forEach(rendered => {
if ($.isElement(rendered)) {
customHtmlTunesContainer.appendChild(rendered);
} else if (Array.isArray(rendered)) {
tunesItems.push(...rendered);
/** Separate custom html from Popover items params for common tunes */
commonTunes.forEach(tuneConfig => {
if ($.isElement(tuneConfig)) {
commonTunesPopoverParams.push({
type: PopoverItemType.Html,
element: tuneConfig,
});
} else if (Array.isArray(tuneConfig)) {
commonTunesPopoverParams.push(...tuneConfig);
} else {
tunesItems.push(rendered);
commonTunesPopoverParams.push(tuneConfig);
}
});
return [tunesItems, customHtmlTunesContainer];
return {
toolTunes: toolTunesPopoverParams,
commonTunes: commonTunesPopoverParams,
};
}
/**
@ -711,11 +732,8 @@ export default class Block extends EventsDispatcher<BlockEvents> {
const blockData = await this.data;
const toolboxItems = toolboxSettings;
return toolboxItems.find((item) => {
return Object.entries(item.data)
.some(([propName, propValue]) => {
return blockData[propName] && _.equals(blockData[propName], propValue);
});
return toolboxItems?.find((item) => {
return isSameBlockData(item.data, blockData);
});
}

View file

@ -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);
}

View file

@ -0,0 +1,15 @@
/**
* Fired when editor mobile layout toggled
*/
export const EditorMobileLayoutToggled = 'editor mobile layout toggled';
/**
* Payload that will be passed with the event
*/
export interface EditorMobileLayoutToggledPayload {
/**
* True, if mobile layout enabled
*/
isEnabled: boolean;
}

View file

@ -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
}

View file

@ -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);
}

View file

@ -18,7 +18,8 @@
},
"popover": {
"Filter": "",
"Nothing found": ""
"Nothing found": "",
"Convert to": ""
}
},
"toolNames": {

View file

@ -1,4 +1,4 @@
import { BlockAPI as BlockAPIInterface, Blocks } from '../../../../types/api';
import type { BlockAPI as BlockAPIInterface, Blocks } from '../../../../types/api';
import { BlockToolData, OutputBlockData, OutputData, ToolConfig } from '../../../../types';
import * as _ from './../../utils';
import BlockAPI from '../../block/api';
@ -327,7 +327,7 @@ export default class BlocksAPI extends Module {
* @param dataOverrides - optional data overrides for the new block
* @throws Error if conversion is not possible
*/
private convert = (id: string, newType: string, dataOverrides?: BlockToolData): void => {
private convert = async (id: string, newType: string, dataOverrides?: BlockToolData): Promise<BlockAPIInterface> => {
const { BlockManager, Tools } = this.Editor;
const blockToConvert = BlockManager.getBlockById(id);
@ -346,7 +346,9 @@ export default class BlocksAPI extends Module {
const targetBlockConvertable = targetBlockTool.conversionConfig?.import !== undefined;
if (originalBlockConvertable && targetBlockConvertable) {
BlockManager.convert(blockToConvert, newType, dataOverrides);
const newBlock = await BlockManager.convert(blockToConvert, newType, dataOverrides);
return new BlockAPI(newBlock);
} else {
const unsupportedBlockTypes = [
!originalBlockConvertable ? capitalize(blockToConvert.name) : false,

View file

@ -1,5 +1,6 @@
import { Caret } from '../../../../types/api';
import { BlockAPI, Caret } from '../../../../types/api';
import Module from '../../__module';
import { resolveBlock } from '../../utils/api';
/**
* @class CaretAPI
@ -96,21 +97,23 @@ export default class CaretAPI extends Module {
/**
* Sets caret to the Block by passed index
*
* @param {number} index - index of Block where to set caret
* @param {string} position - position where to set caret
* @param {number} offset - caret offset
* @param blockOrIdOrIndex - either BlockAPI or Block id or Block index
* @param position - position where to set caret
* @param offset - caret offset
* @returns {boolean}
*/
private setToBlock = (
index: number,
blockOrIdOrIndex: BlockAPI | BlockAPI['id'] | number,
position: string = this.Editor.Caret.positions.DEFAULT,
offset = 0
): boolean => {
if (!this.Editor.BlockManager.blocks[index]) {
const block = resolveBlock(blockOrIdOrIndex, this.Editor);
if (block === undefined) {
return false;
}
this.Editor.Caret.setToBlock(this.Editor.BlockManager.blocks[index], position, offset);
this.Editor.Caret.setToBlock(block, position, offset);
return true;
};

View file

@ -249,6 +249,13 @@ export default class BlockEvents extends Module {
return;
}
/**
* The Toolbox will be opened with immediate focus on the Search input,
* and '/' will be added in the search input by default we need to prevent it and add '/' manually
*/
event.preventDefault();
this.Editor.Caret.insertContentAtCaretPosition('/');
this.activateToolbox();
}
@ -279,8 +286,12 @@ export default class BlockEvents extends Module {
/**
* Allow to create line breaks by Shift+Enter
*
* Note. On iOS devices, Safari automatically treats enter after a period+space (". |") as Shift+Enter
* (it used for capitalizing of the first letter of the next sentence)
* We don't need to lead soft line break in this case new block should be created
*/
if (event.shiftKey) {
if (event.shiftKey && !_.isIosDevice) {
return;
}

View file

@ -370,10 +370,10 @@ export default class BlockManager extends Module {
* @param newTool - new Tool name
* @param data - new Tool data
*/
public replace(block: Block, newTool: string, data: BlockToolData): void {
public replace(block: Block, newTool: string, data: BlockToolData): Block {
const blockIndex = this.getBlockIndex(block);
this.insert({
return this.insert({
tool: newTool,
data,
index: blockIndex,
@ -821,7 +821,7 @@ export default class BlockManager extends Module {
* @param targetToolName - name of the Tool to convert to
* @param blockDataOverrides - optional new Block data overrides
*/
public async convert(blockToConvert: Block, targetToolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
public async convert(blockToConvert: Block, targetToolName: string, blockDataOverrides?: BlockToolData): Promise<Block> {
/**
* At first, we get current Block data
*/
@ -866,7 +866,7 @@ export default class BlockManager extends Module {
newBlockData = Object.assign(newBlockData, blockDataOverrides);
}
this.replace(blockToConvert, replacingTool.name, newBlockData);
return this.replace(blockToConvert, replacingTool.name, newBlockData);
}
/**

View file

@ -7,7 +7,13 @@ 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, PopoverItemParams, PopoverItemDefaultParams, PopoverItemType } from '../../utils/popover';
import { PopoverEvent } from '../../utils/popover/popover.types';
import { isMobileScreen } from '../../utils';
import { EditorMobileLayoutToggled } from '../../events';
import * as _ from '../../utils';
import { IconReplace } from '@codexteam/icons';
import { isSameBlockData } from '../../utils/blocks';
/**
* HTML Elements that used for BlockSettings
@ -27,8 +33,6 @@ interface BlockSettingsNodes {
export default class BlockSettings extends Module<BlockSettingsNodes> {
/**
* Module Events
*
* @returns {{opened: string, closed: string}}
*/
public get events(): { opened: string; closed: string } {
return {
@ -56,8 +60,12 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*
* @todo remove once BlockSettings becomes standalone non-module class
*/
public get flipper(): Flipper {
return this.popover?.flipper;
public get flipper(): Flipper | undefined {
if (this.popover === null) {
return;
}
return 'flipper' in this.popover ? this.popover?.flipper : undefined;
}
/**
@ -67,9 +75,9 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
/**
* Popover instance. There is a util for vertical lists.
* Null until popover is not initialized
*/
private popover: Popover | undefined;
private popover: Popover | null = null;
/**
* Panel with block settings with 2 sections:
@ -82,6 +90,8 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
if (import.meta.env.MODE === 'test') {
this.nodes.wrapper.setAttribute('data-cy', 'block-tunes');
}
this.eventsDispatcher.on(EditorMobileLayoutToggled, this.close);
}
/**
@ -89,6 +99,8 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*/
public destroy(): void {
this.removeAllNodes();
this.listeners.destroy();
this.eventsDispatcher.off(EditorMobileLayoutToggled, this.close);
}
/**
@ -96,7 +108,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*
* @param targetBlock - near which Block we should open BlockSettings
*/
public open(targetBlock: Block = this.Editor.BlockManager.currentBlock): void {
public async open(targetBlock: Block = this.Editor.BlockManager.currentBlock): Promise<void> {
this.opened = true;
/**
@ -111,18 +123,17 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
this.Editor.BlockSelection.selectBlock(targetBlock);
this.Editor.BlockSelection.clearCache();
/**
* Fill Tool's settings
*/
const [tunesItems, customHtmlTunesContainer] = targetBlock.getTunes();
/** Get tool's settings data */
const { toolTunes, commonTunes } = targetBlock.getTunes();
/** 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,
customContentFlippableItems: this.getControls(customHtmlTunesContainer),
items: await this.getTunesItems(targetBlock, commonTunes, toolTunes),
scopeElement: this.Editor.API.methods.ui.nodes.redactor,
messages: {
nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'),
@ -132,7 +143,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
this.popover.on(PopoverEvent.Close, this.onPopoverClose);
this.nodes.wrapper.append(this.popover.getElement());
this.nodes.wrapper?.append(this.popover.getElement());
this.popover.show();
}
@ -140,14 +151,14 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
/**
* Returns root block settings element
*/
public getElement(): HTMLElement {
public getElement(): HTMLElement | undefined {
return this.nodes.wrapper;
}
/**
* Close Block Settings pane
*/
public close(): void {
public close = (): void => {
if (!this.opened) {
return;
}
@ -183,6 +194,115 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
this.popover.getElement().remove();
this.popover = null;
}
};
/**
* Returns list of items to be displayed in block tunes menu.
* Merges tool specific tunes, conversion menu and common tunes in one list in predefined order
*
* @param currentBlock block we are about to open block tunes for
* @param commonTunes common tunes
* @param toolTunes - tool specific tunes
*/
private async getTunesItems(currentBlock: Block, commonTunes: TunesMenuConfigItem[], toolTunes?: TunesMenuConfigItem[]): Promise<PopoverItemParams[]> {
const items = [] as TunesMenuConfigItem[];
if (toolTunes !== undefined && toolTunes.length > 0) {
items.push(...toolTunes);
items.push({
type: PopoverItemType.Separator,
});
}
const convertToItems = await this.getConvertToItems(currentBlock);
if (convertToItems.length > 0) {
items.push({
icon: IconReplace,
title: I18n.ui(I18nInternalNS.ui.popover, 'Convert to'),
children: {
items: convertToItems,
},
});
items.push({
type: PopoverItemType.Separator,
});
}
items.push(...commonTunes);
return items.map(tune => this.resolveTuneAliases(tune));
}
/**
* Returns list of all available conversion menu items
*
* @param currentBlock - block we are about to open block tunes for
*/
private async getConvertToItems(currentBlock: Block): Promise<PopoverItemDefaultParams[]> {
const conversionEntries = Array.from(this.Editor.Tools.blockTools.entries());
const resultItems: PopoverItemDefaultParams[] = [];
const blockData = await currentBlock.data;
conversionEntries.forEach(([toolName, tool]) => {
const conversionConfig = tool.conversionConfig;
/**
* Skip tools without «import» rule specified
*/
if (!conversionConfig || !conversionConfig.import) {
return;
}
tool.toolbox?.forEach((toolboxItem) => {
/**
* Skip tools that don't pass 'toolbox' property
*/
if (_.isEmpty(toolboxItem) || !toolboxItem.icon) {
return;
}
let shouldSkip = false;
if (toolboxItem.data !== undefined) {
/**
* When a tool has several toolbox entries, we need to make sure we do not add
* toolbox item with the same data to the resulting array. This helps exclude duplicates
*/
const hasSameData = isSameBlockData(toolboxItem.data, blockData);
shouldSkip = hasSameData;
} else {
shouldSkip = toolName === currentBlock.name;
}
if (shouldSkip) {
return;
}
resultItems.push({
icon: toolboxItem.icon,
title: toolboxItem.title,
name: toolName,
onActivate: async () => {
const { BlockManager, BlockSelection, Caret } = this.Editor;
const newBlock = await BlockManager.convert(this.Editor.BlockManager.currentBlock, toolName, toolboxItem.data);
BlockSelection.clearSelection();
this.close();
Caret.setToBlock(newBlock, Caret.positions.END);
},
});
});
});
return resultItems;
}
/**
@ -192,27 +312,15 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
this.close();
};
/**
* Returns list of buttons and inputs inside specified container
*
* @param container - container to query controls inside of
*/
private getControls(container: HTMLElement): HTMLElement[] {
const { StylesAPI } = this.Editor;
/** Query buttons and inputs inside tunes html */
const controls = container.querySelectorAll<HTMLElement>(
`.${StylesAPI.classes.settingsButton}, ${$.allInputsSelector}`
);
return Array.from(controls);
}
/**
* Resolves aliases in tunes menu items
*
* @param item - item with resolved aliases
*/
private resolveTuneAliases(item: TunesMenuConfigItem): TunesMenuConfigItem {
private resolveTuneAliases(item: TunesMenuConfigItem): PopoverItemParams {
if (item.type === PopoverItemType.Separator || item.type === PopoverItemType.Html) {
return item;
}
const result = resolveAliases(item, { label: 'title' });
if (item.confirmation) {

View file

@ -183,16 +183,14 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
public async replaceWithBlock(replacingToolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
const { BlockManager, BlockSelection, InlineToolbar, Caret } = this.Editor;
BlockManager.convert(this.Editor.BlockManager.currentBlock, replacingToolName, blockDataOverrides);
const newBlock = await BlockManager.convert(this.Editor.BlockManager.currentBlock, replacingToolName, blockDataOverrides);
BlockSelection.clearSelection();
this.close();
InlineToolbar.close();
window.requestAnimationFrame(() => {
Caret.setToBlock(this.Editor.BlockManager.currentBlock, Caret.positions.END);
});
Caret.setToBlock(newBlock, Caret.positions.END);
}
/**

View file

@ -220,6 +220,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
};
}
/**
* Toggles read-only mode
*
@ -479,9 +480,10 @@ export default class Toolbar extends Module<ToolbarNodes> {
}
});
return this.toolboxInstance.make();
return this.toolboxInstance.getElement();
}
/**
* Handler for Plus Button
*/

View file

@ -427,6 +427,10 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.nodes.togglerAndButtonsWrapper.appendChild(this.nodes.conversionToggler);
if (import.meta.env.MODE === 'test') {
this.nodes.conversionToggler.setAttribute('data-cy', 'conversion-toggler');
}
this.listeners.on(this.nodes.conversionToggler, 'click', () => {
this.Editor.ConversionToolbar.toggle((conversionToolbarOpened) => {
/**

View file

@ -16,6 +16,7 @@ import { mobileScreenBreakpoint } from '../utils';
import styles from '../../styles/main.css?inline';
import { BlockHovered } from '../events/BlockHovered';
import { selectionChangeDebounceTimeout } from '../constants';
import { EditorMobileLayoutToggled } from '../events';
/**
* HTML Elements used for UI
*/
@ -121,7 +122,7 @@ export default class UI extends Module<UINodes> {
/**
* Detect mobile version
*/
this.checkIsMobile();
this.setIsMobile();
/**
* Make main UI elements
@ -234,10 +235,21 @@ export default class UI extends Module<UINodes> {
}
/**
* Check for mobile mode and cache a result
* Check for mobile mode and save the result
*/
private checkIsMobile(): void {
this.isMobile = window.innerWidth < mobileScreenBreakpoint;
private setIsMobile(): void {
const isMobile = window.innerWidth < mobileScreenBreakpoint;
if (isMobile !== this.isMobile) {
/**
* Dispatch global event
*/
this.eventsDispatcher.emit(EditorMobileLayoutToggled, {
isEnabled: this.isMobile,
});
}
this.isMobile = isMobile;
}
/**
@ -426,7 +438,7 @@ export default class UI extends Module<UINodes> {
/**
* Detect mobile version
*/
this.checkIsMobile();
this.setIsMobile();
}
/**

View file

@ -3,11 +3,15 @@ import { BlockToolAPI } from '../block';
import Shortcuts from '../utils/shortcuts';
import BlockTool from '../tools/block';
import ToolsCollection from '../tools/collection';
import { API, BlockToolData, ToolboxConfigEntry, PopoverItem, BlockAPI } from '../../../types';
import { API, BlockToolData, ToolboxConfigEntry, PopoverItemParams, BlockAPI } from '../../../types';
import EventsDispatcher from '../utils/events';
import Popover, { PopoverEvent } from '../utils/popover';
import I18n from '../i18n';
import { I18nInternalNS } from '../i18n/namespace-internal';
import { PopoverEvent } from '../utils/popover/popover.types';
import Listeners from '../utils/listeners';
import Dom from '../dom';
import { Popover, PopoverDesktop, PopoverMobile } from '../utils/popover';
import { EditorMobileLayoutToggled } from '../events';
/**
* @todo the first Tab on the Block focus Plus Button, the second focus Block Tunes Toggler, the third focus next Block
@ -75,6 +79,11 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
*/
public opened = false;
/**
* Listeners util instance
*/
protected listeners: Listeners = new Listeners();
/**
* Editor API
*/
@ -82,8 +91,9 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
/**
* Popover instance. There is a util for vertical lists.
* Null until initialized
*/
private popover: Popover | undefined;
private popover: Popover | null = null;
/**
* List of Tools available. Some of them will be shown in the Toolbox
@ -99,10 +109,8 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
* 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<ToolboxEventMap> {
this.api = api;
this.tools = tools;
this.i18nLabels = i18nLabels;
}
/**
* Makes the Toolbox
*/
public make(): Element {
this.popover = new Popover({
scopeElement: this.api.ui.nodes.redactor,
searchable: true,
messages: {
nothingFound: this.i18nLabels.nothingFound,
search: this.i18nLabels.filter,
},
items: this.toolboxItemsToBeDisplayed,
});
this.popover.on(PopoverEvent.Close, this.onPopoverClose);
/**
* Enable tools shortcuts
*/
this.enableShortcuts();
this.nodes.toolbox = this.popover.getElement();
this.nodes.toolbox.classList.add(Toolbox.CSS.toolbox);
this.nodes = {
toolbox: Dom.make('div', Toolbox.CSS.toolbox),
};
this.initPopover();
if (import.meta.env.MODE === 'test') {
this.nodes.toolbox.setAttribute('data-cy', 'toolbox');
}
this.api.events.on(EditorMobileLayoutToggled, this.handleMobileLayoutToggle);
}
/**
* Returns root block settings element
*/
public getElement(): HTMLElement | null {
return this.nodes.toolbox;
}
@ -165,7 +163,11 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
* Returns true if the Toolbox has the Flipper activated and the Flipper has selected button
*/
public hasFocus(): boolean | undefined {
return this.popover?.hasFocus();
if (this.popover === null) {
return;
}
return 'hasFocus' in this.popover ? this.popover.hasFocus() : undefined;
}
/**
@ -176,11 +178,12 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
if (this.nodes && this.nodes.toolbox) {
this.nodes.toolbox.remove();
this.nodes.toolbox = null;
}
this.removeAllShortcuts();
this.popover?.off(PopoverEvent.Close, this.onPopoverClose);
this.listeners.destroy();
this.api.events.off(EditorMobileLayoutToggled, this.handleMobileLayoutToggle);
}
/**
@ -226,6 +229,50 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
}
}
/**
* Destroys existing popover instance and contructs the new one.
*/
public handleMobileLayoutToggle = (): void => {
this.destroyPopover();
this.initPopover();
};
/**
* Creates toolbox popover and appends it inside wrapper element
*/
private initPopover(): void {
const PopoverClass = _.isMobileScreen() ? PopoverMobile : PopoverDesktop;
this.popover = new PopoverClass({
scopeElement: this.api.ui.nodes.redactor,
searchable: true,
messages: {
nothingFound: this.i18nLabels.nothingFound,
search: this.i18nLabels.filter,
},
items: this.toolboxItemsToBeDisplayed,
});
this.popover.on(PopoverEvent.Close, this.onPopoverClose);
this.nodes.toolbox?.append(this.popover.getElement());
}
/**
* Destroys popover instance and removes it from DOM
*/
private destroyPopover(): void {
if (this.popover !== null) {
this.popover.hide();
this.popover.off(PopoverEvent.Close, this.onPopoverClose);
this.popover.destroy();
this.popover = null;
}
if (this.nodes.toolbox !== null) {
this.nodes.toolbox.innerHTML = '';
}
}
/**
* Handles popover close event
*/
@ -256,11 +303,11 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
* Returns list of items that will be displayed in toolbox
*/
@_.cacheable
private get toolboxItemsToBeDisplayed(): PopoverItem[] {
private get toolboxItemsToBeDisplayed(): PopoverItemParams[] {
/**
* Maps tool data to popover item structure
*/
const toPopoverItem = (toolboxItem: ToolboxConfigEntry, tool: BlockTool): PopoverItem => {
const toPopoverItem = (toolboxItem: ToolboxConfigEntry, tool: BlockTool): PopoverItemParams => {
return {
icon: toolboxItem.icon,
title: I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(tool.name)),
@ -273,7 +320,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
};
return this.toolsToBeDisplayed
.reduce<PopoverItem[]>((result, tool) => {
.reduce<PopoverItemParams[]>((result, tool) => {
if (Array.isArray(tool.toolbox)) {
tool.toolbox.forEach(item => {
result.push(toPopoverItem(item, tool));
@ -309,7 +356,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
Shortcuts.add({
name: shortcut,
on: this.api.ui.nodes.redactor,
handler: (event: KeyboardEvent) => {
handler: async (event: KeyboardEvent) => {
event.preventDefault();
const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
@ -321,11 +368,9 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
*/
if (currentBlock) {
try {
this.api.blocks.convert(currentBlock.id, toolName);
const newBlock = await this.api.blocks.convert(currentBlock.id, toolName);
window.requestAnimationFrame(() => {
this.api.caret.setToBlock(currentBlockIndex, 'end');
});
this.api.caret.setToBlock(newBlock, 'end');
return;
} catch (error) {}

View file

@ -0,0 +1,21 @@
import type { BlockAPI } from '../../../types/api/block';
import { EditorModules } from '../../types-internal/editor-modules';
import Block from '../block';
/**
* Returns Block instance by passed Block index or Block id
*
* @param attribute - either BlockAPI or Block id or Block index
* @param editor - Editor instance
*/
export function resolveBlock(attribute: BlockAPI | BlockAPI['id'] | number, editor: EditorModules): Block | undefined {
if (typeof attribute === 'number') {
return editor.BlockManager.getBlockByIndex(attribute);
}
if (typeof attribute === 'string') {
return editor.BlockManager.getBlockById(attribute);
}
return editor.BlockManager.getBlockById(attribute.id);
}

View file

@ -0,0 +1,25 @@
const ELEMENT_DELIMITER = '__';
const MODIFIER_DELIMITER = '--';
/**
* Utility function that allows to construct class names from block and element names
*
* @example bem('ce-popover)() -> 'ce-popover'
* @example bem('ce-popover)('container') -> 'ce-popover__container'
* @example bem('ce-popover)('container', 'hidden') -> 'ce-popover__container--hidden'
* @example bem('ce-popover)(null, 'hidden') -> 'ce-popover--hidden'
* @param blockName - string with block name
* @param elementName - string with element name
* @param modifier - modifier to be appended
*/
export function bem(blockName: string) {
return (elementName?: string | null, modifier?: string) => {
const className = [blockName, elementName]
.filter(x => !!x)
.join(ELEMENT_DELIMITER);
return [className, modifier]
.filter(x => !!x)
.join(MODIFIER_DELIMITER);
};
}

View file

@ -1,7 +1,8 @@
import type { ConversionConfig } from '../../../types/configs/conversion-config';
import type { BlockToolData } from '../../../types/tools/block-tool-data';
import type Block from '../block';
import { isFunction, isString, log } from '../utils';
import { isFunction, isString, log, equals } from '../utils';
/**
* Check if block has valid conversion config for export or import.
@ -19,6 +20,18 @@ export function isBlockConvertable(block: Block, direction: 'export' | 'import')
return isFunction(conversionProp) || isString(conversionProp);
}
/**
* Checks that all the properties of the first block data exist in second block data with the same values.
*
* @param data1 first block data
* @param data2 second block data
*/
export function isSameBlockData(data1: BlockToolData, data2: BlockToolData): boolean {
return Object.entries(data1).some((([propName, propValue]) => {
return data2[propName] && equals(data2[propName], propValue);
}));
}
/**
* Check if two blocks could be merged.
*

View file

@ -3,7 +3,7 @@ import { isEmpty } from '../utils';
/**
* Event Dispatcher event listener
*/
type Listener<Data> = (data?: Data) => void;
type Listener<Data> = (data: Data) => void;
/**
* Mapped type with subscriptions list

View file

@ -0,0 +1,16 @@
import { bem } from '../../../bem';
/**
* Hint block CSS class constructor
*/
const className = bem('ce-hint');
/**
* CSS class names to be used in hint class
*/
export const css = {
root: className(),
alignedLeft: className(null, 'align-left'),
title: className('title'),
description: className('description'),
};

View file

@ -0,0 +1,10 @@
.ce-hint {
&--align-left {
text-align: left;
}
&__description {
opacity: 0.6;
margin-top: 3px;
}
}

View file

@ -0,0 +1,46 @@
import Dom from '../../../../dom';
import { css } from './hint.const';
import { HintParams } from './hint.types';
import './hint.css';
/**
* Represents the hint content component
*/
export class Hint {
/**
* Html element used to display hint content on screen
*/
private nodes: {
root: HTMLElement;
title: HTMLElement;
description?: HTMLElement;
};
/**
* Constructs the hint content instance
*
* @param params - hint content parameters
*/
constructor(params: HintParams) {
this.nodes = {
root: Dom.make('div', [css.root, css.alignedLeft]),
title: Dom.make('div', css.title, { textContent: params.title }),
};
this.nodes.root.appendChild(this.nodes.title);
if (params.description !== undefined) {
this.nodes.description = Dom.make('div', css.description, { textContent: params.description });
this.nodes.root.appendChild(this.nodes.description);
}
}
/**
* Returns the root element of the hint content
*/
public getElement(): HTMLElement {
return this.nodes.root;
}
}

View file

@ -0,0 +1,19 @@
/**
* Hint parameters
*/
export interface HintParams {
/**
* Title of the hint
*/
title: string;
/**
* Secondary text to be displayed below the title
*/
description?: string;
}
/**
* Possible hint positions
*/
export type HintPosition = 'top' | 'bottom' | 'left' | 'right';

View file

@ -0,0 +1,2 @@
export * from './hint';
export * from './hint.types';

View file

@ -0,0 +1,2 @@
export * from './popover-header';
export * from './popover-header.types';

View file

@ -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'),
};

View file

@ -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();
}
}

View file

@ -0,0 +1,14 @@
/**
* Popover header params
*/
export interface PopoverHeaderParams {
/**
* Text to be displayed inside header
*/
text: string;
/**
* Back button click handler
*/
onBackButtonClick: () => void;
}

View file

@ -0,0 +1,12 @@
import { PopoverItemDefault } from './popover-item-default/popover-item-default';
import { PopoverItemSeparator } from './popover-item-separator/popover-item-separator';
import { PopoverItem } from './popover-item';
export * from './popover-item-default/popover-item-default.const';
export * from './popover-item.types';
export {
PopoverItemDefault,
PopoverItemSeparator,
PopoverItem
};

View file

@ -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')(),
};

View file

@ -1,16 +1,28 @@
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 {
PopoverItemDefaultParams as PopoverItemDefaultParams,
PopoverItemParams as PopoverItemParams,
PopoverItemRenderParamsMap,
PopoverItemType
} from '../popover-item.types';
import { PopoverItem } from '../popover-item';
import { css } from './popover-item-default.const';
/**
* Represents sigle popover item node
*
* @todo move nodes initialization to constructor
* @todo replace multiple make() usages with constructing separate instances
* @todo split regular popover item and popover item with confirmation to separate classes
* @todo display icon on the right side of the item for rtl languages
*/
export class PopoverItem {
export class PopoverItemDefault extends 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 +57,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);
}
/**
@ -59,63 +75,29 @@ export class PopoverItem {
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',
};
}
private confirmationState: PopoverItemDefaultParams | null = null;
/**
* Constructs popover item instance
*
* @param params - popover item construction params
* @param renderParams - popover item render params.
* The parameters that are not set by user via popover api but rather depend on technical implementation
*/
constructor(params: PopoverItemParams) {
this.params = params;
this.nodes.root = this.make(params);
constructor(private readonly params: PopoverItemDefaultParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Default]) {
super();
this.nodes.root = this.make(params, renderParams);
}
/**
* Returns popover item root element
*/
public getElement(): HTMLElement {
public getElement(): HTMLElement | null {
return this.nodes.root;
}
@ -123,7 +105,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 +120,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);
}
/**
@ -146,8 +128,8 @@ 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);
public override toggleHidden(isHidden: boolean): void {
this.nodes.root?.classList.toggle(css.hidden, isHidden);
}
/**
@ -166,40 +148,61 @@ 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
* @param renderParams - popover item render params
*/
private make(params: PopoverItemParams): HTMLElement {
const el = Dom.make('div', PopoverItem.CSS.container);
private make(params: PopoverItemDefaultParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Default]): HTMLElement {
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);
}
if (params.hint !== undefined && renderParams?.hint?.enabled !== false) {
this.addHint(el, {
...params.hint,
position: renderParams?.hint?.position || 'right',
});
}
return el;
@ -210,16 +213,20 @@ export class PopoverItem {
*
* @param newState - new popover item params that should be applied
*/
private enableConfirmationMode(newState: PopoverItemParams): void {
private enableConfirmationMode(newState: PopoverItemDefaultParams): void {
if (this.nodes.root === null) {
return;
}
const params = {
...this.params,
...newState,
confirmation: newState.confirmation,
} as PopoverItemParams;
} as PopoverItemDefaultParams;
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 +237,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 +255,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 +268,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);
};
/**
@ -280,10 +290,10 @@ export class PopoverItem {
*
* @param item - item to activate or bring to confirmation mode
*/
private activateOrEnableConfirmationMode(item: PopoverItemParams): void {
private activateOrEnableConfirmationMode(item: PopoverItemDefaultParams): void {
if (item.confirmation === undefined) {
try {
item.onActivate(item);
item.onActivate?.(item);
this.disableConfirmationMode();
} catch {
this.animateError();
@ -297,20 +307,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);
};
}

View file

@ -0,0 +1,14 @@
import { bem } from '../../../../bem';
/**
* Popover item block CSS class constructor
*/
const className = bem('ce-popover-item-html');
/**
* CSS class names to be used in popover item class
*/
export const css = {
root: className(),
hidden: className(null, 'hidden'),
};

View file

@ -0,0 +1,66 @@
import { PopoverItem } from '../popover-item';
import { PopoverItemHtmlParams, PopoverItemRenderParamsMap, PopoverItemType } from '../popover-item.types';
import { css } from './popover-item-html.const';
import Dom from '../../../../../dom';
/**
* Represents popover item with custom html content
*/
export class PopoverItemHtml extends PopoverItem {
/**
* Item html elements
*/
private nodes: { root: HTMLElement };
/**
* Constructs the instance
*
* @param params instance parameters
* @param renderParams popover item render params.
* The parameters that are not set by user via popover api but rather depend on technical implementation
*/
constructor(params: PopoverItemHtmlParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Html]) {
super();
this.nodes = {
root: Dom.make('div', css.root),
};
this.nodes.root.appendChild(params.element);
if (params.hint !== undefined && renderParams?.hint?.enabled !== false) {
this.addHint(this.nodes.root, {
...params.hint,
position: renderParams?.hint?.position || 'right',
});
}
}
/**
* Returns popover item root element
*/
public getElement(): HTMLElement {
return this.nodes.root;
}
/**
* Toggles item hidden state
*
* @param isHidden - true if item should be hidden
*/
public toggleHidden(isHidden: boolean): void {
this.nodes.root?.classList.toggle(css.hidden, isHidden);
}
/**
* Returns list of buttons and inputs inside custom content
*/
public getControls(): HTMLElement[] {
/** Query buttons and inputs inside custom html */
const controls = this.nodes.root.querySelectorAll<HTMLElement>(
`button, ${Dom.allInputsSelector}`
);
return Array.from(controls);
}
}

View file

@ -0,0 +1,15 @@
import { bem } from '../../../../bem';
/**
* Popover separator block CSS class constructor
*/
const className = bem('ce-popover-item-separator');
/**
* CSS class names to be used in popover separator class
*/
export const css = {
container: className(),
line: className('line'),
hidden: className(null, 'hidden'),
};

View file

@ -0,0 +1,43 @@
import Dom from '../../../../../dom';
import { PopoverItem } from '../popover-item';
import { css } from './popover-item-separator.const';
/**
* Represents popover separator node
*/
export class PopoverItemSeparator extends PopoverItem {
/**
* Html elements
*/
private nodes: { root: HTMLElement; line: HTMLElement };
/**
* Constructs the instance
*/
constructor() {
super();
this.nodes = {
root: Dom.make('div', css.container),
line: Dom.make('div', css.line),
};
this.nodes.root.appendChild(this.nodes.line);
}
/**
* Returns popover separator root element
*/
public getElement(): HTMLElement {
return this.nodes.root;
}
/**
* Toggles item hidden state
*
* @param isHidden - true if item should be hidden
*/
public toggleHidden(isHidden: boolean): void {
this.nodes.root?.classList.toggle(css.hidden, isHidden);
}
}

View file

@ -0,0 +1,34 @@
import * as tooltip from '../../../../utils/tooltip';
import { type HintPosition, Hint } from '../hint';
/**
* Popover item abstract class
*/
export abstract class PopoverItem {
/**
* Adds hint to the item element if hint data is provided
*
* @param itemElement - popover item root element to add hint to
* @param hintData - hint data
*/
protected addHint(itemElement: HTMLElement, hintData: { title: string, description?: string; position: HintPosition }): void {
const content = new Hint(hintData);
tooltip.onHover(itemElement, content.getElement(), {
placement: hintData.position,
hidingDelay: 100,
});
}
/**
* Returns popover item root element
*/
public abstract getElement(): HTMLElement | null;
/**
* Toggles item hidden state
*
* @param isHidden - true if item should be hidden
*/
public abstract toggleHidden(isHidden: boolean): void;
}

View file

@ -0,0 +1,190 @@
import { HintParams, HintPosition } from '../hint';
/**
* Popover item types
*/
export enum PopoverItemType {
/** Regular item with icon, title and other properties */
Default = 'default',
/** Gray line used to separate items from each other */
Separator = 'separator',
/** Item with custom html content */
Html = 'html'
}
/**
* Represents popover item separator.
* Special item type that is used to separate items in the popover.
*/
export interface PopoverItemSeparatorParams {
/**
* Item type
*/
type: PopoverItemType.Separator
}
/**
* Represents popover item with custom html content
*/
export interface PopoverItemHtmlParams {
/**
* Item type
*/
type: PopoverItemType.Html;
/**
* Custom html content to be displayed in the popover
*/
element: HTMLElement;
/**
* Hint data to be displayed on item hover
*/
hint?: HintParams;
}
/**
* Common parameters for all kinds of default popover items: with or without confirmation
*/
interface PopoverItemDefaultBaseParams {
/**
* Item type
*/
type?: PopoverItemType.Default;
/**
* Displayed text
*/
title?: string;
/**
* Item icon to be appeared near a title
*/
icon?: string;
/**
* Additional displayed text
*/
secondaryLabel?: string;
/**
* True if item should be highlighted as active
*/
isActive?: boolean;
/**
* True if item should be disabled
*/
isDisabled?: boolean;
/**
* True if popover should close once item is activated
*/
closeOnActivate?: boolean;
/**
* Item name
* Used in data attributes needed for cypress tests
*/
name?: string;
/**
* Defines whether item should toggle on click.
* Can be represented as boolean value or a string key.
* In case of string, works like radio buttons group and highlights as inactive any other item that has same toggle key value.
*/
toggle?: boolean | string;
/**
* Hint data to be displayed on item hover
*/
hint?: HintParams;
}
/**
* Represents popover item with confirmation state configuration
*/
export interface PopoverItemWithConfirmationParams extends PopoverItemDefaultBaseParams {
/**
* Popover item parameters that should be applied on item activation.
* May be used to ask user for confirmation before executing popover item activation handler.
*/
confirmation: PopoverItemDefaultParams;
onActivate?: never;
}
/**
* Represents popover item without confirmation state configuration
*/
export interface PopoverItemWithoutConfirmationParams extends PopoverItemDefaultBaseParams {
confirmation?: never;
/**
* Popover item activation handler
*
* @param item - activated item
* @param event - event that initiated item activation
*/
onActivate: (item: PopoverItemParams, event?: PointerEvent) => void;
}
/**
* Represents popover item with children (nested popover items)
*/
export interface PopoverItemWithChildrenParams extends PopoverItemDefaultBaseParams {
confirmation?: never;
onActivate?: never;
/**
* Items of nested popover that should be open on the current item hover/click (depending on platform)
*/
children?: {
items: PopoverItemParams[]
}
}
/**
* Default, non-separator popover item type
*/
export type PopoverItemDefaultParams =
PopoverItemWithConfirmationParams |
PopoverItemWithoutConfirmationParams |
PopoverItemWithChildrenParams;
/**
* Represents single popover item
*/
export type PopoverItemParams =
PopoverItemDefaultParams |
PopoverItemSeparatorParams |
PopoverItemHtmlParams;
/**
* Popover item render params.
* The parameters that are not set by user via popover api but rather depend on technical implementation
*/
export type PopoverItemRenderParamsMap = {
[key in PopoverItemType.Default | PopoverItemType.Html]?: {
/**
* Hint render params
*/
hint?: {
/**
* Hint position relative to the item
*/
position?: HintPosition;
/**
* If false, hint will not be rendered.
* True by default.
* Used to disable hints on mobile popover
*/
enabled: boolean;
}
};
};

View file

@ -0,0 +1,2 @@
export * from './search-input';
export * from './search-input.types';

View file

@ -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'),
};

View file

@ -1,18 +1,14 @@
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 { SearchInputEvent, SearchInputEventMap, SearchableItem } from './search-input.types';
import { css } from './search-input.const';
import EventsDispatcher from '../../../events';
/**
* Provides search input element and search logic
*/
export default class SearchInput {
export class SearchInput extends EventsDispatcher<SearchInputEventMap> {
/**
* Input wrapper element
*/
@ -36,44 +32,50 @@ export default class SearchInput {
/**
* Current search query
*/
private searchQuery: string;
/**
* 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',
};
}
private searchQuery: string | undefined;
/**
* @param options - available config
* @param options.items - searchable items list
* @param options.onSearch - search callback
* @param options.placeholder - input placeholder
*/
constructor({ items, onSearch, placeholder }: {
constructor({ items, placeholder }: {
items: SearchableItem[];
onSearch: (query: string, items: SearchableItem[]) => void;
placeholder: string;
placeholder?: string;
}) {
super();
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.emit(SearchInputEvent.Search, {
query: this.searchQuery,
items: this.foundItems,
});
});
}
/**
@ -96,7 +98,11 @@ export default class SearchInput {
public clear(): void {
this.input.value = '';
this.searchQuery = '';
this.onSearch('', this.foundItems);
this.emit(SearchInputEvent.Search, {
query: '',
items: this.foundItems,
});
}
/**
@ -106,38 +112,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 +126,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;
}
}

View file

@ -0,0 +1,30 @@
/**
* Item that could be searched
*/
export interface SearchableItem {
/**
* Items title
*/
title?: string;
}
/**
* Event that can be triggered by the Search Input
*/
export enum SearchInputEvent {
/**
* When search quert applied
*/
Search = 'search'
}
/**
* Events fired by the Search Input
*/
export interface SearchInputEventMap {
/**
* Fired when search quert applied
*/
[SearchInputEvent.Search]: { query: string; items: SearchableItem[]};
}

View file

@ -1,525 +1,12 @@
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';
export * from './components/popover-item/popover-item.types';
/**
* Params required to render popover
* Union type for all popovers
*/
interface PopoverParams {
/**
* Popover items config
*/
items: PopoverItemParams[];
export type Popover = PopoverDesktop | PopoverMobile;
/**
* Element of the page that creates 'scope' of the popover
*/
scopeElement?: HTMLElement;
/**
* Arbitrary html element to be inserted before items list
*/
customContent?: HTMLElement;
/**
* List of html elements inside custom content area that should be available for keyboard navigation
*/
customContentFlippableItems?: HTMLElement[];
/**
* True if popover should contain search field
*/
searchable?: boolean;
/**
* Popover texts overrides
*/
messages?: PopoverMessages
}
/**
* Texts used inside popover
*/
interface PopoverMessages {
/** Text displayed when search has no results */
nothingFound?: string;
/** Search input label */
search?: string
}
/**
* Event that can be triggered by the Popover
*/
export enum PopoverEvent {
/**
* When popover closes
*/
Close = 'close'
}
/**
* Events fired by the Popover
*/
interface PopoverEventMap {
[PopoverEvent.Close]: undefined;
}
/**
* Class responsible for rendering popover and handling its behaviour
*/
export default class Popover extends EventsDispatcher<PopoverEventMap> {
/**
* Flipper - module for keyboard iteration between elements
*/
public flipper: Flipper;
/**
* List of popover items
*/
private items: PopoverItem[];
/**
* Element of the page that creates 'scope' of the popover.
* If possible, popover will not cross specified element's borders when opening.
*/
private scopeElement: HTMLElement = document.body;
/**
* List of html elements inside custom content area that should be available for keyboard navigation
*/
private customContentFlippableItems: HTMLElement[] | undefined;
/**
* Instance of the Search Input
*/
private search: SearchInput | undefined;
/**
* Listeners util instance
*/
private listeners: Listeners = new Listeners();
/**
* ScrollLocker instance
*/
private scrollLocker = new ScrollLocker();
/**
* Popover CSS classes
*/
private static get CSS(): {
popover: string;
popoverOpenTop: string;
popoverOpened: string;
search: string;
nothingFoundMessage: string;
nothingFoundMessageDisplayed: string;
customContent: string;
customContentHidden: string;
items: string;
overlay: string;
overlayHidden: string;
} {
return {
popover: 'ce-popover',
popoverOpenTop: 'ce-popover--open-top',
popoverOpened: 'ce-popover--opened',
search: 'ce-popover__search',
nothingFoundMessage: 'ce-popover__nothing-found-message',
nothingFoundMessageDisplayed: 'ce-popover__nothing-found-message--displayed',
customContent: 'ce-popover__custom-content',
customContentHidden: 'ce-popover__custom-content--hidden',
items: 'ce-popover__items',
overlay: 'ce-popover__overlay',
overlayHidden: 'ce-popover__overlay--hidden',
};
}
/**
* Refs to created HTML elements
*/
private nodes: {
wrapper: HTMLElement | null;
popover: HTMLElement | null;
nothingFoundMessage: HTMLElement | null;
customContent: HTMLElement | null;
items: HTMLElement | null;
overlay: HTMLElement | null;
} = {
wrapper: null,
popover: null,
nothingFoundMessage: null,
customContent: null,
items: null,
overlay: null,
};
/**
* Messages that will be displayed in popover
*/
private messages: PopoverMessages = {
nothingFound: 'Nothing found',
search: 'Search',
};
/**
* Constructs the instance
*
* @param params - popover construction params
*/
constructor(params: PopoverParams) {
super();
this.items = params.items.map(item => new PopoverItem(item));
if (params.scopeElement !== undefined) {
this.scopeElement = params.scopeElement;
}
if (params.messages) {
this.messages = {
...this.messages,
...params.messages,
};
}
if (params.customContentFlippableItems) {
this.customContentFlippableItems = params.customContentFlippableItems;
}
this.make();
if (params.customContent) {
this.addCustomContent(params.customContent);
}
if (params.searchable) {
this.addSearch();
}
this.initializeFlipper();
}
/**
* Returns HTML element corresponding to the popover
*/
public getElement(): HTMLElement {
return this.nodes.wrapper as HTMLElement;
}
/**
* Returns true if some item inside popover is focused
*/
public hasFocus(): boolean {
return this.flipper.hasFocus();
}
/**
* Open popover
*/
public show(): void {
if (!this.shouldOpenBottom) {
this.nodes.popover.style.setProperty('--popover-height', this.height + 'px');
this.nodes.popover.classList.add(Popover.CSS.popoverOpenTop);
}
this.nodes.overlay.classList.remove(Popover.CSS.overlayHidden);
this.nodes.popover.classList.add(Popover.CSS.popoverOpened);
this.flipper.activate(this.flippableElements);
if (this.search !== undefined) {
this.search?.focus();
}
if (isMobileScreen()) {
this.scrollLocker.lock();
}
}
/**
* Closes popover
*/
public hide(): void {
this.nodes.popover.classList.remove(Popover.CSS.popoverOpened);
this.nodes.popover.classList.remove(Popover.CSS.popoverOpenTop);
this.nodes.overlay.classList.add(Popover.CSS.overlayHidden);
this.flipper.deactivate();
this.items.forEach(item => item.reset());
if (this.search !== undefined) {
this.search.clear();
}
if (isMobileScreen()) {
this.scrollLocker.unlock();
}
this.emit(PopoverEvent.Close);
}
/**
* Clears memory
*/
public destroy(): void {
this.flipper.deactivate();
this.listeners.removeAll();
if (isMobileScreen()) {
this.scrollLocker.unlock();
}
}
/**
* Constructs HTML element corresponding to popover
*/
private make(): void {
this.nodes.popover = Dom.make('div', [ Popover.CSS.popover ]);
this.nodes.nothingFoundMessage = Dom.make('div', [ Popover.CSS.nothingFoundMessage ], {
textContent: this.messages.nothingFound,
});
this.nodes.popover.appendChild(this.nodes.nothingFoundMessage);
this.nodes.items = Dom.make('div', [ Popover.CSS.items ]);
this.items.forEach(item => {
this.nodes.items.appendChild(item.getElement());
});
this.nodes.popover.appendChild(this.nodes.items);
this.listeners.on(this.nodes.popover, 'click', (event: PointerEvent) => {
const item = this.getTargetItem(event);
if (item === undefined) {
return;
}
this.handleItemClick(item);
});
this.nodes.wrapper = Dom.make('div');
this.nodes.overlay = Dom.make('div', [Popover.CSS.overlay, Popover.CSS.overlayHidden]);
this.listeners.on(this.nodes.overlay, 'click', () => {
this.hide();
});
this.nodes.wrapper.appendChild(this.nodes.overlay);
this.nodes.wrapper.appendChild(this.nodes.popover);
}
/**
* Adds search to the popover
*/
private addSearch(): void {
this.search = new SearchInput({
items: this.items,
placeholder: this.messages.search,
onSearch: (query: string, result: PopoverItem[]): void => {
this.items.forEach(item => {
const isHidden = !result.includes(item);
item.toggleHidden(isHidden);
});
this.toggleNothingFoundMessage(result.length === 0);
this.toggleCustomContent(query !== '');
/** List of elements available for keyboard navigation considering search query applied */
const flippableElements = query === '' ? this.flippableElements : result.map(item => item.getElement());
if (this.flipper.isActivated) {
/** Update flipper items with only visible */
this.flipper.deactivate();
this.flipper.activate(flippableElements);
}
},
});
const searchElement = this.search.getElement();
searchElement.classList.add(Popover.CSS.search);
this.nodes.popover.insertBefore(searchElement, this.nodes.popover.firstChild);
}
/**
* Adds custom html content to the popover
*
* @param content - html content to append
*/
private addCustomContent(content: HTMLElement): void {
this.nodes.customContent = content;
this.nodes.customContent.classList.add(Popover.CSS.customContent);
this.nodes.popover.insertBefore(content, this.nodes.popover.firstChild);
}
/**
* Retrieves popover item that is the target of the specified event
*
* @param event - event to retrieve popover item from
*/
private getTargetItem(event: PointerEvent): PopoverItem | undefined {
return this.items.find(el => event.composedPath().includes(el.getElement()));
}
/**
* Handles item clicks
*
* @param item - item to handle click of
*/
private handleItemClick(item: PopoverItem): void {
if (item.isDisabled) {
return;
}
/** Cleanup other items state */
this.items.filter(x => x !== item).forEach(x => x.reset());
item.handleClick();
this.toggleItemActivenessIfNeeded(item);
if (item.closeOnActivate) {
this.hide();
}
}
/**
* Creates Flipper instance which allows to navigate between popover items via keyboard
*/
private initializeFlipper(): void {
this.flipper = new Flipper({
items: this.flippableElements,
focusedItemClass: PopoverItem.CSS.focused,
allowedKeys: [
keyCodes.TAB,
keyCodes.UP,
keyCodes.DOWN,
keyCodes.ENTER,
],
});
this.flipper.onFlip(this.onFlip);
}
/**
* Returns list of elements available for keyboard navigation.
* Contains both usual popover items elements and custom html content.
*/
private get flippableElements(): HTMLElement[] {
const popoverItemsElements = this.items.map(item => item.getElement());
const customContentControlsElements = this.customContentFlippableItems || [];
/**
* Combine elements inside custom content area with popover items elements
*/
return customContentControlsElements.concat(popoverItemsElements);
}
/**
* Helps to calculate height of popover while it is not displayed on screen.
* Renders invisible clone of popover to get actual height.
*/
@cacheable
private get height(): number {
let height = 0;
if (this.nodes.popover === null) {
return height;
}
const popoverClone = this.nodes.popover.cloneNode(true) as HTMLElement;
popoverClone.style.visibility = 'hidden';
popoverClone.style.position = 'absolute';
popoverClone.style.top = '-1000px';
popoverClone.classList.add(Popover.CSS.popoverOpened);
document.body.appendChild(popoverClone);
height = popoverClone.offsetHeight;
popoverClone.remove();
return height;
}
/**
* Checks if popover should be opened bottom.
* It should happen when there is enough space below or not enough space above
*/
private get shouldOpenBottom(): boolean {
const popoverRect = this.nodes.popover.getBoundingClientRect();
const scopeElementRect = this.scopeElement.getBoundingClientRect();
const popoverHeight = this.height;
const popoverPotentialBottomEdge = popoverRect.top + popoverHeight;
const popoverPotentialTopEdge = popoverRect.top - popoverHeight;
const bottomEdgeForComparison = Math.min(window.innerHeight, scopeElementRect.bottom);
return popoverPotentialTopEdge < scopeElementRect.top || popoverPotentialBottomEdge <= bottomEdgeForComparison;
}
/**
* Called on flipper navigation
*/
private onFlip = (): void => {
const focusedItem = this.items.find(item => item.isFocused);
focusedItem.onFocus();
};
/**
* Toggles nothing found message visibility
*
* @param isDisplayed - true if the message should be displayed
*/
private toggleNothingFoundMessage(isDisplayed: boolean): void {
this.nodes.nothingFoundMessage.classList.toggle(Popover.CSS.nothingFoundMessageDisplayed, isDisplayed);
}
/**
* Toggles custom content visibility
*
* @param isDisplayed - true if custom content should be displayed
*/
private toggleCustomContent(isDisplayed: boolean): void {
this.nodes.customContent?.classList.toggle(Popover.CSS.customContentHidden, isDisplayed);
}
/**
* - Toggles item active state, if clicked popover item has property 'toggle' set to true.
*
* - Performs radiobutton-like behavior if the item has property 'toggle' set to string key.
* (All the other items with the same key get inactive, and the item gets active)
*
* @param clickedItem - popover item that was clicked
*/
private toggleItemActivenessIfNeeded(clickedItem: PopoverItem): void {
if (clickedItem.toggle === true) {
clickedItem.toggleActive();
}
if (typeof clickedItem.toggle === 'string') {
const itemsInToggleGroup = this.items.filter(item => item.toggle === clickedItem.toggle);
/** If there's only one item in toggle group, toggle it */
if (itemsInToggleGroup.length === 1) {
clickedItem.toggleActive();
return;
}
/** Set clicked item as active and the rest items with same toggle key value as inactive */
itemsInToggleGroup.forEach(item => {
item.toggleActive(item === clickedItem);
});
}
}
}
export { PopoverDesktop, PopoverMobile };

View file

@ -0,0 +1,310 @@
import { PopoverItem, PopoverItemDefault, PopoverItemRenderParamsMap, PopoverItemSeparator, PopoverItemType } from './components/popover-item';
import Dom from '../../dom';
import { SearchInput, SearchInputEvent, 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';
import { PopoverItemParams } from './components/popover-item';
import { PopoverItemHtml } from './components/popover-item/popover-item-html/popover-item-html';
/**
* Class responsible for rendering popover and handling its behaviour
*/
export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes> extends EventsDispatcher<PopoverEventMap> {
/**
* List of popover items
*/
protected items: Array<PopoverItem>;
/**
* Listeners util instance
*/
protected listeners: Listeners = new Listeners();
/**
* Refs to created HTML elements
*/
protected nodes: Nodes;
/**
* List of default popover items that are searchable and may have confirmation state
*/
protected get itemsDefault(): PopoverItemDefault[] {
return this.items.filter(item => item instanceof PopoverItemDefault) as PopoverItemDefault[];
}
/**
* Instance of the Search Input
*/
protected 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
* @param itemsRenderParams - popover item render params.
* The parameters that are not set by user via popover api but rather depend on technical implementation
*/
constructor(
protected readonly params: PopoverParams,
protected readonly itemsRenderParams: PopoverItemRenderParamsMap = {}
) {
super();
this.items = this.buildItems(params.items);
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.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.itemsDefault.forEach(item => item.reset());
if (this.search !== undefined) {
this.search.clear();
}
this.emit(PopoverEvent.Close);
}
/**
* Clears memory
*/
public destroy(): void {
this.listeners.removeAll();
}
/**
* Factory method for creating popover items
*
* @param items - list of items params
*/
protected buildItems(items: PopoverItemParams[]): Array<PopoverItem> {
return items.map(item => {
switch (item.type) {
case PopoverItemType.Separator:
return new PopoverItemSeparator();
case PopoverItemType.Html:
return new PopoverItemHtml(item, this.itemsRenderParams[PopoverItemType.Html]);
default:
return new PopoverItemDefault(item, this.itemsRenderParams[PopoverItemType.Default]);
}
});
}
/**
* Retrieves popover item that is the target of the specified event
*
* @param event - event to retrieve popover item from
*/
protected getTargetItem(event: Event): PopoverItemDefault | undefined {
return this.itemsDefault.find(el => {
const itemEl = el.getElement();
if (itemEl === null) {
return false;
}
return event.composedPath().includes(itemEl);
});
}
/**
* Handles input inside search field
*
* @param data - search input event data
* @param data.query - search query text
* @param data.result - search results
*/
private onSearch = (data: { query: string, items: SearchableItem[] }): void => {
const isEmptyQuery = data.query === '';
const isNothingFound = data.items.length === 0;
this.items
.forEach((item) => {
let isHidden = false;
if (item instanceof PopoverItemDefault) {
isHidden = !data.items.includes(item);
} else if (item instanceof PopoverItemSeparator || item instanceof PopoverItemHtml) {
/** Should hide separators if nothing found message displayed or if there is some search query applied */
isHidden = isNothingFound || !isEmptyQuery;
}
item.toggleHidden(isHidden);
});
this.toggleNothingFoundMessage(isNothingFound);
};
/**
* Adds search to the popover
*/
private addSearch(): void {
this.search = new SearchInput({
items: this.itemsDefault,
placeholder: this.messages.search,
});
this.search.on(SearchInputEvent.Search, this.onSearch);
const searchElement = this.search.getElement();
searchElement.classList.add(css.search);
this.nodes.popoverContainer.insertBefore(searchElement, 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.itemsDefault.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 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: PopoverItemDefault): void {
if (clickedItem.toggle === true) {
clickedItem.toggleActive();
}
if (typeof clickedItem.toggle === 'string') {
const itemsInToggleGroup = this.itemsDefault.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: PopoverItemDefault): void;
}

View file

@ -0,0 +1,358 @@
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 { SearchInputEvent, SearchableItem } from './components/search-input';
import { cacheable } from '../../utils';
import { PopoverItemDefault } from './components/popover-item';
import { PopoverItemHtml } from './components/popover-item/popover-item-html/popover-item-html';
/**
* Desktop popover.
* On desktop devices popover behaves like a floating element. Nested popover appears at right or left side.
*
* @todo support rtl for nested popovers and search
*/
export class PopoverDesktop extends PopoverAbstract {
/**
* Flipper - module for keyboard iteration between elements
*/
public flipper: Flipper;
/**
* 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.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);
this.search?.on(SearchInputEvent.Search, this.handleSearch);
}
/**
* 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.destroyNestedPopoverIfExists();
this.flipper.deactivate();
this.previouslyHoveredItem = null;
}
/**
* Clears memory
*/
public destroy(): void {
this.hide();
super.destroy();
}
/**
* Handles displaying nested items for the item.
*
* @param item item to show nested popover for
*/
protected override showNestedItems(item: PopoverItemDefault): void {
if (this.nestedPopover !== null && this.nestedPopover !== undefined) {
return;
}
this.showNestedPopoverForItem(item);
}
/**
* Additionaly handles input inside search field.
* Updates flipper items considering search query applied.
*
* @param data - search event data
* @param data.query - search query text
* @param data.result - search results
*/
private handleSearch = (data: { query: string, items: SearchableItem[] }): void => {
/** List of elements available for keyboard navigation considering search query applied */
const flippableElements = data.query === '' ? this.flippableElements : data.items.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[]);
}
};
/**
* 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.
*/
private get flippableElements(): HTMLElement[] {
const result = this.items
.map(item => {
if (item instanceof PopoverItemDefault) {
return item.getElement();
}
if (item instanceof PopoverItemHtml) {
return item.getControls();
}
})
.flat()
.filter(item => item !== undefined && item !== null);
return result as HTMLElement[];
}
/**
* Called on flipper navigation
*/
private onFlip = (): void => {
const focusedItem = this.itemsDefault.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: PopoverItemDefault): 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);
}
}

View file

@ -0,0 +1,161 @@
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 { PopoverItemDefault, PopoverItemParams, PopoverItemType } from './components/popover-item';
import { css } from './popover.const';
import Dom from '../../dom';
/**
* Mobile Popover.
* On mobile devices Popover behaves like a fixed panel at the bottom of screen. Nested item appears like "pages" with the "back" button
*/
export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
/**
* ScrollLocker instance
*/
private scrollLocker = new ScrollLocker();
/**
* Reference to popover header if exists
*/
private header: PopoverHeader | undefined | null;
/**
* History of popover states for back navigation.
* Is used for mobile version of popover,
* where we can not display nested popover of the screen and
* have to render nested items in the same popover switching to new state
*/
private history = new PopoverStatesHistory();
/**
* Flag that indicates if popover is hidden
*/
private isHidden = true;
/**
* Construct the instance
*
* @param params - popover params
*/
constructor(params: PopoverParams) {
super(params, {
[PopoverItemType.Default]: {
hint: {
enabled: false,
},
},
});
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();
this.isHidden = false;
}
/**
* Closes popover
*/
public hide(): void {
if (this.isHidden) {
return;
}
super.hide();
this.nodes.overlay.classList.add(css.overlayHidden);
this.scrollLocker.unlock();
this.history.reset();
this.isHidden = true;
}
/**
* 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: PopoverItemDefault): 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 items - new popover items
* @param title - new popover header text
*/
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 = this.buildItems(items);
this.items.forEach(item => {
const itemEl = item.getElement();
if (itemEl === null) {
return;
}
this.nodes.items?.appendChild(itemEl);
});
}
}

View file

@ -0,0 +1,25 @@
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'),
items: className('items'),
overlay: className('overlay'),
overlayHidden: className('overlay', 'hidden'),
popoverNested: className(null, 'nested'),
popoverHeader: className('header'),
};

View file

@ -0,0 +1,96 @@
import { 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;
/**
* 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;
}
/**
* 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;
}

View file

@ -0,0 +1,73 @@
import { PopoverItem } from '../../../../../types';
/**
* Represents single states history item
*/
interface PopoverStatesHistoryItem {
/**
* Popover title
*/
title?: string;
/**
* Popover items
*/
items: PopoverItem[]
}
/**
* Manages items history inside popover. Allows to navigate back in history
*/
export class PopoverStatesHistory {
/**
* Previous items states
*/
private history: PopoverStatesHistoryItem[] = [];
/**
* Push new popover state
*
* @param state - new state
*/
public push(state: PopoverStatesHistoryItem): void {
this.history.push(state);
}
/**
* Pop last popover state
*/
public pop(): PopoverStatesHistoryItem | undefined {
return this.history.pop();
}
/**
* Title retrieved from the current state
*/
public get currentTitle(): string | undefined {
if (this.history.length === 0) {
return '';
}
return this.history[this.history.length - 1].title;
}
/**
* Items list retrieved from the current state
*/
public get currentItems(): PopoverItem[] {
if (this.history.length === 0) {
return [];
}
return this.history[this.history.length - 1].items;
}
/**
* Returns history to initial popover state
*/
public reset(): void {
while (this.history.length > 1) {
this.pop();
}
}
}

View file

@ -15,7 +15,7 @@ export default class ScrollLocker {
/**
* Stores scroll position, used for hard scroll lock
*/
private scrollPosition: null|number;
private scrollPosition: null | number = null;
/**
* Locks body element scroll

View file

@ -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,28 +109,28 @@
}
}
&--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;
}
}
&__search, &__custom-content:not(:empty) {
&__search {
margin-bottom: 5px;
}
@ -123,16 +151,30 @@
}
}
&__custom-content:not(:empty) {
padding: 4px;
@media (--not-mobile) {
padding: 0;
&--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;
}
}
&__custom-content--hidden {
display: none;
&--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%);
}
}
}
}
@ -140,15 +182,34 @@
/**
* Popover item styles
*/
.ce-popover-item {
--border-radius: 6px;
--icon-size: 20px;
--icon-size-mobile: 28px;
.ce-popover-item-separator {
padding: 4px 3px;
&--hidden {
display: none;
}
&__line {
height: 1px;
background: var(--color-border);
width: 100%;
}
}
.ce-popover-item-html {
&--hidden {
display: none;
}
}
.ce-popover-item {
--border-radius: 6px;
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 +222,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 +239,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 +261,8 @@
white-space: nowrap;
text-overflow: ellipsis;
margin-right: auto;
@media (--mobile) {
font-size: 16px;
}
@ -205,7 +271,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 +438,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;
}
}
}

View file

@ -1,5 +1,5 @@
import type EditorJS from '../../../../types/index';
import { ConversionConfig, ToolboxConfig } from '../../../../types';
import type { ConversionConfig, ToolboxConfig } from '../../../../types';
import ToolMock from '../../fixtures/tools/ToolMock';
/**
@ -202,7 +202,7 @@ describe('api.blocks', () => {
});
describe('.convert()', function () {
it('should convert a Block to another type if original Tool has "conversionConfig.export" and target Tool has "conversionConfig.import"', function () {
it('should convert a Block to another type if original Tool has "conversionConfig.export" and target Tool has "conversionConfig.import". Should return BlockAPI as well.', function () {
/**
* Mock of Tool with conversionConfig
*/
@ -246,20 +246,28 @@ describe('api.blocks', () => {
existingBlock,
],
},
}).then((editor) => {
}).then(async (editor) => {
const { convert } = editor.blocks;
convert(existingBlock.id, 'convertableTool');
const returnValue = await convert(existingBlock.id, 'convertableTool');
// wait for block to be converted
cy.wait(100).then(() => {
cy.wait(100).then(async () => {
/**
* Check that block was converted
*/
editor.save().then(( { blocks }) => {
expect(blocks.length).to.eq(1);
expect(blocks[0].type).to.eq('convertableTool');
expect(blocks[0].data.text).to.eq(existingBlock.data.text);
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1);
expect(blocks[0].type).to.eq('convertableTool');
expect(blocks[0].data.text).to.eq(existingBlock.data.text);
/**
* Check that returned value is BlockAPI
*/
expect(returnValue).to.containSubset({
name: 'convertableTool',
id: blocks[0].id,
});
});
});
@ -274,9 +282,10 @@ describe('api.blocks', () => {
const fakeId = 'WRNG_ID';
const { convert } = editor.blocks;
const exec = (): void => convert(fakeId, 'convertableTool');
expect(exec).to.throw(`Block with id "${fakeId}" not found`);
return convert(fakeId, 'convertableTool')
.catch((error) => {
expect(error.message).to.be.eq(`Block with id "${fakeId}" not found`);
});
});
});
@ -302,9 +311,10 @@ describe('api.blocks', () => {
const nonexistingToolName = 'WRNG_TOOL_NAME';
const { convert } = editor.blocks;
const exec = (): void => convert(existingBlock.id, nonexistingToolName);
expect(exec).to.throw(`Block Tool with type "${nonexistingToolName}" not found`);
return convert(existingBlock.id, nonexistingToolName)
.catch((error) => {
expect(error.message).to.be.eq(`Block Tool with type "${nonexistingToolName}" not found`);
});
});
});
@ -340,9 +350,10 @@ describe('api.blocks', () => {
*/
const { convert } = editor.blocks;
const exec = (): void => convert(existingBlock.id, 'nonConvertableTool');
expect(exec).to.throw(`Conversion from "paragraph" to "nonConvertableTool" is not possible. NonConvertableTool tool(s) should provide a "conversionConfig"`);
return convert(existingBlock.id, 'nonConvertableTool')
.catch((error) => {
expect(error.message).to.be.eq(`Conversion from "paragraph" to "nonConvertableTool" is not possible. NonConvertableTool tool(s) should provide a "conversionConfig"`);
});
});
});
});

View file

@ -0,0 +1,113 @@
import EditorJS from '../../../../types';
/**
* Test cases for Caret API
*/
describe('Caret API', () => {
const paragraphDataMock = {
id: 'bwnFX5LoX7',
type: 'paragraph',
data: {
text: 'The first block content mock.',
},
};
describe('.setToBlock()', () => {
/**
* The arrange part of the following tests are the same:
* - create an editor
* - move caret out of the block by default
*/
beforeEach(() => {
cy.createEditor({
data: {
blocks: [
paragraphDataMock,
],
},
}).as('editorInstance');
/**
* Blur caret from the block before setting via api
*/
cy.get('[data-cy=editorjs]')
.click();
});
it('should set caret to a block (and return true) if block index is passed as argument', () => {
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const returnedValue = editor.caret.setToBlock(0);
/**
* Check that caret belongs block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.first()
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
expect(returnedValue).to.be.true;
});
});
it('should set caret to a block (and return true) if block id is passed as argument', () => {
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const returnedValue = editor.caret.setToBlock(paragraphDataMock.id);
/**
* Check that caret belongs block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.first()
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
expect(returnedValue).to.be.true;
});
});
it('should set caret to a block (and return true) if Block API is passed as argument', () => {
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const block = editor.blocks.getById(paragraphDataMock.id);
const returnedValue = editor.caret.setToBlock(block);
/**
* Check that caret belongs block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.first()
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
expect(returnedValue).to.be.true;
});
});
});
});

View file

@ -1,6 +1,6 @@
describe('Slash keydown', function () {
describe('pressed in empty block', function () {
it('should open Toolbox', () => {
it('should add "/" in a block and open Toolbox', () => {
cy.createEditor({
data: {
blocks: [
@ -19,7 +19,15 @@ describe('Slash keydown', function () {
.click()
.type('/');
cy.get('[data-cy="toolbox"] .ce-popover')
/**
* Block content should contain slash
*/
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.invoke('text')
.should('eq', '/');
cy.get('[data-cy="toolbox"] .ce-popover__container')
.should('be.visible');
});
@ -46,7 +54,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 +80,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 +114,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');
});
});

View file

@ -1,3 +1,5 @@
import Header from '@editorjs/header';
describe('Inline Toolbar', () => {
it('should appear aligned with left coord of selection rect', () => {
cy.createEditor({
@ -73,4 +75,56 @@ describe('Inline Toolbar', () => {
});
});
});
describe('Conversion toolbar', () => {
it('should restore caret after converting of a block', () => {
cy.createEditor({
tools: {
header: {
class: Header,
},
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Some text',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.selectText('Some text');
cy.get('[data-cy=conversion-toggler]')
.click();
cy.get('[data-cy=editorjs]')
.find('.ce-conversion-tool[data-tool=header]')
.click();
cy.get('[data-cy=editorjs]')
.find('.ce-header')
.should('have.text', 'Some text');
cy.window()
.then((window) => {
const selection = window.getSelection();
expect(selection.rangeCount).to.be.equal(1);
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-header')
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
});
});
});

View file

@ -1,4 +1,8 @@
import { selectionChangeDebounceTimeout } from '../../../../src/components/constants';
import Header from '@editorjs/header';
import { ToolboxConfig } from '../../../../types';
import { TunesMenuConfig } from '../../../../types/tools';
describe('BlockTunes', function () {
describe('Search', () => {
@ -104,4 +108,334 @@ describe('BlockTunes', function () {
.should('have.class', 'ce-block--selected');
});
});
describe('Convert to', () => {
it('should display Convert to inside Block Tunes', () => {
cy.createEditor({
tools: {
header: Header,
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Some text',
},
},
],
},
});
/** 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 "Convert to" option is present */
cy.get('[data-cy=editorjs]')
.get('.ce-popover-item')
.contains('Convert to')
.should('exist');
/** Click "Convert to" option*/
cy.get('[data-cy=editorjs]')
.get('.ce-popover-item')
.contains('Convert to')
.click();
/** Check nected popover with "Heading" option is present */
cy.get('[data-cy=editorjs]')
.get('.ce-popover--nested [data-item-name=header]')
.should('exist');
});
it('should not display Convert to inside Block Tunes if there is nothing to convert to', () => {
/** Editor instance with single default tool */
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Some text',
},
},
],
},
});
/** 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 "Convert to" option is not present */
cy.get('[data-cy=editorjs]')
.get('.ce-popover-item')
.contains('Convert to')
.should('not.exist');
});
it('should not display tool with the same data in "Convert to" menu', () => {
/**
* Tool with several toolbox entries configured
*/
class TestTool {
/**
* Tool is convertable
*/
public static get conversionConfig(): { import: string } {
return {
import: 'text',
};
}
/**
* TestTool contains several toolbox options
*/
public static get toolbox(): ToolboxConfig {
return [
{
title: 'Title 1',
icon: 'Icon1',
data: {
level: 1,
},
},
{
title: 'Title 2',
icon: 'Icon2',
data: {
level: 2,
},
},
];
}
/**
* Tool can render itself
*/
public render(): HTMLDivElement {
const div = document.createElement('div');
div.innerText = 'Some text';
return div;
}
/**
* Tool can save it's data
*/
public save(): { text: string; level: number } {
return {
text: 'Some text',
level: 1,
};
}
}
/** Editor instance with TestTool installed and one block of TestTool type */
cy.createEditor({
tools: {
testTool: TestTool,
},
data: {
blocks: [
{
type: 'testTool',
data: {
text: 'Some text',
level: 1,
},
},
],
},
});
/** Open block tunes menu */
cy.get('[data-cy=editorjs]')
.get('.ce-block')
.click();
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
/** Open "Convert to" menu */
cy.get('[data-cy=editorjs]')
.get('.ce-popover-item')
.contains('Convert to')
.click();
/** Check TestTool option with SAME data is NOT present */
cy.get('[data-cy=editorjs]')
.get('.ce-popover--nested [data-item-name=testTool]')
.contains('Title 1')
.should('not.exist');
/** Check TestTool option with DIFFERENT data IS present */
cy.get('[data-cy=editorjs]')
.get('.ce-popover--nested [data-item-name=testTool]')
.contains('Title 2')
.should('exist');
});
it('should convert block to another type and set caret to the new block', () => {
cy.createEditor({
tools: {
header: Header,
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Some text',
},
},
],
},
});
/** Open block tunes menu */
cy.get('[data-cy=editorjs]')
.get('.cdx-block')
.click();
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
/** Click "Convert to" option*/
cy.get('[data-cy=editorjs]')
.get('.ce-popover-item')
.contains('Convert to')
.click();
/** Click "Heading" option */
cy.get('[data-cy=editorjs]')
.get('.ce-popover--nested [data-item-name=header]')
.click();
/** Check the block was converted to the second option */
cy.get('[data-cy=editorjs]')
.get('.ce-header')
.should('have.text', 'Some text');
/** Check that caret set to the end of the new block */
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-header')
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
});
});
describe('Tunes order', () => {
it('should display block specific tunes before common tunes', () => {
/**
* Tool with several toolbox entries configured
*/
class TestTool {
/**
* TestTool contains several toolbox options
*/
public static get toolbox(): ToolboxConfig {
return [
{
title: 'Title 1',
icon: 'Icon1',
data: {
level: 1,
},
},
];
}
/**
* Tool can render itself
*/
public render(): HTMLDivElement {
const div = document.createElement('div');
div.innerText = 'Some text';
return div;
}
/**
*
*/
public renderSettings(): TunesMenuConfig {
return {
icon: 'Icon',
title: 'Tune',
};
}
/**
* Tool can save it's data
*/
public save(): { text: string; level: number } {
return {
text: 'Some text',
level: 1,
};
}
}
/** Editor instance with TestTool installed and one block of TestTool type */
cy.createEditor({
tools: {
testTool: TestTool,
},
data: {
blocks: [
{
type: 'testTool',
data: {
text: 'Some text',
level: 1,
},
},
],
},
});
/** Open block tunes menu */
cy.get('[data-cy=editorjs]')
.get('.ce-block')
.click();
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
/** Check there are more than 1 tune */
cy.get('[data-cy=editorjs]')
.get('.ce-popover-item')
.should('have.length.above', 1);
/** Check the first tune is tool specific tune */
cy.get('[data-cy=editorjs]')
.get('.ce-popover-item:first-child')
.contains('Tune')
.should('exist');
});
});
});

View file

@ -4,7 +4,7 @@ import ToolMock from '../../fixtures/tools/ToolMock';
describe('Toolbox', function () {
describe('Shortcuts', function () {
it('should covert current Block to the Shortcuts\'s Block if both tools provides a "conversionConfig" ', function () {
it('should convert current Block to the Shortcuts\'s Block if both tools provides a "conversionConfig". Caret should be restored after conversion.', function () {
/**
* Mock of Tool with conversionConfig
*/
@ -54,6 +54,21 @@ describe('Toolbox', function () {
expect(blocks.length).to.eq(1);
expect(blocks[0].type).to.eq('convertableTool');
expect(blocks[0].data.text).to.eq('Some text');
/**
* Check that caret belongs to the new block after conversion
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find(`.ce-block[data-id=${blocks[0].id}]`)
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
});
});

View file

@ -1,4 +1,4 @@
import { PopoverItem } from '../../../../types/index.js';
import { PopoverItemParams } from '../../../../types/index.js';
/**
* Mock of some Block Tool
@ -26,7 +26,7 @@ class SomePlugin {
/**
* Used to display our tool in the Toolbox
*/
public static get toolbox(): PopoverItem {
public static get toolbox(): PopoverItemParams {
return {
icon: '₷',
title: 'Some tool',
@ -34,6 +34,15 @@ class SomePlugin {
onActivate: (): void => {},
};
}
/**
* Extracts data from the plugin's UI
*/
public save(): {data: string} {
return {
data: '123',
};
}
}
describe('Flipper', () => {
@ -71,15 +80,16 @@ describe('Flipper', () => {
cy.get('[data-cy=editorjs]')
.get('.cdx-some-plugin')
// Open tunes menu
.trigger('keydown', { code: 'Slash', ctrlKey: true })
.trigger('keydown', { code: 'Slash',
ctrlKey: true })
// Navigate to delete button (the second button)
.trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE })
.trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE });
/**
* Check whether we focus the Delete Tune or not
* Check whether we focus the Move Up Tune or not
*/
cy.get('[data-item-name="delete"]')
cy.get('[data-item-name="move-up"]')
.should('have.class', 'ce-popover-item--focused');
cy.get('[data-cy=editorjs]')

View file

@ -1,5 +1,6 @@
import Popover from '../../../../src/components/utils/popover';
import { PopoverItem } from '../../../../types';
import { PopoverDesktop as Popover, PopoverItemType } from '../../../../src/components/utils/popover';
import { PopoverItemParams } from '../../../../types';
import { TunesMenuConfig } from '../../../../types/tools';
/* eslint-disable @typescript-eslint/no-empty-function */
@ -14,13 +15,13 @@ describe('Popover', () => {
* Confirmation is moved to separate variable to be able to test it's callback execution.
* (Inside popover null value is set to confirmation property, so, object becomes unavailable otherwise)
*/
const confirmation = {
const confirmation: PopoverItemParams = {
icon: confirmActionIcon,
title: confirmActionTitle,
onActivate: cy.stub(),
};
const items: PopoverItem[] = [
const items: PopoverItemParams[] = [
{
icon: actionIcon,
title: actionTitle,
@ -68,7 +69,7 @@ describe('Popover', () => {
});
it('should render the items with true isActive property value as active', () => {
const items: PopoverItem[] = [
const items = [
{
icon: 'Icon',
title: 'Title',
@ -92,7 +93,7 @@ describe('Popover', () => {
});
it('should not execute item\'s onActivate callback if the item is disabled', () => {
const items: PopoverItem[] = [
const items: PopoverItemParams[] = [
{
icon: 'Icon',
title: 'Title',
@ -114,6 +115,9 @@ describe('Popover', () => {
.should('have.class', 'ce-popover-item--disabled')
.click()
.then(() => {
if (items[0].type !== PopoverItemType.Default) {
return;
}
// Check onActivate callback has never been called
expect(items[0].onActivate).to.have.not.been.called;
});
@ -121,7 +125,7 @@ describe('Popover', () => {
});
it('should close once item with closeOnActivate property set to true is activated', () => {
const items: PopoverItem[] = [
const items = [
{
icon: 'Icon',
title: 'Title',
@ -148,7 +152,7 @@ describe('Popover', () => {
});
it('should highlight as active the item with toggle property set to true once activated', () => {
const items: PopoverItem[] = [
const items = [
{
icon: 'Icon',
title: 'Title',
@ -172,7 +176,7 @@ describe('Popover', () => {
});
it('should perform radiobutton-like behavior among the items that have toggle property value set to the same string value', () => {
const items: PopoverItem[] = [
const items = [
{
icon: 'Icon 1',
title: 'Title 1',
@ -217,7 +221,7 @@ describe('Popover', () => {
});
it('should toggle item if it is the only item in toggle group', () => {
const items: PopoverItem[] = [
const items = [
{
icon: 'Icon',
title: 'Title',
@ -240,21 +244,637 @@ describe('Popover', () => {
});
});
it('should render custom html content', () => {
const customHtml = document.createElement('div');
it('should display item with custom html', () => {
/**
* Block Tune with html as return type of render() method
*/
class TestTune {
public static isTune = true;
customHtml.setAttribute('data-cy-name', 'customContent');
customHtml.innerText = 'custom html content';
const popover = new Popover({
customContent: customHtml,
items: [],
/** Tune control displayed in block tunes popover */
public render(): HTMLElement {
const button = document.createElement('button');
button.classList.add('ce-settings__button');
button.innerText = 'Tune';
return button;
}
}
/** Create editor instance */
cy.createEditor({
tools: {
testTool: TestTune,
},
tunes: [ 'testTool' ],
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Hello',
},
},
],
},
});
cy.document().then(doc => {
doc.body.append(popover.getElement());
/** Open block tunes menu */
cy.get('[data-cy=editorjs]')
.get('.cdx-block')
.click();
/* Check custom content exists in the popover */
cy.get('[data-cy-name=customContent]');
cy.get('[data-cy=editorjs]')
.get('.ce-toolbar__settings-btn')
.click();
/** Check item with custom html content is displayed */
cy.get('[data-cy=editorjs]')
.get('.ce-popover .ce-popover-item-html')
.contains('Tune')
.should('be.visible');
});
it('should support flipping between custom content items', () => {
/**
* Block Tune with html as return type of render() method
*/
class TestTune1 {
public static isTune = true;
/** Tune control displayed in block tunes popover */
public render(): HTMLElement {
const button = document.createElement('button');
button.classList.add('ce-settings__button');
button.innerText = 'Tune1';
return button;
}
}
/**
* Block Tune with html as return type of render() method
*/
class TestTune2 {
public static isTune = true;
/** Tune control displayed in block tunes popover */
public render(): HTMLElement {
const button = document.createElement('button');
button.classList.add('ce-settings__button');
button.innerText = 'Tune2';
return button;
}
}
/** Create editor instance */
cy.createEditor({
tools: {
testTool1: TestTune1,
testTool2: TestTune2,
},
tunes: ['testTool1', 'testTool2'],
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();
/** Press Tab */
// eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here
cy.get('body').tab();
/** Check the first custom html item is focused */
cy.get('[data-cy=editorjs]')
.get('.ce-popover .ce-popover-item-html .ce-settings__button')
.contains('Tune1')
.should('have.class', 'ce-popover-item--focused');
/** Press Tab */
// eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here
cy.get('body').tab();
/** Check the second custom html item is focused */
cy.get('[data-cy=editorjs]')
.get('.ce-popover .ce-popover-item-html .ce-settings__button')
.contains('Tune2')
.should('have.class', 'ce-popover-item--focused');
/** Press Tab */
// eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here
cy.get('body').tab();
/** Check that default popover item got focused */
cy.get('[data-cy=editorjs]')
.get('[data-item-name=move-up]')
.should('have.class', 'ce-popover-item--focused');
});
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');
});
it('should display default (non-separator) items without specifying type: default', () => {
/** 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 {
onActivate: (): void => {},
icon: 'Icon',
title: 'Tune',
toggle: 'key',
name: '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 displayed */
cy.get('[data-cy=editorjs]')
.get('.ce-popover__container')
.get('[data-item-name="test-item"]')
.should('be.visible');
});
it('should display separator', () => {
/** 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 [
{
onActivate: (): void => {},
icon: 'Icon',
title: 'Tune',
toggle: 'key',
name: 'test-item',
},
{
type: PopoverItemType.Separator,
},
];
}
}
/** 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 displayed */
cy.get('[data-cy=editorjs]')
.get('.ce-popover__container')
.get('[data-item-name="test-item"]')
.should('be.visible');
/** Check separator displayed */
cy.get('[data-cy=editorjs]')
.get('.ce-popover__container')
.get('.ce-popover-item-separator')
.should('be.visible');
});
it('should perform keyboard navigation between items ignoring separators', () => {
/** 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 [
{
onActivate: (): void => {},
icon: 'Icon',
title: 'Tune 1',
name: 'test-item-1',
},
{
type: PopoverItemType.Separator,
},
{
onActivate: (): void => {},
icon: 'Icon',
title: 'Tune 2',
name: 'test-item-2',
},
];
}
}
/** 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();
/** Press Tab */
// eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here
cy.get('body').tab();
/** Check first item is focused */
cy.get('[data-cy=editorjs]')
.get('.ce-popover__container')
.get('[data-item-name="test-item-1"].ce-popover-item--focused')
.should('exist');
/** Check second item is not focused */
cy.get('[data-cy=editorjs]')
.get('.ce-popover__container')
.get('[data-item-name="test-item-2"].ce-popover-item--focused')
.should('not.exist');
/** Press Tab */
// eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here
cy.get('body').tab();
/** Check first item is not focused */
cy.get('[data-cy=editorjs]')
.get('.ce-popover__container')
.get('[data-item-name="test-item-1"].ce-popover-item--focused')
.should('not.exist');
/** Check second item is focused */
cy.get('[data-cy=editorjs]')
.get('.ce-popover__container')
.get('[data-item-name="test-item-2"].ce-popover-item--focused')
.should('exist');
});
it('should perform keyboard navigation between items ignoring separators when search query is applied', () => {
/** 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 [
{
onActivate: (): void => {},
icon: 'Icon',
title: 'Tune 1',
name: 'test-item-1',
},
{
type: PopoverItemType.Separator,
},
{
onActivate: (): void => {},
icon: 'Icon',
title: 'Tune 2',
name: 'test-item-2',
},
];
}
}
/** 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 separator displayed */
cy.get('[data-cy=editorjs]')
.get('.ce-popover__container')
.get('.ce-popover-item-separator')
.should('be.visible');
/** Enter search query */
cy.get('[data-cy=editorjs]')
.get('[data-cy=block-tunes] .cdx-search-field__input')
.type('Tune');
/** Check separator not displayed */
cy.get('[data-cy=editorjs]')
.get('.ce-popover__container')
.get('.ce-popover-item-separator')
.should('not.be.visible');
/** Press Tab */
// eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here
cy.get('body').tab();
/** Check first item is focused */
cy.get('[data-cy=editorjs]')
.get('.ce-popover__container')
.get('[data-item-name="test-item-1"].ce-popover-item--focused')
.should('exist');
/** Check second item is not focused */
cy.get('[data-cy=editorjs]')
.get('.ce-popover__container')
.get('[data-item-name="test-item-2"].ce-popover-item--focused')
.should('not.exist');
/** Press Tab */
// eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here
cy.get('body').tab();
/** Check first item is not focused */
cy.get('[data-cy=editorjs]')
.get('.ce-popover__container')
.get('[data-item-name="test-item-1"].ce-popover-item--focused')
.should('not.exist');
/** Check second item is focused */
cy.get('[data-cy=editorjs]')
.get('.ce-popover__container')
.get('[data-item-name="test-item-2"].ce-popover-item--focused')
.should('exist');
});
});

View file

@ -147,5 +147,5 @@ export interface Blocks {
*
* @throws Error if conversion is not possible
*/
convert(id: string, newType: string, dataOverrides?: BlockToolData): void;
convert(id: string, newType: string, dataOverrides?: BlockToolData): Promise<BlockAPI>;
}

10
types/api/caret.d.ts vendored
View file

@ -1,3 +1,5 @@
import { BlockAPI } from "./block";
/**
* Describes Editor`s caret API
*/
@ -46,13 +48,13 @@ export interface Caret {
/**
* Sets caret to the Block by passed index
*
* @param {number} index - index of Block where to set caret
* @param {string} position - position where to set caret
* @param {number} offset - caret offset
* @param blockOrIdOrIndex - BlockAPI or Block id or Block index
* @param position - position where to set caret
* @param offset - caret offset
*
* @return {boolean}
*/
setToBlock(index: number, position?: 'end'|'start'|'default', offset?: number): boolean;
setToBlock(blockOrIdOrIndex: BlockAPI | BlockAPI['id'] | number, position?: 'end'|'start'|'default', offset?: number): boolean;
/**
* Sets caret to the Editor

View file

@ -5,4 +5,4 @@ export * from './conversion-config';
export * from './log-levels';
export * from './i18n-config';
export * from './i18n-dictionary';
export * from './popover'
export * from '../../src/components/utils/popover';

View file

@ -1,81 +0,0 @@
/**
* Common parameters for both types of popover items: with or without confirmation
*/
interface PopoverItemBase {
/**
* Displayed text
*/
title?: string;
/**
* Item icon to be appeared near a title
*/
icon?: string;
/**
* Additional displayed text
*/
secondaryLabel?: string;
/**
* True if item should be highlighted as active
*/
isActive?: boolean;
/**
* True if item should be disabled
*/
isDisabled?: boolean;
/**
* True if popover should close once item is activated
*/
closeOnActivate?: boolean;
/**
* Item name
* Used in data attributes needed for cypress tests
*/
name?: string;
/**
* Defines whether item should toggle on click.
* Can be represented as boolean value or a string key.
* In case of string, works like radio buttons group and highlights as inactive any other item that has same toggle key value.
*/
toggle?: boolean | string;
}
/**
* Represents popover item with confirmation state configuration
*/
export interface PopoverItemWithConfirmation extends PopoverItemBase {
/**
* Popover item parameters that should be applied on item activation.
* May be used to ask user for confirmation before executing popover item activation handler.
*/
confirmation: PopoverItem;
onActivate?: never;
}
/**
* Represents default popover item without confirmation state configuration
*/
export interface PopoverItemWithoutConfirmation extends PopoverItemBase {
confirmation?: never;
/**
* Popover item activation handler
*
* @param item - activated item
* @param event - event that initiated item activation
*/
onActivate: (item: PopoverItem, event?: PointerEvent) => void;
}
/**
* Represents single popover item
*/
export type PopoverItem = PopoverItemWithConfirmation | PopoverItemWithoutConfirmation

11
types/index.d.ts vendored
View file

@ -77,10 +77,15 @@ export {
Dictionary,
DictValue,
I18nConfig,
PopoverItem,
PopoverItemWithConfirmation,
PopoverItemWithoutConfirmation
} from './configs';
export {
PopoverItemParams,
PopoverItemDefaultParams,
PopoverItemWithConfirmationParams,
PopoverItemWithoutConfirmationParams
} from '../src/components/utils/popover';
export { OutputData, OutputBlockData} from './data-formats/output-data';
export { BlockId } from './data-formats/block-id';
export { BlockAPI } from './api'

View file

@ -1,6 +1,6 @@
import { ToolConfig } from './tool-config';
import { ToolConstructable, BlockToolData } from './index';
import { PopoverItem } from '../configs';
import { PopoverItemDefaultParams, PopoverItemSeparatorParams, PopoverItemHtmlParams } from '../configs';
/**
* Tool may specify its toolbox configuration
@ -28,11 +28,10 @@ export interface ToolboxConfigEntry {
data?: BlockToolData
}
/**
* Represents single Tunes Menu item
* Represents single interactive (non-separator) Tunes Menu item
*/
export type TunesMenuConfigItem = PopoverItem & {
export type TunesMenuConfigDefaultItem = PopoverItemDefaultParams & {
/**
* Tune displayed text.
*/
@ -50,9 +49,24 @@ export type TunesMenuConfigItem = PopoverItem & {
* Menu item parameters that should be applied on item activation.
* May be used to ask user for confirmation before executing menu item activation handler.
*/
confirmation?: TunesMenuConfigItem;
confirmation?: TunesMenuConfigDefaultItem;
}
/**
* Represents single separator Tunes Menu item
*/
export type TunesMenuConfigSeparatorItem = PopoverItemSeparatorParams;
/**
* Represents single Tunes Menu item with custom HTML contect
*/
export type TunesMenuConfigHtmlItem = PopoverItemHtmlParams;
/**
* Union of all Tunes Menu item types
*/
export type TunesMenuConfigItem = TunesMenuConfigDefaultItem | TunesMenuConfigSeparatorItem | TunesMenuConfigHtmlItem;
/**
* Tool may specify its tunes configuration
* that can contain either one or multiple entries

View file

@ -1424,6 +1424,21 @@ chokidar@3.5.3:
optionalDependencies:
fsevents "~2.3.2"
chokidar@^3.5.3:
version "3.6.0"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
dependencies:
anymatch "~3.1.2"
braces "~3.0.2"
glob-parent "~5.1.2"
is-binary-path "~2.1.0"
is-glob "~4.0.1"
normalize-path "~3.0.0"
readdirp "~3.6.0"
optionalDependencies:
fsevents "~2.3.2"
ci-info@^3.2.0:
version "3.9.0"
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4"
@ -1689,6 +1704,14 @@ cypress-terminal-report@^5.3.2:
semver "^7.3.5"
tv4 "^1.3.0"
cypress-vite@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/cypress-vite/-/cypress-vite-1.5.0.tgz#471ecc1175c7ab51b3b132c595dc3c7e222fe944"
integrity sha512-vvTMqJZgI3sN2ylQTi4OQh8LRRjSrfrIdkQD5fOj+EC/e9oHkxS96lif1SyDF1PwailG1tnpJE+VpN6+AwO/rg==
dependencies:
chokidar "^3.5.3"
debug "^4.3.4"
cypress@^13.7.1:
version "13.7.1"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.7.1.tgz#d1208eb04efd46ef52a30480a5da71a03373261a"