Merge branch 'next' into fix/ios-scroll

This commit is contained in:
Peter Savchenko 2024-04-27 19:23:43 +03:00
commit 905515adba
29 changed files with 1323 additions and 439 deletions

View file

@ -3,6 +3,8 @@
### 2.30.1 ### 2.30.1
`New` Block Tunes now supports nesting items `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
### 2.30.0 ### 2.30.0

View file

@ -1,6 +1,6 @@
{ {
"name": "@editorjs/editorjs", "name": "@editorjs/editorjs",
"version": "2.30.0-rc.3", "version": "2.30.0-rc.5",
"description": "Editor.js — Native JS, based on API and Open Source", "description": "Editor.js — Native JS, based on API and Open Source",
"main": "dist/editorjs.umd.js", "main": "dist/editorjs.umd.js",
"module": "dist/editorjs.mjs", "module": "dist/editorjs.mjs",

View file

@ -6,7 +6,7 @@ import {
SanitizerConfig, SanitizerConfig,
ToolConfig, ToolConfig,
ToolboxConfigEntry, ToolboxConfigEntry,
PopoverItem PopoverItemParams
} from '../../../types'; } from '../../../types';
import { SavedData } from '../../../types/data-formats'; import { SavedData } from '../../../types/data-formats';
@ -21,11 +21,11 @@ import BlockTune from '../tools/tune';
import { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; import { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
import ToolsCollection from '../tools/collection'; import ToolsCollection from '../tools/collection';
import EventsDispatcher from '../utils/events'; import EventsDispatcher from '../utils/events';
import { TunesMenuConfigItem } from '../../../types/tools'; import { TunesMenuConfig, TunesMenuConfigItem } from '../../../types/tools';
import { isMutationBelongsToElement } from '../utils/mutations'; import { isMutationBelongsToElement } from '../utils/mutations';
import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events'; import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events';
import { RedactorDomChangedPayload } from '../events/RedactorDomChanged'; import { RedactorDomChangedPayload } from '../events/RedactorDomChanged';
import { convertBlockDataToString } from '../utils/blocks'; import { convertBlockDataToString, isSameBlockData } from '../utils/blocks';
/** /**
* Interface describes Block class constructor argument * Interface describes Block class constructor argument
@ -229,7 +229,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
tunesData, tunesData,
}: BlockConstructorOptions, eventBus?: EventsDispatcher<EditorEventMap>) { }: BlockConstructorOptions, eventBus?: EventsDispatcher<EditorEventMap>) {
super(); super();
this.name = tool.name; this.name = tool.name;
this.id = id; this.id = id;
this.settings = tool.settings; this.settings = tool.settings;
@ -612,34 +611,60 @@ export default class Block extends EventsDispatcher<BlockEvents> {
/** /**
* Returns data to render in tunes menu. * Returns data to render in tunes menu.
* Splits block tunes settings into 2 groups: popover items and custom html. * Splits block tunes into 3 groups: block specific tunes, common tunes
* and custom html that is produced by combining tunes html from both previous groups
*/ */
public getTunes(): [PopoverItem[], HTMLElement] { public getTunes(): {
toolTunes: PopoverItemParams[];
commonTunes: PopoverItemParams[];
customHtmlTunes: HTMLElement
} {
const customHtmlTunesContainer = document.createElement('div'); const customHtmlTunesContainer = document.createElement('div');
const tunesItems: TunesMenuConfigItem[] = []; const commonTunesPopoverParams: TunesMenuConfigItem[] = [];
/** Tool's tunes: may be defined as return value of optional renderSettings method */ /** Tool's tunes: may be defined as return value of optional renderSettings method */
const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : []; const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : [];
/** Separate custom html from Popover items params for tool's tunes */
const {
items: toolTunesPopoverParams,
htmlElement: toolTunesHtmlElement,
} = this.getTunesDataSegregated(tunesDefinedInTool);
if (toolTunesHtmlElement !== undefined) {
customHtmlTunesContainer.appendChild(toolTunesHtmlElement);
}
/** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */ /** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */
const commonTunes = [ const commonTunes = [
...this.tunesInstances.values(), ...this.tunesInstances.values(),
...this.defaultTunesInstances.values(), ...this.defaultTunesInstances.values(),
].map(tuneInstance => tuneInstance.render()); ].map(tuneInstance => tuneInstance.render());
[tunesDefinedInTool, commonTunes].flat().forEach(rendered => { /** Separate custom html from Popover items params for common tunes */
if ($.isElement(rendered)) { commonTunes.forEach(tuneConfig => {
customHtmlTunesContainer.appendChild(rendered); const {
} else if (Array.isArray(rendered)) { items,
tunesItems.push(...rendered); htmlElement,
} else { } = this.getTunesDataSegregated(tuneConfig);
tunesItems.push(rendered);
if (htmlElement !== undefined) {
customHtmlTunesContainer.appendChild(htmlElement);
}
if (items !== undefined) {
commonTunesPopoverParams.push(...items);
} }
}); });
return [tunesItems, customHtmlTunesContainer]; return {
toolTunes: toolTunesPopoverParams,
commonTunes: commonTunesPopoverParams,
customHtmlTunes: customHtmlTunesContainer,
};
} }
/** /**
* Update current input index with selection anchor node * Update current input index with selection anchor node
*/ */
@ -711,11 +736,8 @@ export default class Block extends EventsDispatcher<BlockEvents> {
const blockData = await this.data; const blockData = await this.data;
const toolboxItems = toolboxSettings; const toolboxItems = toolboxSettings;
return toolboxItems.find((item) => { return toolboxItems?.find((item) => {
return Object.entries(item.data) return isSameBlockData(item.data, blockData);
.some(([propName, propValue]) => {
return blockData[propName] && _.equals(blockData[propName], propValue);
});
}); });
} }
@ -728,6 +750,25 @@ export default class Block extends EventsDispatcher<BlockEvents> {
return convertBlockDataToString(blockData, this.tool.conversionConfig); return convertBlockDataToString(blockData, this.tool.conversionConfig);
} }
/**
* Determines if tool's tunes settings are custom html or popover params and separates one from another by putting to different object fields
*
* @param tunes - tool's tunes config
*/
private getTunesDataSegregated(tunes: HTMLElement | TunesMenuConfig): { htmlElement?: HTMLElement; items: PopoverItemParams[] } {
const result = { } as { htmlElement?: HTMLElement; items: PopoverItemParams[] };
if ($.isElement(tunes)) {
result.htmlElement = tunes as HTMLElement;
} else if (Array.isArray(tunes)) {
result.items = tunes as PopoverItemParams[];
} else {
result.items = [ tunes ];
}
return result;
}
/** /**
* Make default Block wrappers and put Tool`s content there * Make default Block wrappers and put Tool`s content there
* *

View file

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

View file

@ -7,10 +7,13 @@ import { I18nInternalNS } from '../../i18n/namespace-internal';
import Flipper from '../../flipper'; import Flipper from '../../flipper';
import { TunesMenuConfigItem } from '../../../../types/tools'; import { TunesMenuConfigItem } from '../../../../types/tools';
import { resolveAliases } from '../../utils/resolve-aliases'; import { resolveAliases } from '../../utils/resolve-aliases';
import { type Popover, PopoverDesktop, PopoverMobile } from '../../utils/popover'; import { type Popover, PopoverDesktop, PopoverMobile, PopoverItemParams, PopoverItemDefaultParams } from '../../utils/popover';
import { PopoverEvent } from '../../utils/popover/popover.types'; import { PopoverEvent } from '../../utils/popover/popover.types';
import { isMobileScreen } from '../../utils'; import { isMobileScreen } from '../../utils';
import { EditorMobileLayoutToggled } from '../../events'; import { EditorMobileLayoutToggled } from '../../events';
import * as _ from '../../utils';
import { IconReplace } from '@codexteam/icons';
import { isSameBlockData } from '../../utils/blocks';
/** /**
* HTML Elements that used for BlockSettings * HTML Elements that used for BlockSettings
@ -105,7 +108,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
* *
* @param targetBlock - near which Block we should open BlockSettings * @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; this.opened = true;
/** /**
@ -120,10 +123,8 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
this.Editor.BlockSelection.selectBlock(targetBlock); this.Editor.BlockSelection.selectBlock(targetBlock);
this.Editor.BlockSelection.clearCache(); this.Editor.BlockSelection.clearCache();
/** /** Get tool's settings data */
* Fill Tool's settings const { toolTunes, commonTunes, customHtmlTunes } = targetBlock.getTunes();
*/
const [tunesItems, customHtmlTunesContainer] = targetBlock.getTunes();
/** Tell to subscribers that block settings is opened */ /** Tell to subscribers that block settings is opened */
this.eventsDispatcher.emit(this.events.opened); this.eventsDispatcher.emit(this.events.opened);
@ -132,9 +133,9 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
this.popover = new PopoverClass({ this.popover = new PopoverClass({
searchable: true, searchable: true,
items: tunesItems.map(tune => this.resolveTuneAliases(tune)), items: await this.getTunesItems(targetBlock, commonTunes, toolTunes),
customContent: customHtmlTunesContainer, customContent: customHtmlTunes,
customContentFlippableItems: this.getControls(customHtmlTunesContainer), customContentFlippableItems: this.getControls(customHtmlTunes),
scopeElement: this.Editor.API.methods.ui.nodes.redactor, scopeElement: this.Editor.API.methods.ui.nodes.redactor,
messages: { messages: {
nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'), nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'),
@ -197,6 +198,117 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
} }
}; };
/**
* 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: '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: '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: () => {
const { BlockManager, BlockSelection, Caret } = this.Editor;
BlockManager.convert(this.Editor.BlockManager.currentBlock, toolName, toolboxItem.data);
BlockSelection.clearSelection();
this.close();
window.requestAnimationFrame(() => {
Caret.setToBlock(this.Editor.BlockManager.currentBlock, Caret.positions.END);
});
},
});
});
});
return resultItems;
}
/** /**
* Handles popover close event * Handles popover close event
*/ */
@ -224,7 +336,10 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
* *
* @param item - item with resolved aliases * @param item - item with resolved aliases
*/ */
private resolveTuneAliases(item: TunesMenuConfigItem): TunesMenuConfigItem { private resolveTuneAliases(item: TunesMenuConfigItem): PopoverItemParams {
if (item.type === 'separator') {
return item;
}
const result = resolveAliases(item, { label: 'title' }); const result = resolveAliases(item, { label: 'title' });
if (item.confirmation) { if (item.confirmation) {

View file

@ -13,7 +13,7 @@ const MODIFIER_DELIMITER = '--';
* @param modifier - modifier to be appended * @param modifier - modifier to be appended
*/ */
export function bem(blockName: string) { export function bem(blockName: string) {
return (elementName?: string, modifier?: string) => { return (elementName?: string | null, modifier?: string) => {
const className = [blockName, elementName] const className = [blockName, elementName]
.filter(x => !!x) .filter(x => !!x)
.join(ELEMENT_DELIMITER); .join(ELEMENT_DELIMITER);

View file

@ -1,7 +1,8 @@
import type { ConversionConfig } from '../../../types/configs/conversion-config'; import type { ConversionConfig } from '../../../types/configs/conversion-config';
import type { BlockToolData } from '../../../types/tools/block-tool-data'; import type { BlockToolData } from '../../../types/tools/block-tool-data';
import type Block from '../block'; 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. * 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); 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. * Check if two blocks could be merged.
* *

View file

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

View file

@ -1,2 +1,12 @@
export * from './popover-item'; import { PopoverItemDefault } from './popover-item-default/popover-item-default';
export * from './popover-item.const'; 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

@ -1,4 +1,4 @@
import { bem } from '../../../bem'; import { bem } from '../../../../bem';
/** /**
* Popover item block CSS class constructor * Popover item block CSS class constructor

View file

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

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

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

View file

@ -1,7 +1,24 @@
/** /**
* Common parameters for both types of popover items: with or without confirmation * Represents popover item separator.
* Special item type that is used to separate items in the popover.
*/ */
interface PopoverItemBase { export interface PopoverItemSeparatorParams {
/**
* Item type
*/
type: 'separator'
}
/**
* Common parameters for all kinds of default popover items: with or without confirmation
*/
interface PopoverItemDefaultBaseParams {
/**
* Item type
*/
type?: 'default';
/** /**
* Displayed text * Displayed text
*/ */
@ -39,8 +56,8 @@ interface PopoverItemBase {
name?: string; name?: string;
/** /**
* Defines whether item should toggle on click. * Defines whether item should toggle on click.
* Can be represented as boolean value or a string key. * 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. * 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; toggle?: boolean | string;
@ -49,12 +66,12 @@ interface PopoverItemBase {
/** /**
* Represents popover item with confirmation state configuration * Represents popover item with confirmation state configuration
*/ */
export interface PopoverItemWithConfirmation extends PopoverItemBase { export interface PopoverItemWithConfirmationParams extends PopoverItemDefaultBaseParams {
/** /**
* Popover item parameters that should be applied on item activation. * Popover item parameters that should be applied on item activation.
* May be used to ask user for confirmation before executing popover item activation handler. * May be used to ask user for confirmation before executing popover item activation handler.
*/ */
confirmation: PopoverItem; confirmation: PopoverItemDefaultParams;
onActivate?: never; onActivate?: never;
} }
@ -62,7 +79,7 @@ export interface PopoverItemWithConfirmation extends PopoverItemBase {
/** /**
* Represents popover item without confirmation state configuration * Represents popover item without confirmation state configuration
*/ */
export interface PopoverItemWithoutConfirmation extends PopoverItemBase { export interface PopoverItemWithoutConfirmationParams extends PopoverItemDefaultBaseParams {
confirmation?: never; confirmation?: never;
/** /**
@ -71,7 +88,7 @@ export interface PopoverItemWithoutConfirmation extends PopoverItemBase {
* @param item - activated item * @param item - activated item
* @param event - event that initiated item activation * @param event - event that initiated item activation
*/ */
onActivate: (item: PopoverItem, event?: PointerEvent) => void; onActivate: (item: PopoverItemParams, event?: PointerEvent) => void;
} }
@ -79,7 +96,7 @@ export interface PopoverItemWithoutConfirmation extends PopoverItemBase {
/** /**
* Represents popover item with children (nested popover items) * Represents popover item with children (nested popover items)
*/ */
export interface PopoverItemWithChildren extends PopoverItemBase { export interface PopoverItemWithChildrenParams extends PopoverItemDefaultBaseParams {
confirmation?: never; confirmation?: never;
onActivate?: never; onActivate?: never;
@ -87,12 +104,20 @@ export interface PopoverItemWithChildren extends PopoverItemBase {
* Items of nested popover that should be open on the current item hover/click (depending on platform) * Items of nested popover that should be open on the current item hover/click (depending on platform)
*/ */
children?: { children?: {
items: PopoverItem[] items: PopoverItemParams[]
} }
} }
/**
* Default, non-separator popover item type
*/
export type PopoverItemDefaultParams =
PopoverItemWithConfirmationParams |
PopoverItemWithoutConfirmationParams |
PopoverItemWithChildrenParams;
/** /**
* Represents single popover item * Represents single popover item
*/ */
export type PopoverItem = PopoverItemWithConfirmation | PopoverItemWithoutConfirmation | PopoverItemWithChildren export type PopoverItemParams = PopoverItemDefaultParams | PopoverItemSeparatorParams;

View file

@ -1,13 +1,14 @@
import Dom from '../../../../dom'; import Dom from '../../../../dom';
import Listeners from '../../../listeners'; import Listeners from '../../../listeners';
import { IconSearch } from '@codexteam/icons'; import { IconSearch } from '@codexteam/icons';
import { SearchableItem } from './search-input.types'; import { SearchInputEvent, SearchInputEventMap, SearchableItem } from './search-input.types';
import { css } from './search-input.const'; import { css } from './search-input.const';
import EventsDispatcher from '../../../events';
/** /**
* Provides search input element and search logic * Provides search input element and search logic
*/ */
export class SearchInput { export class SearchInput extends EventsDispatcher<SearchInputEventMap> {
/** /**
* Input wrapper element * Input wrapper element
*/ */
@ -33,25 +34,19 @@ export class SearchInput {
*/ */
private searchQuery: string | undefined; private searchQuery: string | undefined;
/**
* Externally passed callback for the search
*/
private readonly onSearch: (query: string, items: SearchableItem[]) => void;
/** /**
* @param options - available config * @param options - available config
* @param options.items - searchable items list * @param options.items - searchable items list
* @param options.onSearch - search callback
* @param options.placeholder - input placeholder * @param options.placeholder - input placeholder
*/ */
constructor({ items, onSearch, placeholder }: { constructor({ items, placeholder }: {
items: SearchableItem[]; items: SearchableItem[];
onSearch: (query: string, items: SearchableItem[]) => void;
placeholder?: string; placeholder?: string;
}) { }) {
super();
this.listeners = new Listeners(); this.listeners = new Listeners();
this.items = items; this.items = items;
this.onSearch = onSearch;
/** Build ui */ /** Build ui */
this.wrapper = Dom.make('div', css.wrapper); this.wrapper = Dom.make('div', css.wrapper);
@ -76,7 +71,10 @@ export class SearchInput {
this.listeners.on(this.input, 'input', () => { this.listeners.on(this.input, 'input', () => {
this.searchQuery = this.input.value; this.searchQuery = this.input.value;
this.onSearch(this.searchQuery, this.foundItems); this.emit(SearchInputEvent.Search, {
query: this.searchQuery,
items: this.foundItems,
});
}); });
} }
@ -101,7 +99,10 @@ export class SearchInput {
this.input.value = ''; this.input.value = '';
this.searchQuery = ''; this.searchQuery = '';
this.onSearch('', this.foundItems); this.emit(SearchInputEvent.Search, {
query: '',
items: this.foundItems,
});
} }
/** /**

View file

@ -7,3 +7,24 @@ export interface SearchableItem {
*/ */
title?: string; 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,6 +1,8 @@
import { PopoverDesktop } from './popover-desktop'; import { PopoverDesktop } from './popover-desktop';
import { PopoverMobile } from './popover-mobile'; import { PopoverMobile } from './popover-mobile';
export * from './popover.types'; export * from './popover.types';
export * from './components/popover-item/popover-item.types';
/** /**
* Union type for all popovers * Union type for all popovers

View file

@ -1,10 +1,11 @@
import { PopoverItem } from './components/popover-item'; import { PopoverItem, PopoverItemDefault, PopoverItemSeparator } from './components/popover-item';
import Dom from '../../dom'; import Dom from '../../dom';
import { SearchInput, SearchableItem } from './components/search-input'; import { SearchInput, SearchInputEvent, SearchableItem } from './components/search-input';
import EventsDispatcher from '../events'; import EventsDispatcher from '../events';
import Listeners from '../listeners'; import Listeners from '../listeners';
import { PopoverEventMap, PopoverMessages, PopoverParams, PopoverEvent, PopoverNodes } from './popover.types'; import { PopoverEventMap, PopoverMessages, PopoverParams, PopoverEvent, PopoverNodes } from './popover.types';
import { css } from './popover.const'; import { css } from './popover.const';
import { PopoverItemParams } from './components/popover-item';
/** /**
* Class responsible for rendering popover and handling its behaviour * Class responsible for rendering popover and handling its behaviour
@ -13,7 +14,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
/** /**
* List of popover items * List of popover items
*/ */
protected items: PopoverItem[]; protected items: Array<PopoverItem>;
/** /**
* Listeners util instance * Listeners util instance
@ -25,10 +26,18 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
*/ */
protected nodes: Nodes; protected nodes: Nodes;
/**
* List of usual interactive popover items that can be clicked, hovered, etc.
* (excluding separators)
*/
protected get itemsInteractive(): PopoverItemDefault[] {
return this.items.filter(item => item instanceof PopoverItemDefault) as PopoverItemDefault[];
}
/** /**
* Instance of the Search Input * Instance of the Search Input
*/ */
private search: SearchInput | undefined; protected search: SearchInput | undefined;
/** /**
* Messages that will be displayed in popover * Messages that will be displayed in popover
@ -46,7 +55,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
constructor(protected readonly params: PopoverParams) { constructor(protected readonly params: PopoverParams) {
super(); super();
this.items = params.items.map(item => new PopoverItem(item)); this.items = this.buildItems(params.items);
if (params.messages) { if (params.messages) {
this.messages = { this.messages = {
@ -122,7 +131,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
this.nodes.popover.classList.remove(css.popoverOpened); this.nodes.popover.classList.remove(css.popoverOpened);
this.nodes.popover.classList.remove(css.popoverOpenTop); this.nodes.popover.classList.remove(css.popoverOpenTop);
this.items.forEach(item => item.reset()); this.itemsInteractive.forEach(item => item.reset());
if (this.search !== undefined) { if (this.search !== undefined) {
this.search.clear(); this.search.clear();
@ -139,29 +148,28 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
} }
/** /**
* Handles input inside search field * Factory method for creating popover items
* *
* @param query - search query text * @param items - list of items params
* @param result - search results
*/ */
protected onSearch = (query: string, result: SearchableItem[]): void => { protected buildItems(items: PopoverItemParams[]): Array<PopoverItem> {
this.items.forEach(item => { return items.map(item => {
const isHidden = !result.includes(item); switch (item.type) {
case 'separator':
item.toggleHidden(isHidden); return new PopoverItemSeparator();
default:
return new PopoverItemDefault(item);
}
}); });
this.toggleNothingFoundMessage(result.length === 0); }
this.toggleCustomContent(query !== '');
};
/** /**
* Retrieves popover item that is the target of the specified event * Retrieves popover item that is the target of the specified event
* *
* @param event - event to retrieve popover item from * @param event - event to retrieve popover item from
*/ */
protected getTargetItem(event: Event): PopoverItem | undefined { protected getTargetItem(event: Event): PopoverItemDefault | undefined {
return this.items.find(el => { return this.itemsInteractive.find(el => {
const itemEl = el.getElement(); const itemEl = el.getElement();
if (itemEl === null) { if (itemEl === null) {
@ -172,16 +180,44 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
}); });
} }
/**
* 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) {
/** 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);
this.toggleCustomContent(isEmptyQuery);
};
/** /**
* Adds search to the popover * Adds search to the popover
*/ */
private addSearch(): void { private addSearch(): void {
this.search = new SearchInput({ this.search = new SearchInput({
items: this.items, items: this.itemsInteractive,
placeholder: this.messages.search, placeholder: this.messages.search,
onSearch: this.onSearch,
}); });
this.search.on(SearchInputEvent.Search, this.onSearch);
const searchElement = this.search.getElement(); const searchElement = this.search.getElement();
searchElement.classList.add(css.search); searchElement.classList.add(css.search);
@ -223,7 +259,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
} }
/** Cleanup other items state */ /** Cleanup other items state */
this.items.filter(x => x !== item).forEach(x => x.reset()); this.itemsInteractive.filter(x => x !== item).forEach(x => x.reset());
item.handleClick(); item.handleClick();
@ -260,13 +296,13 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
* *
* @param clickedItem - popover item that was clicked * @param clickedItem - popover item that was clicked
*/ */
private toggleItemActivenessIfNeeded(clickedItem: PopoverItem): void { private toggleItemActivenessIfNeeded(clickedItem: PopoverItemDefault): void {
if (clickedItem.toggle === true) { if (clickedItem.toggle === true) {
clickedItem.toggleActive(); clickedItem.toggleActive();
} }
if (typeof clickedItem.toggle === 'string') { if (typeof clickedItem.toggle === 'string') {
const itemsInToggleGroup = this.items.filter(item => item.toggle === clickedItem.toggle); const itemsInToggleGroup = this.itemsInteractive.filter(item => item.toggle === clickedItem.toggle);
/** If there's only one item in toggle group, toggle it */ /** If there's only one item in toggle group, toggle it */
if (itemsInToggleGroup.length === 1) { if (itemsInToggleGroup.length === 1) {
@ -287,5 +323,5 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
* *
* @param item item to show nested popover for * @param item item to show nested popover for
*/ */
protected abstract showNestedItems(item: PopoverItem): void; protected abstract showNestedItems(item: PopoverItemDefault): void;
} }

View file

@ -4,8 +4,9 @@ import { PopoverItem, css as popoverItemCls } from './components/popover-item';
import { PopoverParams } from './popover.types'; import { PopoverParams } from './popover.types';
import { keyCodes } from '../../utils'; import { keyCodes } from '../../utils';
import { css } from './popover.const'; import { css } from './popover.const';
import { SearchableItem } from './components/search-input'; import { SearchInputEvent, SearchableItem } from './components/search-input';
import { cacheable } from '../../utils'; import { cacheable } from '../../utils';
import { PopoverItemDefault } from './components/popover-item';
/** /**
* Desktop popover. * Desktop popover.
@ -86,6 +87,8 @@ export class PopoverDesktop extends PopoverAbstract {
}); });
this.flipper.onFlip(this.onFlip); this.flipper.onFlip(this.onFlip);
this.search?.on(SearchInputEvent.Search, this.handleSearch);
} }
/** /**
@ -161,16 +164,28 @@ export class PopoverDesktop extends PopoverAbstract {
} }
/** /**
* Handles input inside search field * Handles displaying nested items for the item.
* *
* @param query - search query text * @param item item to show nested popover for
* @param result - search results
*/ */
protected override onSearch = (query: string, result: SearchableItem[]): void => { protected override showNestedItems(item: PopoverItemDefault): void {
super.onSearch(query, result); 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 */ /** List of elements available for keyboard navigation considering search query applied */
const flippableElements = query === '' ? this.flippableElements : result.map(item => (item as PopoverItem).getElement()); const flippableElements = data.query === '' ? this.flippableElements : data.items.map(item => (item as PopoverItem).getElement());
if (this.flipper.isActivated) { if (this.flipper.isActivated) {
/** Update flipper items with only visible */ /** Update flipper items with only visible */
@ -179,18 +194,6 @@ export class PopoverDesktop extends PopoverAbstract {
} }
}; };
/**
* Handles displaying nested items for the item.
*
* @param item item to show nested popover for
*/
protected override showNestedItems(item: PopoverItem): void {
if (this.nestedPopover !== null && this.nestedPopover !== undefined) {
return;
}
this.showNestedPopoverForItem(item);
}
/** /**
* Checks if popover should be opened bottom. * Checks if popover should be opened bottom.
* It should happen when there is enough space below or not enough space above * It should happen when there is enough space below or not enough space above
@ -283,7 +286,7 @@ export class PopoverDesktop extends PopoverAbstract {
* Contains both usual popover items elements and custom html content. * Contains both usual popover items elements and custom html content.
*/ */
private get flippableElements(): HTMLElement[] { private get flippableElements(): HTMLElement[] {
const popoverItemsElements = this.items.map(item => item.getElement()); const popoverItemsElements = this.itemsInteractive.map(item => item.getElement());
const customContentControlsElements = this.customContentFlippableItems || []; const customContentControlsElements = this.customContentFlippableItems || [];
/** /**
@ -296,7 +299,7 @@ export class PopoverDesktop extends PopoverAbstract {
* Called on flipper navigation * Called on flipper navigation
*/ */
private onFlip = (): void => { private onFlip = (): void => {
const focusedItem = this.items.find(item => item.isFocused); const focusedItem = this.itemsInteractive.find(item => item.isFocused);
focusedItem?.onFocus(); focusedItem?.onFocus();
}; };
@ -307,7 +310,7 @@ export class PopoverDesktop extends PopoverAbstract {
* *
* @param item - item to display nested popover by * @param item - item to display nested popover by
*/ */
private showNestedPopoverForItem(item: PopoverItem): void { private showNestedPopoverForItem(item: PopoverItemDefault): void {
this.nestedPopover = new PopoverDesktop({ this.nestedPopover = new PopoverDesktop({
items: item.children, items: item.children,
nestingLevel: this.nestingLevel + 1, nestingLevel: this.nestingLevel + 1,

View file

@ -3,8 +3,7 @@ import ScrollLocker from '../scroll-locker';
import { PopoverHeader } from './components/popover-header'; import { PopoverHeader } from './components/popover-header';
import { PopoverStatesHistory } from './utils/popover-states-history'; import { PopoverStatesHistory } from './utils/popover-states-history';
import { PopoverMobileNodes, PopoverParams } from './popover.types'; import { PopoverMobileNodes, PopoverParams } from './popover.types';
import { PopoverItem } from './components/popover-item'; import { PopoverItemDefault, PopoverItemParams } from './components/popover-item';
import { PopoverItem as PopoverItemParams } from '../../../../types';
import { css } from './popover.const'; import { css } from './popover.const';
import Dom from '../../dom'; import Dom from '../../dom';
@ -93,7 +92,7 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
* *
* @param item  item to show nested popover for * @param item  item to show nested popover for
*/ */
protected override showNestedItems(item: PopoverItem): void { protected override showNestedItems(item: PopoverItemDefault): void {
/** Show nested items */ /** Show nested items */
this.updateItemsAndHeader(item.children, item.title); this.updateItemsAndHeader(item.children, item.title);
@ -134,7 +133,7 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
/** Re-render items */ /** Re-render items */
this.items.forEach(item => item.getElement()?.remove()); this.items.forEach(item => item.getElement()?.remove());
this.items = items.map(params => new PopoverItem(params)); this.items = this.buildItems(items);
this.items.forEach(item => { this.items.forEach(item => {
const itemEl = item.getElement(); const itemEl = item.getElement();

View file

@ -1,4 +1,4 @@
import { PopoverItem as PopoverItemParams } from '../../../../types'; import { PopoverItemParams } from '../../../../types';
/** /**
* Params required to render popover * Params required to render popover

View file

@ -194,7 +194,23 @@
/** /**
* Popover item styles * Popover item styles
*/ */
.ce-popover-item {
.ce-popover-item-separator {
padding: 4px 3px;
&--hidden {
display: none;
}
&__line {
height: 1px;
background: var(--color-border);
width: 100%;
}
}
.ce-popover-item {
--border-radius: 6px; --border-radius: 6px;
border-radius: var(--border-radius); border-radius: var(--border-radius);
display: flex; display: flex;

View file

@ -1,4 +1,7 @@
import { selectionChangeDebounceTimeout } from '../../../../src/components/constants'; import { selectionChangeDebounceTimeout } from '../../../../src/components/constants';
import Header from '@editorjs/header';
import { ToolboxConfig } from '../../../../types';
describe('BlockTunes', function () { describe('BlockTunes', function () {
describe('Search', () => { describe('Search', () => {
@ -104,4 +107,185 @@ describe('BlockTunes', function () {
.should('have.class', 'ce-block--selected'); .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');
});
});
}); });

View file

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

View file

@ -1,5 +1,5 @@
import { PopoverDesktop as Popover } from '../../../../src/components/utils/popover'; import { PopoverDesktop as Popover } from '../../../../src/components/utils/popover';
import { PopoverItem } from '../../../../types'; import { PopoverItemParams } from '../../../../types';
import { TunesMenuConfig } from '../../../../types/tools'; import { TunesMenuConfig } from '../../../../types/tools';
/* eslint-disable @typescript-eslint/no-empty-function */ /* eslint-disable @typescript-eslint/no-empty-function */
@ -15,13 +15,13 @@ describe('Popover', () => {
* Confirmation is moved to separate variable to be able to test it's callback execution. * 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) * (Inside popover null value is set to confirmation property, so, object becomes unavailable otherwise)
*/ */
const confirmation = { const confirmation: PopoverItemParams = {
icon: confirmActionIcon, icon: confirmActionIcon,
title: confirmActionTitle, title: confirmActionTitle,
onActivate: cy.stub(), onActivate: cy.stub(),
}; };
const items: PopoverItem[] = [ const items: PopoverItemParams[] = [
{ {
icon: actionIcon, icon: actionIcon,
title: actionTitle, title: actionTitle,
@ -69,7 +69,7 @@ describe('Popover', () => {
}); });
it('should render the items with true isActive property value as active', () => { it('should render the items with true isActive property value as active', () => {
const items: PopoverItem[] = [ const items = [
{ {
icon: 'Icon', icon: 'Icon',
title: 'Title', title: 'Title',
@ -93,7 +93,7 @@ describe('Popover', () => {
}); });
it('should not execute item\'s onActivate callback if the item is disabled', () => { it('should not execute item\'s onActivate callback if the item is disabled', () => {
const items: PopoverItem[] = [ const items: PopoverItemParams[] = [
{ {
icon: 'Icon', icon: 'Icon',
title: 'Title', title: 'Title',
@ -115,6 +115,9 @@ describe('Popover', () => {
.should('have.class', 'ce-popover-item--disabled') .should('have.class', 'ce-popover-item--disabled')
.click() .click()
.then(() => { .then(() => {
if (items[0].type !== 'default') {
return;
}
// Check onActivate callback has never been called // Check onActivate callback has never been called
expect(items[0].onActivate).to.have.not.been.called; expect(items[0].onActivate).to.have.not.been.called;
}); });
@ -122,7 +125,7 @@ describe('Popover', () => {
}); });
it('should close once item with closeOnActivate property set to true is activated', () => { it('should close once item with closeOnActivate property set to true is activated', () => {
const items: PopoverItem[] = [ const items = [
{ {
icon: 'Icon', icon: 'Icon',
title: 'Title', title: 'Title',
@ -149,7 +152,7 @@ describe('Popover', () => {
}); });
it('should highlight as active the item with toggle property set to true once activated', () => { it('should highlight as active the item with toggle property set to true once activated', () => {
const items: PopoverItem[] = [ const items = [
{ {
icon: 'Icon', icon: 'Icon',
title: 'Title', title: 'Title',
@ -173,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', () => { 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', icon: 'Icon 1',
title: 'Title 1', title: 'Title 1',
@ -218,7 +221,7 @@ describe('Popover', () => {
}); });
it('should toggle item if it is the only item in toggle group', () => { it('should toggle item if it is the only item in toggle group', () => {
const items: PopoverItem[] = [ const items = [
{ {
icon: 'Icon', icon: 'Icon',
title: 'Title', title: 'Title',
@ -441,4 +444,312 @@ describe('Popover', () => {
.get('.ce-popover-header') .get('.ce-popover-header')
.should('not.exist'); .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 {
// @ts-expect-error type is not specified on purpose to test the back compatibility
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: '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: '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: '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

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

11
types/index.d.ts vendored
View file

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

View file

@ -1,6 +1,6 @@
import { ToolConfig } from './tool-config'; import { ToolConfig } from './tool-config';
import { ToolConstructable, BlockToolData } from './index'; import { ToolConstructable, BlockToolData } from './index';
import { PopoverItem } from '../configs'; import { PopoverItemDefaultParams, PopoverItemSeparatorParams, PopoverItemParams } from '../configs';
/** /**
* Tool may specify its toolbox configuration * Tool may specify its toolbox configuration
@ -28,11 +28,10 @@ export interface ToolboxConfigEntry {
data?: BlockToolData 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. * Tune displayed text.
*/ */
@ -50,9 +49,19 @@ export type TunesMenuConfigItem = PopoverItem & {
* Menu item parameters that should be applied on item activation. * Menu item parameters that should be applied on item activation.
* May be used to ask user for confirmation before executing menu item activation handler. * 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;
/**
* Union of all Tunes Menu item types
*/
export type TunesMenuConfigItem = TunesMenuConfigDefaultItem | TunesMenuConfigSeparatorItem;
/** /**
* Tool may specify its tunes configuration * Tool may specify its tunes configuration
* that can contain either one or multiple entries * that can contain either one or multiple entries