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
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
56 changes: 56 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 @@ -265,6 +267,56 @@ 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);
}
}

_renderFocusTarget(): void {
if (this._getEffectiveAriaRole() !== 'dialog') {
const { tabIndex } = this.option();
// @ts-expect-error
this._focusTarget().attr('tabIndex', tabIndex);
}
}

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

const $overlayContent = this.$overlayContent();
if ($overlayContent.attr('tabindex') === undefined) {
$overlayContent.attr('tabindex', -1);
}
return $overlayContent;
}

return super._focusTarget();
}

_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 @@ -894,6 +946,10 @@ class Popover<
}

_dispose(): void {
const { visible } = this.option();
if (visible && this._getEffectiveAriaRole() === 'dialog') {
this._restoreTargetFocus();
}
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: [{ 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