diff --git a/package.json b/package.json index 644616bf00..335ff3a8ff 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "@types/moment": "^2.13.0", "@types/prop-types": "^15.7.3", "@types/react": "^16.9.17", - "@types/react-close-on-escape": "^3.0.0", "@types/react-dom": "^16.9.4", "@types/react-fontawesome": "^1.6.4", "@types/react-portal": "^4.0.1", @@ -160,7 +159,6 @@ "prop-types": "^15.5.10", "raf": "^3.4.1", "react": "^16.12.0", - "react-close-on-escape": "^3.0.0", "react-codemirror2": "^7.2.1", "react-collapse": "^5.0.1", "react-datetime": "^2.8.10", diff --git a/packages/react-ui-components/package.json b/packages/react-ui-components/package.json index 5d2a5f09a1..ba12401375 100644 --- a/packages/react-ui-components/package.json +++ b/packages/react-ui-components/package.json @@ -53,7 +53,6 @@ "react-dom": "^16.0.0", "react-height": "^3.0.0", "react-motion": "^0.5.0", - "react-portal": "^4.2.0", "react-svg": "^11.1.2", "react-textarea-autosize": "^8.3.0" }, diff --git a/packages/react-ui-components/src/Dialog/DialogManager.spec.ts b/packages/react-ui-components/src/Dialog/DialogManager.spec.ts new file mode 100644 index 0000000000..01902ca0b0 --- /dev/null +++ b/packages/react-ui-components/src/Dialog/DialogManager.spec.ts @@ -0,0 +1,239 @@ +import { DialogManager, Dialog, EventRoot } from './DialogManager'; + +describe('DialogManager', () => { + describe('#register', () => { + it('adds the `dialogManager.handleKeydown` event listener to the given event root if invoked for the first time', () => { + const eventRoot: EventRoot = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + const dialogManager = new DialogManager({ eventRoot }); + const dialog: Dialog = { close: jest.fn() }; + + dialogManager.register(dialog); + + expect(eventRoot.addEventListener).toBeCalledWith( + 'keydown', + dialogManager.handleKeydown + ); + }); + + it('does not add the event listener to the given event root on subsequent calls', () => { + const eventRoot: EventRoot = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + const dialogManager = new DialogManager({ eventRoot }); + const dialog1: Dialog = { close: jest.fn() }; + const dialog2: Dialog = { close: jest.fn() }; + const dialog3: Dialog = { close: jest.fn() }; + + dialogManager.register(dialog1); + dialogManager.register(dialog2); + dialogManager.register(dialog3); + + expect(eventRoot.addEventListener).toBeCalledTimes(1); + }); + + it('re-adds the event listener to the given event root if invoked after all dialogs have been closed', () => { + const eventRoot: EventRoot = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + const dialogManager = new DialogManager({ eventRoot }); + const dialog1: Dialog = { close: jest.fn() }; + const dialog2: Dialog = { close: jest.fn() }; + const dialog3: Dialog = { close: jest.fn() }; + + // Register dialogs + dialogManager.register(dialog1); + dialogManager.register(dialog2); + dialogManager.register(dialog3); + + // Close all dialogs + dialogManager.closeLatest(); + dialogManager.closeLatest(); + dialogManager.closeLatest(); + + expect(eventRoot.addEventListener).toBeCalledTimes(1); + + // Register another dialog + dialogManager.register(dialog1); + + expect(eventRoot.addEventListener).toBeCalledTimes(2); + }); + }); + + describe('#handleKeydown', () => { + it('invokes `dialogManager.closeLatest` if the given event was an Escape-Keypress', () => { + const eventRoot: EventRoot = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + const dialogManager = Object.assign( + new DialogManager({ eventRoot }), + { closeLatest: jest.fn() } + ); + const event: KeyboardEvent = { + key: 'Escape', + } as KeyboardEvent; + + dialogManager.handleKeydown(event); + + expect(dialogManager.closeLatest).toBeCalled(); + }); + it('does not invoke `dialogManager.closeLatest` if the given event was not an Escape-Keypress', () => { + const eventRoot: EventRoot = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + const dialogManager = Object.assign( + new DialogManager({ eventRoot }), + { closeLatest: jest.fn() } + ); + + dialogManager.handleKeydown({ + key: 'A', + } as KeyboardEvent); + + expect(dialogManager.closeLatest).not.toBeCalled(); + + dialogManager.handleKeydown({ + key: 'Foo', + } as KeyboardEvent); + + expect(dialogManager.closeLatest).not.toBeCalled(); + }); + }); + + describe('#closeLatest', () => { + it('picks the latest registered dialog and invokes `dialog.close` on it', () => { + const eventRoot: EventRoot = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + const dialogManager = new DialogManager({ eventRoot }); + const dialog1: Dialog = { close: jest.fn() }; + const dialog2: Dialog = { close: jest.fn() }; + const dialog3: Dialog = { close: jest.fn() }; + + dialogManager.register(dialog1); + dialogManager.register(dialog2); + dialogManager.register(dialog3); + + dialogManager.closeLatest(); + expect(dialog1.close).not.toHaveBeenCalled(); + expect(dialog2.close).not.toHaveBeenCalled(); + expect(dialog3.close).toHaveBeenCalled(); + + dialogManager.closeLatest(); + expect(dialog1.close).not.toHaveBeenCalled(); + expect(dialog2.close).toHaveBeenCalled(); + expect(dialog3.close).toHaveBeenCalledTimes(1); + + dialogManager.closeLatest(); + expect(dialog1.close).toHaveBeenCalled(); + expect(dialog2.close).toHaveBeenCalledTimes(1); + expect(dialog3.close).toHaveBeenCalledTimes(1); + + dialogManager.closeLatest(); + expect(dialog1.close).toHaveBeenCalledTimes(1); + expect(dialog2.close).toHaveBeenCalledTimes(1); + expect(dialog3.close).toHaveBeenCalledTimes(1); + }); + + it('removes the `dialogManager.handleKeydown` event listener from the given event root once all dialogs have been closed', () => { + const eventRoot: EventRoot = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + const dialogManager = new DialogManager({ eventRoot }); + const dialog1: Dialog = { close: jest.fn() }; + const dialog2: Dialog = { close: jest.fn() }; + const dialog3: Dialog = { close: jest.fn() }; + + dialogManager.register(dialog1); + dialogManager.register(dialog2); + dialogManager.register(dialog3); + + dialogManager.closeLatest(); + dialogManager.closeLatest(); + + expect(eventRoot.removeEventListener).not.toBeCalled(); + + dialogManager.closeLatest(); + + expect(eventRoot.removeEventListener).toBeCalledWith( + 'keydown', + dialogManager.handleKeydown + ); + + dialogManager.closeLatest(); + + expect(eventRoot.removeEventListener).toBeCalledTimes(1); + }); + + it('closes a registered dialog only once, even if has been registered twice - in which case it uses order of first registration', () => { + const eventRoot: EventRoot = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + const dialogManager = new DialogManager({ eventRoot }); + const dialog1: Dialog = { close: jest.fn() }; + const dialog2: Dialog = { close: jest.fn() }; + + dialogManager.register(dialog1); + dialogManager.register(dialog2); + + // Register dialog 1 again + dialogManager.register(dialog1); + + dialogManager.closeLatest(); + expect(dialog1.close).not.toBeCalled(); + + dialogManager.closeLatest(); + expect(dialog1.close).toBeCalled(); + }); + }); + + describe('#forget', () => { + it('removes a dialog from the stack', () => { + const eventRoot: EventRoot = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + const dialogManager = new DialogManager({ eventRoot }); + const dialog1: Dialog = { close: jest.fn() }; + const dialog2: Dialog = { close: jest.fn() }; + const dialog3: Dialog = { close: jest.fn() }; + + dialogManager.register(dialog1); + dialogManager.register(dialog2); + dialogManager.register(dialog3); + + dialogManager.forget(dialog2); + + dialogManager.closeLatest(); + dialogManager.closeLatest(); + dialogManager.closeLatest(); + + expect(dialog1.close).toHaveBeenCalled(); + expect(dialog2.close).not.toHaveBeenCalled(); + expect(dialog3.close).toHaveBeenCalled(); + }); + + it('removes the `dialogManager.handleKeydown` event listener from the given event root if the last remaining dialog is removed', () => { + const eventRoot: EventRoot = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + const dialogManager = new DialogManager({ eventRoot }); + const dialog: Dialog = { close: jest.fn() }; + + dialogManager.register(dialog); + dialogManager.forget(dialog); + + expect(eventRoot.removeEventListener).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/react-ui-components/src/Dialog/DialogManager.ts b/packages/react-ui-components/src/Dialog/DialogManager.ts new file mode 100644 index 0000000000..f6304f04b6 --- /dev/null +++ b/packages/react-ui-components/src/Dialog/DialogManager.ts @@ -0,0 +1,52 @@ +export interface EventRoot { + addEventListener: Document['addEventListener']; + removeEventListener: Document['removeEventListener']; +} + +export interface Dialog { + close: () => void; +} + +export class DialogManager { + private dialogs: Dialog[] = []; + + constructor(private readonly deps: { eventRoot: EventRoot }) {} + + public register(dialog: Dialog): void { + if (this.dialogs.length === 0) { + this.deps.eventRoot.addEventListener('keydown', this.handleKeydown); + } + + if (!this.dialogs.includes(dialog)) { + this.dialogs.push(dialog); + } + } + + public forget(dialog: Dialog): void { + this.dialogs = this.dialogs.filter((d) => d !== dialog); + this.removeHandleKeydownEventListenerIfNecessary(); + } + + public readonly handleKeydown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + this.closeLatest(); + } + } + + public closeLatest(): void { + const dialog = this.dialogs.pop(); + if (dialog) { + dialog.close(); + this.removeHandleKeydownEventListenerIfNecessary(); + } + } + + private removeHandleKeydownEventListenerIfNecessary(): void { + if (this.dialogs.length === 0) { + this.deps.eventRoot.removeEventListener( + 'keydown', + this.handleKeydown + ); + } + } +} diff --git a/packages/react-ui-components/src/Dialog/__snapshots__/dialog.spec.tsx.snap b/packages/react-ui-components/src/Dialog/__snapshots__/dialog.spec.tsx.snap index 0a2fd0ecf6..f61dc84713 100644 --- a/packages/react-ui-components/src/Dialog/__snapshots__/dialog.spec.tsx.snap +++ b/packages/react-ui-components/src/Dialog/__snapshots__/dialog.spec.tsx.snap @@ -37,50 +37,49 @@ exports[` Content should render correctly. 1`] = ` `; exports[` Portal should render correctly. 1`] = ` -} > - -
+ - - Foo children - -
-
-
+ } + title="Foo title" + type="error" + > + Foo children + + + `; diff --git a/packages/react-ui-components/src/Dialog/dialog.spec.tsx b/packages/react-ui-components/src/Dialog/dialog.spec.tsx index 61162c8585..18a4629d4e 100644 --- a/packages/react-ui-components/src/Dialog/dialog.spec.tsx +++ b/packages/react-ui-components/src/Dialog/dialog.spec.tsx @@ -1,9 +1,8 @@ import {shallow} from 'enzyme'; import toJson from 'enzyme-to-json'; import React from 'react'; -import {Portal} from 'react-portal'; -import DialogWithEscape, {DialogProps, DialogWithoutEscape} from './dialog'; +import DialogWithOverlay, {DialogProps, DialogWithoutOverlay} from './dialog'; describe('', () => { const props: DialogProps = { @@ -33,43 +32,40 @@ describe('', () => { }; it('Portal should render correctly.', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(toJson(wrapper)).toMatchSnapshot(); }); it('Content should render correctly.', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(toJson(wrapper)).toMatchSnapshot(); }); it('should render the "dialog--wide" className from the "theme" prop if the style is wide.', () => { - const wrapper = shallow(); - const portal = wrapper.find(Portal); - const section = portal.find('section'); + const wrapper = shallow(); + const section = wrapper.find('section'); expect(section.prop('className')).toContain('wideClassName'); }); it('should render the "dialog--jumbo" className from the "theme" prop if the style is jumbo.', () => { - const wrapper = shallow(); - const portal = wrapper.find(Portal); - const section = portal.find('section'); + const wrapper = shallow(); + const section = wrapper.find('section'); expect(section.prop('className')).toContain('jumboClassName'); }); it('should render the "dialog--narrow" className from the "theme" prop if the style is narrow.', () => { - const wrapper = shallow(); - const portal = wrapper.find(Portal); - const section = portal.find('section'); + const wrapper = shallow(); + const section = wrapper.find('section'); expect(section.prop('className')).toContain('narrowClassName'); }); it('should render the actions if passed.', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.html().includes('Foo 1')).toBeTruthy(); expect(wrapper.html().includes('Foo 2')).toBeTruthy(); diff --git a/packages/react-ui-components/src/Dialog/dialog.tsx b/packages/react-ui-components/src/Dialog/dialog.tsx index 7f3aea0825..abf274233d 100644 --- a/packages/react-ui-components/src/Dialog/dialog.tsx +++ b/packages/react-ui-components/src/Dialog/dialog.tsx @@ -1,8 +1,7 @@ import mergeClassNames from 'classnames'; -import React, {PureComponent, ReactNode} from 'react'; -import enhanceWithClickOutside from '../enhanceWithClickOutside/index'; -import CloseOnEscape from 'react-close-on-escape'; -import {Portal} from 'react-portal'; +import React, { PureComponent, ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { Dialog, DialogManager } from './DialogManager'; type DialogType = 'success' | 'warn' | 'error'; type DialogStyle = 'wide' | 'jumbo' | 'narrow'; @@ -80,18 +79,20 @@ export interface DialogProps { readonly theme: DialogTheme; } -export class DialogWithoutEscape extends PureComponent { +const dialogManager = new DialogManager({ + eventRoot: document +}); + +export class DialogWithoutOverlay extends PureComponent { // tslint:disable-next-line:readonly-keyword private ref?: HTMLDivElement; + private dialog: Dialog = { + close: this.props.onRequestClose, + }; + public render(): JSX.Element { - const { - title, - children, - actions, - theme, - type - } = this.props; + const { title, children, actions, theme, type } = this.props; const finalClassNameBody = mergeClassNames( theme.dialog__body, @@ -104,21 +105,22 @@ export class DialogWithoutEscape extends PureComponent { ); return ( -
+
+
{title}
+
{children}
-
- {title} -
-
- {children} -
- - {actions && actions.length ? + {actions && actions.length ? (
- {React.Children.map(actions, (action, index) => {action})} -
: null - } + {React.Children.map(actions, (action, index) => ( + {action} + ))} +
+ ) : null}
); @@ -129,39 +131,22 @@ export class DialogWithoutEscape extends PureComponent { this.ref = ref; } - public readonly handleClickOutside = () => { - this.props.onRequestClose(); - } - public readonly componentDidMount = (): void => { - document.addEventListener('keydown', (event : KeyboardEvent) => this.handleKeyPress(event)); - const {autoFocus} = this.props; + const { autoFocus } = this.props; if (this.ref && autoFocus) { this.ref.focus(); } - } - public readonly componentWillUnmount = (): void => { - document.removeEventListener('keydown', (event : KeyboardEvent) => this.handleKeyPress(event)); + dialogManager.register(this.dialog); } - /** - * Closes the dialog when the escape key has been pressed. - * - * @param {KeyboardEvent} event - * @returns {void} - */ - public readonly handleKeyPress = (event : KeyboardEvent): void => { - if (event.key === 'Escape') { - this.props.onRequestClose(); - } + public readonly componentWillUnmount = (): void => { + dialogManager.forget(this.dialog); } } -const EnhancedDialogWithoutEscapeWithClickOutside = enhanceWithClickOutside(DialogWithoutEscape); - // tslint:disable-next-line:max-classes-per-file -class DialogWithEscape extends PureComponent { +class DialogWithOverlay extends PureComponent { public render(): JSX.Element | null { const { className, @@ -195,20 +180,25 @@ class DialogWithEscape extends PureComponent { return null; } - return ( - - -
- -
-
-
+ return createPortal( +
+ +
, + document.body ); } - private readonly onEscape = () => { - this.props.onRequestClose(); + private readonly handleOverlayClick = (ev: React.MouseEvent) => { + if (ev.target === ev.currentTarget) { + this.props.onRequestClose(); + } } } -export default DialogWithEscape; +export default DialogWithOverlay; diff --git a/webpack.config.js b/webpack.config.js index f09973c8e4..e464549374 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -29,7 +29,10 @@ module.exports = merge( modules: [ path.resolve(__dirname, './packages/neos-ui/node_modules'), path.resolve(__dirname, './node_modules') - ] + ], + alias: { + '@neos-project/react-ui-components$': path.resolve(__dirname, './packages/react-ui-components/src') + }, }, watchOptions: { ignored: /node_modules/ diff --git a/yarn.lock b/yarn.lock index ab10383f8e..6d5e287238 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2349,13 +2349,6 @@ resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== -"@types/react-close-on-escape@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/react-close-on-escape/-/react-close-on-escape-3.0.0.tgz#b7d368ec53e14e45dcd33ce0cf63f30d038c4600" - integrity sha512-bmo3RYe2Bbpaej8NtVkkoxiakLQJ9UsqkPgPlImPRnhGQKHGr+TeWvVawMoHMcS4vIYQNBzU8t8OVmcakRwvQQ== - dependencies: - "@types/react" "*" - "@types/react-dom@^16.9.4": version "16.9.6" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.6.tgz#9e7f83d90566521cc2083be2277c6712dcaf754c" @@ -14365,11 +14358,6 @@ react-addons-create-fragment@^15.5.3: loose-envify "^1.3.1" object-assign "^4.1.0" -react-close-on-escape@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/react-close-on-escape/-/react-close-on-escape-3.0.0.tgz#02ea1f78301cd3e3c24c944cdfde2b37456c870a" - integrity sha512-cpOkGa/FPOYbvXw1HmNNCQLTKlXEPkfA0MXBat9ui7bGRnqNNQUGTQg6fadW1F/MYGnsGrHjex77DfkqNYHjUw== - react-codemirror2@^7.2.1: version "7.2.1" resolved "https://registry.yarnpkg.com/react-codemirror2/-/react-codemirror2-7.2.1.tgz#38dab492fcbe5fb8ebf5630e5bb7922db8d3a10c"