Skip to content
1 change: 1 addition & 0 deletions packages/devextreme/js/__internal/ui/lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,7 @@ class Lookup extends DropDownList<LookupProperties> {
hideOnParentScroll: true,
_fixWrapperPosition: false,
_overlayContentRole: 'dialog',
_preventDialogFocus: true,
width: this._isInitialOptionValue('dropDownOptions.width')
? (): number => getOuterWidth(this.$element()) as number
: popupConfig.width,
Expand Down
20 changes: 14 additions & 6 deletions packages/devextreme/js/__internal/ui/overlay/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -992,14 +992,22 @@ class Overlay<
const $currentElement = $elements?.eq(i) ?? null;
const $reverseElement = $elements?.eq(elementsCount - i) ?? null;

// @ts-expect-error is should can get function as callback
if (!$first && $currentElement.is(selectors.tabbable)) {
$first = $currentElement;
if (!$first) {
// @ts-expect-error is should can get function as callback
const isCurrentTabbable = $currentElement?.is(selectors.tabbable);
const isCurrentNotOverlay = $currentElement?.get(0) !== this._$content?.get(0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we include overlay selector in the previous one:

$currentElement
    .is(selectors.tabbable)
    .not(overlayId);

?

if (isCurrentTabbable && isCurrentNotOverlay) {
$first = $currentElement;
}
}

// @ts-expect-error is should can get function as callback
if (!$last && $reverseElement.is(selectors.tabbable)) {
$last = $reverseElement;
if (!$last) {
// @ts-expect-error is should can get function as callback
const isReverseTabbable = $reverseElement?.is(selectors.tabbable);
const isReverseNotOverlay = $reverseElement?.get(0) !== this._$content?.get(0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The same comment about selector

if (isReverseTabbable && isReverseNotOverlay) {
$last = $reverseElement;
}
}

if ($first && $last) {
Expand Down
48 changes: 48 additions & 0 deletions packages/devextreme/js/__internal/ui/popover/popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export interface PopoverProperties extends Omit<Properties,
_overlayContentRole?: string;

_describeTarget?: boolean;

_preventDialogFocus?: boolean;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How about _preventDialogContainerFocus?

}
class Popover<
TProperties extends PopoverProperties = PopoverProperties,
Expand Down Expand Up @@ -265,6 +267,48 @@ class Popover<
_syncAriaAttributes(): void {
this.setAria('role', this._getEffectiveAriaRole());
this._syncTargetAriaDescription();
this._syncFocusOptions();
}

_syncFocusOptions(): void {
if (this._getEffectiveAriaRole() === 'dialog' && !this.option('_preventDialogFocus')) {
this._setOptionWithoutOptionChange('focusStateEnabled', true);
this._setOptionWithoutOptionChange('tabFocusLoopEnabled', true);
}
}

// Intentional no-op: Focus target logic is inherited from Widget,
// uses in Popup and do not need here.
_renderFocusTarget(): void {}

_getFocusTarget(): dxElementWrapper | null | undefined {
const $firstFocusableTarget = this._findTabbableBounds().$first;
if ($firstFocusableTarget?.length) {
return $firstFocusableTarget;
}

return null;
}

_focusTarget(): dxElementWrapper {
return this._getFocusTarget() ?? this.$overlayContent();
}

_restoreTargetFocus(): void {
const $targets = this._getAriaDescriptionTargets();

@Raushen Raushen Jul 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can have only one target. We can apply changes from this comment.


if ($targets.length) {
// @ts-expect-error trigger should be typed on type 'EventsEngineType'
eventsEngine.trigger($targets.first(), 'focus');
}
}

_forceFocusLost(): void {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please check base method. May we can move there this logic.
Also please check what exactly base method do and why.

if (this._getEffectiveAriaRole() === 'dialog') {
this._restoreTargetFocus();
} else {
super._forceFocusLost();
}
}

_getAriaRole(): string {
Expand Down Expand Up @@ -894,6 +938,10 @@ class Popover<
}

_dispose(): void {
const { visible } = this.option();
if (visible && this._getEffectiveAriaRole() === 'dialog') {
this._restoreTargetFocus();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we check if we have focus inside?

}
this._removeTargetAriaDescription();
this._detachEscapeKeyHandler();
super._dispose();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2987,4 +2987,135 @@ QUnit.module('accessibility', {
assert.ok(instance.option('visible'), 'popover remains visible when pointer re-enters overlay before delay expires');
});
});

QUnit.module('dialog mode focus management and accessibility', {
beforeEach() {
this.clock = sinon.useFakeTimers();
this.$element = $('#what');
this.$target = $('#where');
},
afterEach() {
this.clock.restore();
}
}, () => {
QUnit.test('Popover in dialog mode should enable focusStateEnabled and tabFocusLoopEnabled on show', function(assert) {
const instance = new Popover(this.$element, {
target: this.$target,
toolbarItems: [{ text: 'OK' }],
visible: false,
});

instance.show();
this.clock.tick(0);

assert.strictEqual(instance.option('focusStateEnabled'), true, 'focusStateEnabled is enabled for dialog mode');
assert.strictEqual(instance.option('tabFocusLoopEnabled'), true, 'tabFocusLoopEnabled is enabled for dialog mode');
});

QUnit.test('Popover in dialog mode should move focus inside on show and restore focus to target when hidden', function(assert) {
this.$target.attr('tabindex', 0).focus();
assert.strictEqual(document.activeElement, this.$target.get(0), 'target is focused before show');

const instance = new Popover(this.$element, {
target: this.$target,
toolbarItems: [{ widget: 'dxButton', options: { text: 'OK' } }],
visible: false,
});

instance.show();
this.clock.tick(500);

const isFocusInside = $(document.activeElement).closest(wrapper()).length > 0;
assert.strictEqual(isFocusInside, true, 'focus moved inside popover wrapper on show');

instance.hide();
this.clock.tick(500);

assert.strictEqual(document.activeElement, this.$target.get(0), 'focus is restored to target after hide');
});

QUnit.test('Popover in dialog mode should loop focus from last to first element on tab keypress', function(assert) {
const instance = new Popover(this.$element, {
target: this.$target,
toolbarItems: [
{ widget: 'dxButton', options: { text: 'OK' } },
{ widget: 'dxButton', options: { text: 'Cancel' } }
],
visible: false,
});

instance.show();
this.clock.tick(500);

const bounds = instance._findTabbableBounds();
const firstFocusable = bounds.$first.get(0);
const lastFocusable = bounds.$last.get(0);

$(lastFocusable).focus();

const tabEvent = $.Event('keydown', { key: 'Tab' });
$(document).trigger(tabEvent);

assert.strictEqual(document.activeElement, firstFocusable, 'focus looped to the first element');
});

QUnit.test('Popover in dialog mode should loop focus from first to last element on shift+tab keypress', function(assert) {
const instance = new Popover(this.$element, {
target: this.$target,
toolbarItems: [
{ widget: 'dxButton', options: { text: 'OK', } },
{ widget: 'dxButton', options: { text: 'Cancel', } }
],
visible: false,
});

instance.show();
this.clock.tick(500);

const bounds = instance._findTabbableBounds();
const firstFocusable = bounds.$first.get(0);
const lastFocusable = bounds.$last.get(0);

$(firstFocusable).focus();

const shiftTabEvent = $.Event('keydown', { key: 'Tab', shiftKey: true });
$(document).trigger(shiftTabEvent);

assert.strictEqual(document.activeElement, lastFocusable, 'focus looped to the last element');
});

QUnit.test('Popover in dialog mode should focus first tabbable element inside content on show', function(assert) {
const instance = new Popover(this.$element, {
target: this.$target,
contentTemplate: function() {
return $('<div><input id="input1" /><input id="input2" /></div>');
},
toolbarItems: [{ text: 'OK' }],
visible: false,
});

instance.show();
this.clock.tick(500);

const $input1 = $('#input1');
assert.strictEqual(document.activeElement, $input1.get(0), 'first tabbable element is focused');
});

QUnit.test('Popover in dialog mode should restore focus to target on dispose when visible', function(assert) {
this.$target.attr('tabindex', 0).focus();

const instance = new Popover(this.$element, {
target: this.$target,
toolbarItems: [{ text: 'OK' }],
visible: false,
});

instance.show();

instance.dispose();

assert.strictEqual(document.activeElement, this.$target.get(0), 'focus is restored to target after dispose');
});
});
});

Loading