Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions apps/react-storybook/stories/popup/Popup.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Popup> = {
title: 'Components/Popup',
component: Popup,
parameters: {
layout: 'padded',
},
};

export default meta;

type Story = StoryObj<typeof Popup>;

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 (
<div style={{ padding: 24 }}>
<p style={{ marginBottom: 16, color: '#555' }}>
Open the popup and focus any editor. Press Escape - the popup
should close even when focus is inside a TextBox, SelectBox, or DateBox.
</p>
<Button text="Open Popup" type="default" onClick={show} />

<Popup
visible={visible}
onHiding={hide}
title="Edit Product"
width={420}
height="auto"
hideOnOutsideClick
showCloseButton
toolbarItems={[
{
widget: 'dxButton',
toolbar: 'bottom',
location: 'after',
options: {
text: 'Save',
type: 'default',
stylingMode: 'contained',
onClick: hide,
},
},
{
widget: 'dxButton',
toolbar: 'bottom',
location: 'after',
options: {
text: 'Cancel',
stylingMode: 'outlined',
onClick: hide,
},
},
]}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: '8px 0' }}>
<TextBox
label="Product"
value={selectedProduct.text}
onValueChanged={(e) => setSelectedProduct({ ...selectedProduct, text: e.value })}
/>
<SelectBox
label="Category"
items={categories}
value={category}
onValueChanged={(e) => setCategory(e.value)}
/>
<DateBox
label="Available From"
type="date"
defaultValue={new Date(2024, 0, 1)}
/>
</div>
</Popup>
</div>
);
};

export const EscapeFromEditors: Story = {
name: 'Popup - Escape handling',
render: EscapeFromEditorsExample,
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one more place

12 changes: 12 additions & 0 deletions apps/react-storybook/stories/popup/data.ts
Original file line number Diff line number Diff line change
@@ -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 },
];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't forget to add empty lines where needed

Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
23 changes: 23 additions & 0 deletions packages/devextreme/js/__internal/ui/popup/m_popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>) => void;

Expand Down Expand Up @@ -177,6 +180,8 @@ export interface PopupProperties extends Properties {
useDefaultToolbarButtons?: boolean;

useFlatToolbarButtons?: boolean;

_ignoreCloseOnChildEscape?: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should set a default value for this property as well, just as we do for all the others.

Copy link
Contributor

@marker-dao marker-dao Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved 002c48d

}

class Popup<
Expand Down Expand Up @@ -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(),
Expand All @@ -245,6 +267,7 @@ class Popup<
useDefaultToolbarButtons: false,
useFlatToolbarButtons: false,
autoResizeEnabled: true,
_ignoreCloseOnChildEscape: false,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ export default class DropDownMenu extends Widget<DropDownMenuProperties> {
showTitle: false,
fullScreen: false,
ignoreChildEvents: false,
_ignoreCloseOnChildEscape: true,
_fixWrapperPosition: true,
});
this._popup.registerKeyHandler('space', (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = $('<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 = $('<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 = $('<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', {
Expand Down
Loading