Skip to content

Commit 3da65f3

Browse files
r-farkhutdinovRuslan FarkhutdinovCopilotmarker dao ®
authored
Popup: Close popup on Escape key press when focus is inside a child editor (#32957)
Co-authored-by: Ruslan Farkhutdinov <ruslan.farkhutdinov@devexpress.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: marker dao ® <youdontknow@marker-dao.eth>
1 parent f8a592d commit 3da65f3

7 files changed

Lines changed: 176 additions & 0 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { Meta, StoryObj } from '@storybook/react-webpack5';
2+
import React, { useCallback, useState } from 'react';
3+
4+
import { Button } from 'devextreme-react/button';
5+
import { DateBox } from 'devextreme-react/date-box';
6+
import { Popup } from 'devextreme-react/popup';
7+
import { SelectBox } from 'devextreme-react/select-box';
8+
import { TextBox } from 'devextreme-react/text-box';
9+
import { categories, products } from './data';
10+
11+
const meta: Meta<typeof Popup> = {
12+
title: 'Components/Popup',
13+
component: Popup,
14+
parameters: {
15+
layout: 'padded',
16+
},
17+
};
18+
19+
export default meta;
20+
21+
type Story = StoryObj<typeof Popup>;
22+
23+
const EscapeFromEditorsExample: Story['render'] = () => {
24+
const [visible, setVisible] = useState(false);
25+
const [selectedProduct, setSelectedProduct] = useState(products[0]);
26+
const [category, setCategory] = useState(categories[0]);
27+
28+
const show = useCallback(() => setVisible(true), []);
29+
const hide = useCallback(() => setVisible(false), []);
30+
31+
return (
32+
<div style={{ padding: 24 }}>
33+
<p style={{ marginBottom: 16, color: '#555' }}>
34+
Open the popup and focus any editor. Press Escape - the popup
35+
should close even when focus is inside a TextBox, SelectBox, or DateBox.
36+
</p>
37+
<Button text="Open Popup" type="default" onClick={show} />
38+
39+
<Popup
40+
visible={visible}
41+
onHiding={hide}
42+
title="Edit Product"
43+
width={420}
44+
height="auto"
45+
hideOnOutsideClick
46+
showCloseButton
47+
toolbarItems={[
48+
{
49+
widget: 'dxButton',
50+
toolbar: 'bottom',
51+
location: 'after',
52+
options: {
53+
text: 'Save',
54+
type: 'default',
55+
stylingMode: 'contained',
56+
onClick: hide,
57+
},
58+
},
59+
{
60+
widget: 'dxButton',
61+
toolbar: 'bottom',
62+
location: 'after',
63+
options: {
64+
text: 'Cancel',
65+
stylingMode: 'outlined',
66+
onClick: hide,
67+
},
68+
},
69+
]}
70+
>
71+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: '8px 0' }}>
72+
<TextBox
73+
label="Product"
74+
value={selectedProduct.text}
75+
onValueChanged={(e) => setSelectedProduct({ ...selectedProduct, text: e.value })}
76+
/>
77+
<SelectBox
78+
label="Category"
79+
items={categories}
80+
value={category}
81+
onValueChanged={(e) => setCategory(e.value)}
82+
/>
83+
<DateBox
84+
label="Available From"
85+
type="date"
86+
defaultValue={new Date(2024, 0, 1)}
87+
/>
88+
</div>
89+
</Popup>
90+
</div>
91+
);
92+
};
93+
94+
export const EscapeFromEditors: Story = {
95+
name: 'Popup - Escape handling',
96+
render: EscapeFromEditorsExample,
97+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const categories = ['Video Players', 'Televisions', 'Monitors', 'Projectors'];
2+
3+
export const products = [
4+
{ id: '1_1', text: 'HD Video Player', category: 'Video Players', price: 220 },
5+
{ id: '1_2', text: 'SuperHD Video Player', category: 'Video Players', price: 270 },
6+
{ id: '2_1', text: 'SuperLCD 42', category: 'Televisions', price: 1200 },
7+
{ id: '2_2', text: 'SuperLED 42', category: 'Televisions', price: 1450 },
8+
{ id: '3_1_1', text: 'DesktopLCD 19', category: 'Monitors', price: 160 },
9+
{ id: '3_2_1', text: 'DesktopLCD 21', category: 'Monitors', price: 170 },
10+
{ id: '4_1', text: 'Projector Plus', category: 'Projectors', price: 550 },
11+
{ id: '4_2', text: 'Projector PlusHD', category: 'Projectors', price: 750 },
12+
];

packages/devextreme/js/__internal/grids/grid_core/column_chooser/m_column_chooser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ export class ColumnChooserView extends ColumnsView {
174174
rtlEnabled: that.option('rtlEnabled'),
175175
container: columnChooserOptions.container,
176176
_loopFocus: true,
177+
_ignoreCloseOnChildEscape: true,
177178
} as PopupProperties;
178179

179180
if (!isDefined(this._popupContainer)) {

packages/devextreme/js/__internal/ui/popup/m_popup.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import type Toolbar from '@js/ui/toolbar';
4545
import windowUtils from '@ts/core/utils/m_window';
4646
import type { OptionChanged } from '@ts/core/widget/types';
4747
import type { SupportedKeys } from '@ts/core/widget/widget';
48+
import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor';
4849
import type { GeometryOptions, OverlayActions } from '@ts/ui/overlay/overlay';
4950
import Overlay from '@ts/ui/overlay/overlay';
5051
import type {
@@ -103,6 +104,8 @@ const HEIGHT_STRATEGIES = {
103104
flex: POPUP_CONTENT_FLEX_HEIGHT_CLASS,
104105
} as const;
105106

107+
const ESC_KEY_NAME = 'escape';
108+
106109
type HeightStrategiesType = typeof HEIGHT_STRATEGIES[keyof typeof HEIGHT_STRATEGIES];
107110
type TitleRenderAction = (event?: Record<string, unknown>) => void;
108111

@@ -177,6 +180,8 @@ export interface PopupProperties extends Properties {
177180
useDefaultToolbarButtons?: boolean;
178181

179182
useFlatToolbarButtons?: boolean;
183+
184+
_ignoreCloseOnChildEscape?: boolean;
180185
}
181186

182187
class Popup<
@@ -223,6 +228,23 @@ class Popup<
223228
};
224229
}
225230

231+
_keyboardHandler(options: KeyboardKeyDownEvent, onlyChildProcessing?: boolean): void {
232+
// eslint-disable-next-line @typescript-eslint/naming-convention
233+
const { _ignoreCloseOnChildEscape } = this.option();
234+
const e = options.originalEvent;
235+
const $target = $(e.target);
236+
237+
if (this._$content && !$target.is(this._$content)
238+
&& options.keyName === ESC_KEY_NAME
239+
&& !e.isDefaultPrevented()
240+
&& !_ignoreCloseOnChildEscape) {
241+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
242+
this.hide();
243+
}
244+
245+
super._keyboardHandler(options, onlyChildProcessing);
246+
}
247+
226248
_getDefaultOptions(): TProperties {
227249
return {
228250
...super._getDefaultOptions(),
@@ -245,6 +267,7 @@ class Popup<
245267
useDefaultToolbarButtons: false,
246268
useFlatToolbarButtons: false,
247269
autoResizeEnabled: true,
270+
_ignoreCloseOnChildEscape: false,
248271
};
249272
}
250273

packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ export default class DropDownMenu extends Widget<DropDownMenuProperties> {
286286
showTitle: false,
287287
fullScreen: false,
288288
ignoreChildEvents: false,
289+
_ignoreCloseOnChildEscape: true,
289290
_fixWrapperPosition: true,
290291
});
291292
this._popup.registerKeyHandler('space', (

packages/devextreme/testing/tests/DevExpress.ui.widgets/optionChanged_bundled.tests.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ QUnit.module('OptionChanged', {
3939
name === 'templatesRenderAsynchronously' ||
4040
name === 'ignoreChildEvents' ||
4141
name === '_dataController' ||
42+
name === '_ignoreCloseOnChildEscape' ||
4243
name === '_ignorePreventScrollEventsDeprecation' ||
4344
name === '_checkParentVisibility') {
4445
return;

packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2684,6 +2684,47 @@ QUnit.module('keyboard navigation', {
26842684

26852685
assert.ok(isOk, 'arrows handling should not throw an error');
26862686
});
2687+
2688+
QUnit.test('should be closed on escape key press when focus is on a child element', function(assert) {
2689+
this.init({ dragEnabled: false });
2690+
2691+
const $input = $('<input>').appendTo(this.popup.$content());
2692+
const keyboard = keyboardMock($input);
2693+
2694+
keyboard.keyDown('esc');
2695+
2696+
assert.strictEqual(this.popup.option('visible'), false, 'popup is closed after pressing esc on a child element');
2697+
});
2698+
2699+
QUnit.test('should remain visible when child element prevents default on escape key press', function(assert) {
2700+
this.init({ dragEnabled: false });
2701+
2702+
const $input = $('<input>').appendTo(this.popup.$content());
2703+
2704+
$input.on('keydown', (e) => {
2705+
const isEscape = e.key === 'Escape' || e.which === 27;
2706+
if(isEscape) {
2707+
e.preventDefault();
2708+
}
2709+
});
2710+
2711+
const keyboard = keyboardMock($input);
2712+
2713+
keyboard.keyDown('esc');
2714+
2715+
assert.strictEqual(this.popup.option('visible'), true, 'popup remains visible after pressing esc on a child element that prevents default');
2716+
});
2717+
2718+
QUnit.test('should remain visible when child element presses escape and _ignoreCloseOnChildEscape is true', function(assert) {
2719+
this.init({ dragEnabled: false, _ignoreCloseOnChildEscape: true });
2720+
2721+
const $input = $('<input>').appendTo(this.popup.$content());
2722+
const keyboard = keyboardMock($input);
2723+
2724+
keyboard.keyDown('esc');
2725+
2726+
assert.strictEqual(this.popup.option('visible'), true, 'popup remains visible when _ignoreCloseOnChildEscape is true');
2727+
});
26872728
});
26882729

26892730
QUnit.module('rendering', {

0 commit comments

Comments
 (0)