From d275e25c330d778b1bf533deb0f74ad9bcd1d69d Mon Sep 17 00:00:00 2001 From: Ruslan Farkhutdinov Date: Wed, 18 Mar 2026 17:21:54 +0200 Subject: [PATCH 1/8] Popup: Close popup on Escape key press when focus is inside a child editor --- .../stories/popup/Popup.stories.tsx | 97 +++++++++++++++++++ apps/react-storybook/stories/popup/data.ts | 12 +++ .../js/__internal/ui/popup/m_popup.ts | 21 ++++ .../DevExpress.ui.widgets/popup.tests.js | 11 +++ 4 files changed, 141 insertions(+) create mode 100644 apps/react-storybook/stories/popup/Popup.stories.tsx create mode 100644 apps/react-storybook/stories/popup/data.ts 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..caaaf5450b75 --- /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, +}; \ No newline at end of file diff --git a/apps/react-storybook/stories/popup/data.ts b/apps/react-storybook/stories/popup/data.ts new file mode 100644 index 000000000000..9f05d73ad7ab --- /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 }, +]; \ No newline at end of file diff --git a/packages/devextreme/js/__internal/ui/popup/m_popup.ts b/packages/devextreme/js/__internal/ui/popup/m_popup.ts index d0401e22e2bd..d28272bc9e87 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; @@ -223,6 +226,24 @@ class Popup< }; } + _keyboardHandler(options: KeyboardKeyDownEvent, onlyChildProcessing?: boolean): void { + if (!onlyChildProcessing) { + const e = options.originalEvent; + const $target = $(e.target); + + if (this._$content && !$target.is(this._$content) + && options.keyName === ESC_KEY_NAME + && !e.isDefaultPrevented()) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.hide(); + + return; + } + } + + super._keyboardHandler(options, onlyChildProcessing); + } + _getDefaultOptions(): TProperties { return { ...super._getDefaultOptions(), 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..eb3bb5f021c3 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,17 @@ 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.module('rendering', { From ad71ff2f89e5c1e6d809337b165acbece08b440e Mon Sep 17 00:00:00 2001 From: Ruslan Farkhutdinov Date: Wed, 18 Mar 2026 17:39:27 +0200 Subject: [PATCH 2/8] Popup: Add unit test to ensure nested editors closing correctly Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ruslan Farkhutdinov --- .../DevExpress.ui.widgets/popup.tests.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 eb3bb5f021c3..8ef1615373d6 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js @@ -2695,6 +2695,25 @@ QUnit.module('keyboard navigation', { 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.module('rendering', { From 3de341b89707c52d0336f0ad6243c9afab52eb5d Mon Sep 17 00:00:00 2001 From: Ruslan Farkhutdinov Date: Thu, 19 Mar 2026 14:08:54 +0200 Subject: [PATCH 3/8] Popup: Support _ignoreCloseOnChildEscape option --- .../js/__internal/ui/popup/m_popup.ts | 21 +++++++++++-------- .../ui/toolbar/internal/toolbar.menu.ts | 1 + .../DevExpress.ui.widgets/popup.tests.js | 11 ++++++++++ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/popup/m_popup.ts b/packages/devextreme/js/__internal/ui/popup/m_popup.ts index d28272bc9e87..730e203ea378 100644 --- a/packages/devextreme/js/__internal/ui/popup/m_popup.ts +++ b/packages/devextreme/js/__internal/ui/popup/m_popup.ts @@ -180,6 +180,8 @@ export interface PopupProperties extends Properties { useDefaultToolbarButtons?: boolean; useFlatToolbarButtons?: boolean; + + _ignoreCloseOnChildEscape?: boolean; } class Popup< @@ -227,18 +229,19 @@ class Popup< } _keyboardHandler(options: KeyboardKeyDownEvent, onlyChildProcessing?: boolean): void { - if (!onlyChildProcessing) { - const e = options.originalEvent; - const $target = $(e.target); + // 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) + if (this._$content && !$target.is(this._$content) && options.keyName === ESC_KEY_NAME - && !e.isDefaultPrevented()) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.hide(); + && !e.isDefaultPrevented() + && !_ignoreCloseOnChildEscape) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.hide(); - return; - } + return; } super._keyboardHandler(options, onlyChildProcessing); 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/popup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js index 8ef1615373d6..bbc0d48fdc80 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js @@ -2714,6 +2714,17 @@ QUnit.module('keyboard navigation', { 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 _closeOnChildEscape is false'); + }); }); QUnit.module('rendering', { From f5448c8d33c9f2fc875d54d7073c0f86c1874dcf Mon Sep 17 00:00:00 2001 From: Ruslan Farkhutdinov Date: Thu, 19 Mar 2026 14:43:06 +0200 Subject: [PATCH 4/8] Popup: Add !onlyChildProcessing check for hiding --- packages/devextreme/js/__internal/ui/popup/m_popup.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/devextreme/js/__internal/ui/popup/m_popup.ts b/packages/devextreme/js/__internal/ui/popup/m_popup.ts index 730e203ea378..715324678b09 100644 --- a/packages/devextreme/js/__internal/ui/popup/m_popup.ts +++ b/packages/devextreme/js/__internal/ui/popup/m_popup.ts @@ -237,6 +237,7 @@ class Popup< if (this._$content && !$target.is(this._$content) && options.keyName === ESC_KEY_NAME && !e.isDefaultPrevented() + && !onlyChildProcessing && !_ignoreCloseOnChildEscape) { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.hide(); From e99179e77819be86989ee255686d2639af16a41e Mon Sep 17 00:00:00 2001 From: Ruslan Farkhutdinov Date: Thu, 19 Mar 2026 15:34:14 +0200 Subject: [PATCH 5/8] Grid: Add _ignoreCloseOnChildEscape to Popup init in columnChooser --- .../grids/grid_core/column_chooser/m_column_chooser.ts | 1 + packages/devextreme/js/__internal/ui/popup/m_popup.ts | 3 --- .../testing/tests/DevExpress.ui.widgets/popup.tests.js | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) 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 715324678b09..67ec06400de5 100644 --- a/packages/devextreme/js/__internal/ui/popup/m_popup.ts +++ b/packages/devextreme/js/__internal/ui/popup/m_popup.ts @@ -237,12 +237,9 @@ class Popup< if (this._$content && !$target.is(this._$content) && options.keyName === ESC_KEY_NAME && !e.isDefaultPrevented() - && !onlyChildProcessing && !_ignoreCloseOnChildEscape) { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.hide(); - - return; } super._keyboardHandler(options, onlyChildProcessing); 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 bbc0d48fdc80..9a9f285d81e5 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js @@ -2723,7 +2723,7 @@ QUnit.module('keyboard navigation', { keyboard.keyDown('esc'); - assert.strictEqual(this.popup.option('visible'), true, 'popup remains visible when _closeOnChildEscape is false'); + assert.strictEqual(this.popup.option('visible'), true, 'popup remains visible when _ignoreCloseOnChildEscape is true'); }); }); From 002c48d8e3978bfc1a2a6fe3fca24fb11a6d4db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marker=20dao=20=C2=AE?= Date: Fri, 20 Mar 2026 12:48:32 +0100 Subject: [PATCH 6/8] refactor(popup): Add default value --- packages/devextreme/js/__internal/ui/popup/m_popup.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/devextreme/js/__internal/ui/popup/m_popup.ts b/packages/devextreme/js/__internal/ui/popup/m_popup.ts index 67ec06400de5..fac819c15fa2 100644 --- a/packages/devextreme/js/__internal/ui/popup/m_popup.ts +++ b/packages/devextreme/js/__internal/ui/popup/m_popup.ts @@ -267,6 +267,7 @@ class Popup< useDefaultToolbarButtons: false, useFlatToolbarButtons: false, autoResizeEnabled: true, + _ignoreCloseOnChildEscape: false, }; } From b345faacdc49de2eb6d13bebe3e78e23f9a50065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marker=20dao=20=C2=AE?= Date: Fri, 20 Mar 2026 13:10:29 +0100 Subject: [PATCH 7/8] refactor(minor) --- apps/react-storybook/stories/popup/Popup.stories.tsx | 2 +- apps/react-storybook/stories/popup/data.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/react-storybook/stories/popup/Popup.stories.tsx b/apps/react-storybook/stories/popup/Popup.stories.tsx index caaaf5450b75..5b3d49886967 100644 --- a/apps/react-storybook/stories/popup/Popup.stories.tsx +++ b/apps/react-storybook/stories/popup/Popup.stories.tsx @@ -94,4 +94,4 @@ const EscapeFromEditorsExample: Story['render'] = () => { export const EscapeFromEditors: Story = { name: 'Popup - Escape handling', render: EscapeFromEditorsExample, -}; \ No newline at end of file +}; diff --git a/apps/react-storybook/stories/popup/data.ts b/apps/react-storybook/stories/popup/data.ts index 9f05d73ad7ab..39df3fc385fc 100644 --- a/apps/react-storybook/stories/popup/data.ts +++ b/apps/react-storybook/stories/popup/data.ts @@ -9,4 +9,4 @@ export const products = [ { 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 }, -]; \ No newline at end of file +]; From c6649b56199ac9d3d6b4a3fcfa24d20f5307062d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marker=20dao=20=C2=AE?= Date: Fri, 20 Mar 2026 14:04:46 +0100 Subject: [PATCH 8/8] feat(tests): Add _ignoreCloseOnChildEscape to the skip --- .../tests/DevExpress.ui.widgets/optionChanged_bundled.tests.js | 1 + 1 file changed, 1 insertion(+) 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;