Skip to content
Open
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 @@ -679,6 +679,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
40 changes: 34 additions & 6 deletions packages/devextreme/js/__internal/ui/overlay/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ class Overlay<

if (focusStateEnabled) {
// @ts-expect-error trigger should be typed on type 'EventsEngineType'
eventsEngine.trigger(this._focusTarget(), 'focus');
eventsEngine.trigger(this._getFocusTarget(), 'focus');
}

completeShowAnimation.call(this, element, config);
Expand Down Expand Up @@ -993,12 +993,14 @@ class Overlay<
const $reverseElement = $elements?.eq(elementsCount - i) ?? null;

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

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

Expand All @@ -1011,7 +1013,7 @@ class Overlay<
}

_tabKeyHandler(e: KeyboardEvent): void {
if (normalizeKeyName(e) !== TAB_KEY || !this._isTopOverlay()) {
if (normalizeKeyName(e) !== TAB_KEY || !this._isTopOverlay() || e.defaultPrevented) {
return;
}

Expand All @@ -1032,7 +1034,7 @@ class Overlay<
if (shouldPreventDefault) {
e.preventDefault();

const $focusElement = e.shiftKey ? $lastTabbable : $firstTabbable;
const $focusElement = (e.shiftKey ? $lastTabbable : $firstTabbable) ?? this._$content;

// @ts-expect-error trigger should be typed on type 'EventsEngineType'
eventsEngine.trigger($focusElement, 'focusin');
Expand Down Expand Up @@ -1149,6 +1151,7 @@ class Overlay<
this._appendContentToElement();

super._renderContent();
this._renderFocusTarget();
}

_isParentHidden(): boolean {
Expand Down Expand Up @@ -1446,9 +1449,34 @@ class Overlay<
});
}

_renderFocusTarget(): void {
const { tabIndex } = this.option();
const $focusTarget = this._getFocusTarget();
const $overlayContent = this._$content as dxElementWrapper;

if ($overlayContent) {
$overlayContent.attr('tabIndex', tabIndex ?? 0);
}
if ($focusTarget && $focusTarget !== $overlayContent) {
$focusTarget.attr('tabIndex', tabIndex ?? 0);
}
}

_getFocusTarget(): dxElementWrapper | null | undefined {
const $overlayContent = this._$content as dxElementWrapper;
if ($overlayContent && $overlayContent.is(':visible') && $overlayContent.css('visibility') !== 'hidden') {
const $firstFocusableTarget = this._findTabbableBounds().$first;
if ($firstFocusableTarget?.length) {
return $firstFocusableTarget;
}
}

return $overlayContent;
}

// @ts-expect-error LSP
_focusTarget(): dxElementWrapper | null | undefined {
return this._$content;
return this._getFocusTarget();
}

_attachKeyboardEvents(): void {
Expand Down
31 changes: 31 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;
}
class Popover<
TProperties extends PopoverProperties = PopoverProperties,
Expand Down Expand Up @@ -266,6 +268,31 @@ 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);
}
}

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

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

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

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

_dispose(): void {
const { visible } = this.option();
if (visible && this._getEffectiveAriaRole() === 'dialog') {
this._restoreTargetFocus();
}
this._removeTargetAriaDescription();
this._detachEscapeKeyHandler();
super._dispose();
Expand Down
2 changes: 1 addition & 1 deletion packages/devextreme/js/__internal/ui/popup/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1536,7 +1536,7 @@ class Popup<
return $wrapper
.find('[tabindex]')
// @ts-expect-error ts-error
.filter((_, item) => item.getAttribute('tabindex') >= 0);
.filter((_, item) => item.getAttribute('tabindex') >= 0 && item !== this.$overlayContent()?.get(0));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3009,4 +3009,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: [{ 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