diff --git a/apps/react-storybook/stories/popup/Popup.stories.tsx b/apps/react-storybook/stories/popup/Popup.stories.tsx new file mode 100644 index 000000000000..5b3d49886967 --- /dev/null +++ b/apps/react-storybook/stories/popup/Popup.stories.tsx @@ -0,0 +1,97 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import React, { useCallback, useState } from 'react'; + +import { Button } from 'devextreme-react/button'; +import { DateBox } from 'devextreme-react/date-box'; +import { Popup } from 'devextreme-react/popup'; +import { SelectBox } from 'devextreme-react/select-box'; +import { TextBox } from 'devextreme-react/text-box'; +import { categories, products } from './data'; + +const meta: Meta = { + title: 'Components/Popup', + component: Popup, + parameters: { + layout: 'padded', + }, +}; + +export default meta; + +type Story = StoryObj; + +const EscapeFromEditorsExample: Story['render'] = () => { + const [visible, setVisible] = useState(false); + const [selectedProduct, setSelectedProduct] = useState(products[0]); + const [category, setCategory] = useState(categories[0]); + + const show = useCallback(() => setVisible(true), []); + const hide = useCallback(() => setVisible(false), []); + + return ( +
+

+ Open the popup and focus any editor. Press Escape - the popup + should close even when focus is inside a TextBox, SelectBox, or DateBox. +

+
+ ); +}; + +export const EscapeFromEditors: Story = { + name: 'Popup - Escape handling', + render: EscapeFromEditorsExample, +}; diff --git a/apps/react-storybook/stories/popup/data.ts b/apps/react-storybook/stories/popup/data.ts new file mode 100644 index 000000000000..39df3fc385fc --- /dev/null +++ b/apps/react-storybook/stories/popup/data.ts @@ -0,0 +1,12 @@ +export const categories = ['Video Players', 'Televisions', 'Monitors', 'Projectors']; + +export const products = [ + { id: '1_1', text: 'HD Video Player', category: 'Video Players', price: 220 }, + { id: '1_2', text: 'SuperHD Video Player', category: 'Video Players', price: 270 }, + { id: '2_1', text: 'SuperLCD 42', category: 'Televisions', price: 1200 }, + { id: '2_2', text: 'SuperLED 42', category: 'Televisions', price: 1450 }, + { id: '3_1_1', text: 'DesktopLCD 19', category: 'Monitors', price: 160 }, + { id: '3_2_1', text: 'DesktopLCD 21', category: 'Monitors', price: 170 }, + { id: '4_1', text: 'Projector Plus', category: 'Projectors', price: 550 }, + { id: '4_2', text: 'Projector PlusHD', category: 'Projectors', price: 750 }, +]; diff --git a/packages/devextreme/js/__internal/grids/grid_core/column_chooser/m_column_chooser.ts b/packages/devextreme/js/__internal/grids/grid_core/column_chooser/m_column_chooser.ts index 31f1af0109e9..f12a6a54cf19 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/column_chooser/m_column_chooser.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/column_chooser/m_column_chooser.ts @@ -174,6 +174,7 @@ export class ColumnChooserView extends ColumnsView { rtlEnabled: that.option('rtlEnabled'), container: columnChooserOptions.container, _loopFocus: true, + _ignoreCloseOnChildEscape: true, } as PopupProperties; if (!isDefined(this._popupContainer)) { diff --git a/packages/devextreme/js/__internal/ui/popup/m_popup.ts b/packages/devextreme/js/__internal/ui/popup/m_popup.ts index d0401e22e2bd..fac819c15fa2 100644 --- a/packages/devextreme/js/__internal/ui/popup/m_popup.ts +++ b/packages/devextreme/js/__internal/ui/popup/m_popup.ts @@ -45,6 +45,7 @@ import type Toolbar from '@js/ui/toolbar'; import windowUtils from '@ts/core/utils/m_window'; import type { OptionChanged } from '@ts/core/widget/types'; import type { SupportedKeys } from '@ts/core/widget/widget'; +import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; import type { GeometryOptions, OverlayActions } from '@ts/ui/overlay/overlay'; import Overlay from '@ts/ui/overlay/overlay'; import type { @@ -103,6 +104,8 @@ const HEIGHT_STRATEGIES = { flex: POPUP_CONTENT_FLEX_HEIGHT_CLASS, } as const; +const ESC_KEY_NAME = 'escape'; + type HeightStrategiesType = typeof HEIGHT_STRATEGIES[keyof typeof HEIGHT_STRATEGIES]; type TitleRenderAction = (event?: Record) => void; @@ -177,6 +180,8 @@ export interface PopupProperties extends Properties { useDefaultToolbarButtons?: boolean; useFlatToolbarButtons?: boolean; + + _ignoreCloseOnChildEscape?: boolean; } class Popup< @@ -223,6 +228,23 @@ class Popup< }; } + _keyboardHandler(options: KeyboardKeyDownEvent, onlyChildProcessing?: boolean): void { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { _ignoreCloseOnChildEscape } = this.option(); + const e = options.originalEvent; + const $target = $(e.target); + + if (this._$content && !$target.is(this._$content) + && options.keyName === ESC_KEY_NAME + && !e.isDefaultPrevented() + && !_ignoreCloseOnChildEscape) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.hide(); + } + + super._keyboardHandler(options, onlyChildProcessing); + } + _getDefaultOptions(): TProperties { return { ...super._getDefaultOptions(), @@ -245,6 +267,7 @@ class Popup< useDefaultToolbarButtons: false, useFlatToolbarButtons: false, autoResizeEnabled: true, + _ignoreCloseOnChildEscape: false, }; } diff --git a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts index 1ab03630185e..3abd4963b1cc 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts @@ -286,6 +286,7 @@ export default class DropDownMenu extends Widget { showTitle: false, fullScreen: false, ignoreChildEvents: false, + _ignoreCloseOnChildEscape: true, _fixWrapperPosition: true, }); this._popup.registerKeyHandler('space', ( diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/optionChanged_bundled.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/optionChanged_bundled.tests.js index 22f32251ce6c..07c6d48614dd 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/optionChanged_bundled.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/optionChanged_bundled.tests.js @@ -39,6 +39,7 @@ QUnit.module('OptionChanged', { name === 'templatesRenderAsynchronously' || name === 'ignoreChildEvents' || name === '_dataController' || + name === '_ignoreCloseOnChildEscape' || name === '_ignorePreventScrollEventsDeprecation' || name === '_checkParentVisibility') { return; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js index 518c8304aba8..9a9f285d81e5 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js @@ -2684,6 +2684,47 @@ QUnit.module('keyboard navigation', { assert.ok(isOk, 'arrows handling should not throw an error'); }); + + QUnit.test('should be closed on escape key press when focus is on a child element', function(assert) { + this.init({ dragEnabled: false }); + + const $input = $('').appendTo(this.popup.$content()); + const keyboard = keyboardMock($input); + + keyboard.keyDown('esc'); + + assert.strictEqual(this.popup.option('visible'), false, 'popup is closed after pressing esc on a child element'); + }); + + QUnit.test('should remain visible when child element prevents default on escape key press', function(assert) { + this.init({ dragEnabled: false }); + + const $input = $('').appendTo(this.popup.$content()); + + $input.on('keydown', (e) => { + const isEscape = e.key === 'Escape' || e.which === 27; + if(isEscape) { + e.preventDefault(); + } + }); + + const keyboard = keyboardMock($input); + + keyboard.keyDown('esc'); + + assert.strictEqual(this.popup.option('visible'), true, 'popup remains visible after pressing esc on a child element that prevents default'); + }); + + QUnit.test('should remain visible when child element presses escape and _ignoreCloseOnChildEscape is true', function(assert) { + this.init({ dragEnabled: false, _ignoreCloseOnChildEscape: true }); + + const $input = $('').appendTo(this.popup.$content()); + const keyboard = keyboardMock($input); + + keyboard.keyDown('esc'); + + assert.strictEqual(this.popup.option('visible'), true, 'popup remains visible when _ignoreCloseOnChildEscape is true'); + }); }); QUnit.module('rendering', {