Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lookup: Loop focus in dropdown overlay #28136

Merged
merged 27 commits into from
Oct 15, 2024
Merged
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
68 changes: 47 additions & 21 deletions packages/devextreme/js/__internal/ui/m_lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ const Lookup = DropDownList.inherit({
return getSize('height');
},
shading: true,
hideOnOutsideClick: false,
hideOnOutsideClick: true,
position: undefined,
animation: {},
title: '',
Expand Down Expand Up @@ -192,7 +192,6 @@ const Lookup = DropDownList.inherit({
dropDownCentered: true,
_scrollToSelectedItemEnabled: true,
dropDownOptions: {
hideOnOutsideClick: true,
_ignoreFunctionValueDeprecation: true,

width: () => getElementWidth(this.$element()),
Expand Down Expand Up @@ -542,7 +541,13 @@ const Lookup = DropDownList.inherit({
return 'auto';
},

_popupTabHandler: noop,
_popupTabHandler(e) {
const shouldLoopFocusInsidePopup = this._shouldLoopFocusInsidePopup();

if (!shouldLoopFocusInsidePopup) {
this.callBase(e);
}
},

_renderPopup() {
if (this.option('usePopover') && !this.option('dropDownOptions.fullScreen')) {
Expand All @@ -561,8 +566,10 @@ const Lookup = DropDownList.inherit({
},

_renderPopover() {
this._popup = this._createComponent(this._$popup, Popover, extend(
this._popupConfig(),
const popupConfig = this._popupConfig();

const options = extend(
popupConfig,
this._options.cache('dropDownOptions'),
{
showEvent: null,
Expand All @@ -573,10 +580,12 @@ const Lookup = DropDownList.inherit({
hideOnParentScroll: true,
_fixWrapperPosition: false,
width: this._isInitialOptionValue('dropDownOptions.width')
? function () { return getOuterWidth(this.$element()); }.bind(this)
: this._popupConfig().width,
? () => getOuterWidth(this.$element())
: popupConfig.width,
ksercs marked this conversation as resolved.
Show resolved Hide resolved
},
));
);

this._popup = this._createComponent(this._$popup, Popover, options);

this._popup.$overlayContent().attr('role', 'dialog');

Expand All @@ -588,10 +597,11 @@ const Lookup = DropDownList.inherit({
contentReady: this._contentReadyHandler.bind(this),
});

if (this.option('_scrollToSelectedItemEnabled')) this._popup._$arrow.remove();
if (this.option('_scrollToSelectedItemEnabled')) {
this._popup._$arrow.remove();
}

this._setPopupContentId(this._popup.$content());

this._contentReadyHandler();
},

Expand All @@ -610,23 +620,38 @@ const Lookup = DropDownList.inherit({

_preventFocusOnPopup: noop,

_shouldLoopFocusInsidePopup(): boolean {
const {
usePopover,
dropDownCentered,
// eslint-disable-next-line @typescript-eslint/naming-convention
_scrollToSelectedItemEnabled,
} = this.option();

const result: boolean = _scrollToSelectedItemEnabled
? dropDownCentered
: !usePopover;

return result;
},

_popupConfig() {
const result = extend(this.callBase(), {
const { dropDownOptions } = this.option();
const shouldLoopFocusInsidePopup = this._shouldLoopFocusInsidePopup();

const result = extend(this.callBase(), {
toolbarItems: this._getPopupToolbarItems(),

hideOnParentScroll: false,
onPositioned: null,

maxHeight: '100vh',

showTitle: this.option('dropDownOptions.showTitle'),
title: this.option('dropDownOptions.title'),
showTitle: dropDownOptions.showTitle,
title: dropDownOptions.title,
titleTemplate: this._getTemplateByOption('dropDownOptions.titleTemplate'),
onTitleRendered: this.option('dropDownOptions.onTitleRendered'),
fullScreen: this.option('dropDownOptions.fullScreen'),
shading: this.option('dropDownOptions.shading'),
hideOnOutsideClick: this.option('dropDownOptions.hideOnOutsideClick') || this.option('dropDownOptions.closeOnOutsideClick'),
onTitleRendered: dropDownOptions.onTitleRendered,
fullScreen: dropDownOptions.fullScreen,
shading: dropDownOptions.shading,
hideOnOutsideClick: dropDownOptions.hideOnOutsideClick || dropDownOptions.closeOnOutsideClick,
_loopFocus: shouldLoopFocusInsidePopup,
});

delete result.animation;
Expand All @@ -647,7 +672,8 @@ const Lookup = DropDownList.inherit({
}

each(['position', 'animation', 'width', 'height'], (_, optionName) => {
const popupOptionValue = this.option(`dropDownOptions.${optionName}`);
const popupOptionValue = dropDownOptions[optionName];

if (popupOptionValue !== undefined) {
result[optionName] = popupOptionValue;
}
Expand Down
8 changes: 7 additions & 1 deletion packages/devextreme/js/__internal/ui/overlay/m_overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ const Overlay: typeof OverlayInstance = Widget.inherit({
_checkParentVisibility: true,
_hideOnParentScrollTarget: undefined,
_fixWrapperPosition: false,
_loopFocus: false,
});
},

Expand Down Expand Up @@ -684,8 +685,12 @@ const Overlay: typeof OverlayInstance = Widget.inherit({
},

_toggleTabTerminator(enabled) {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { _loopFocus } = this.option();

const eventName = addNamespace('keydown', this.NAME);
if (enabled) {

if (_loopFocus || enabled) {
eventsEngine.on(domAdapter.getDocument(), eventName, this._proxiedTabTerminatorHandler);
} else {
eventsEngine.off(domAdapter.getDocument(), eventName, this._proxiedTabTerminatorHandler);
Expand Down Expand Up @@ -1158,6 +1163,7 @@ const Overlay: typeof OverlayInstance = Widget.inherit({
switch (name) {
case 'animation':
break;
case '_loopFocus':
ksercs marked this conversation as resolved.
Show resolved Hide resolved
case 'shading':
this._toggleShading(this.option('visible'));
this._toggleSafariScrolling();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ QUnit.testStart(function() {
$('#widthRootStyle').css('width', '300px');
});

const OVERLAY_CLASS = 'dx-overlay';
const OVERLAY_SHADER_CLASS = 'dx-overlay-shader';
const OVERLAY_WRAPPER_CLASS = 'dx-overlay-wrapper';
const OVERLAY_CONTENT_CLASS = 'dx-overlay-content';
Expand All @@ -89,9 +90,10 @@ const PLACEHOLDER_CLASS = 'dx-placeholder';
const SCROLL_VIEW_LOAD_PANEL_CLASS = 'dx-scrollview-loadpanel';
const SCROLL_VIEW_CONTENT_CLASS = 'dx-scrollview-content';
const LIST_ITEMS_CLASS = 'dx-list-items';

const FOCUSED_CLASS = 'dx-state-focused';

const CANCEL_BUTTON_SELECTOR = '.dx-popup-cancel.dx-button';

const WINDOW_RATIO = 0.8;

const toSelector = function(val) {
Expand Down Expand Up @@ -1033,11 +1035,14 @@ QUnit.module('Lookup', {

openPopupWithList(firstLookup);

// NOTE: in ShadowDOM mode one selected item is inside ShadowDOM
// and other is in document
// NOTE: In ShadowDom mode, the two selected elements are inside ShadowDom.
// This happens because the overlays were closed and moved to the overlay container.
if(QUnit.isInShadowDomMode()) {
assert.strictEqual(document.querySelectorAll(`.${LIST_ITEM_SELECTED_CLASS}`).length, 1);
assert.strictEqual($('#qunit-fixture').get(0).querySelectorAll(`.${LIST_ITEM_SELECTED_CLASS}`).length, 1);
const listItemSelectedInDocument = document.querySelectorAll(`.${LIST_ITEM_SELECTED_CLASS}`);
const listItemSelectedInShadowDOM = $('#qunit-fixture').get(0).querySelectorAll(`.${LIST_ITEM_SELECTED_CLASS}`);

assert.strictEqual(listItemSelectedInDocument.length, 0);
assert.strictEqual(listItemSelectedInShadowDOM.length, 2);
} else {
assert.strictEqual($(`.${LIST_ITEM_SELECTED_CLASS}`).length, 2);
}
Expand Down Expand Up @@ -2156,7 +2161,7 @@ QUnit.module('popup options', {
assert.ok(!$wrapper.hasClass(OVERLAY_SHADER_CLASS));
});

QUnit.test('popup should not be hidden after outsideClick', function(assert) {
QUnit.test('popup should be hidden after outsideClick', function(assert) {
const $lookup = $('#lookupOptions');
const instance = $lookup.dxLookup({
dataSource: [1, 2, 3]
Expand All @@ -2167,13 +2172,13 @@ QUnit.module('popup options', {
const $overlay = $(toSelector(OVERLAY_CONTENT_CLASS)).eq(0);

$(document).trigger('dxpointerdown');
assert.equal($overlay.is(':visible'), true, 'overlay is not hidden');
assert.equal($overlay.is(':visible'), false, 'overlay is hidden');
});

QUnit.test('lookup popup should be hidden after click outside was present', function(assert) {
QUnit.test('lookup popup should not be hidden after click outside was present if dropDownOptions.hideOnOutsideClick is set to false', function(assert) {
const $lookup = $('#lookupOptions');
const instance = $lookup.dxLookup({
'dropDownOptions.hideOnOutsideClick': true,
'dropDownOptions.hideOnOutsideClick': false,
visible: true,
usePopover: false
}).dxLookup('instance');
Expand All @@ -2186,7 +2191,37 @@ QUnit.module('popup options', {
assert.equal($overlay.is(':visible'), true, 'overlay is not hidden');

$(document).trigger('dxpointerdown');
assert.equal($overlay.is(':visible'), false, 'overlay is hidden');
assert.equal($overlay.is(':visible'), true, 'overlay is not hidden');
});

[true, false].forEach(dropDownCentered => {
[true, false].forEach(_scrollToSelectedItemEnabled => {
[true, false].forEach(usePopover => {
QUnit.test(`Popup should have correct _loopFocus option value if usePopover=${usePopover}, _scrollToSelectedItemEnabled=${_scrollToSelectedItemEnabled}, dropDownCentered=${dropDownCentered}`, function(assert) {
const instance = $('#lookupOptions').dxLookup({
_scrollToSelectedItemEnabled,
usePopover,
dropDownCentered,
}).dxLookup('instance');

openPopupWithList(instance);

const $popup = $(`.${POPUP_CLASS}`);

const popup = usePopover && !_scrollToSelectedItemEnabled
? PopoverFull.getInstance($popup)
: PopupFull.getInstance($popup);

const expectedValue = _scrollToSelectedItemEnabled
? dropDownCentered
: !usePopover;

const { _loopFocus } = popup.option();

assert.strictEqual(_loopFocus, expectedValue, `_loopFocus: ${_loopFocus} is correct`);
});
});
});
});

QUnit.test('custom titleTemplate option', function(assert) {
Expand Down Expand Up @@ -2225,7 +2260,8 @@ QUnit.module('popup options', {
QUnit.test('custom titleTemplate and onTitleRendered option is set correctly by options', function(assert) {
assert.expect(2);

const $lookup = $('#lookupOptions').dxLookup(); const instance = $lookup.dxLookup('instance');
const $lookup = $('#lookupOptions').dxLookup();
const instance = $lookup.dxLookup('instance');

instance.option('dropDownOptions.onTitleRendered', function(e) {
assert.ok(true, 'option \'onTitleRendered\' successfully passed to the popup widget raised on titleTemplate');
Expand Down Expand Up @@ -2906,6 +2942,80 @@ QUnit.module('keyboard navigation', {
assert.ok(instance._$list.find(`.${LIST_ITEM_CLASS}`).eq(1).hasClass(FOCUSED_CLASS), 'second list-item is focused after down key pressing');
});

[true, false].forEach(value => {
QUnit.test(`focus from last Popover element should ${value ? 'not' : ''} move to Lookup field while keeping Popup open when usePopover: true and _scrollToSelectedItemEnabled: ${value}`, function(assert) {
if(devices.real().deviceType !== 'desktop') {
assert.ok(true, 'test does not actual for mobile devices');
return;
}

const $element = $('#widget').dxLookup({
_scrollToSelectedItemEnabled: value,
items: [1, 2, 3],
opened: true,
focusStateEnabled: true,
});
const instance = $element.dxLookup('instance');
const tabKeyDownEvent = $.Event('keydown', { key: 'Tab' });
const $overlayContent = $(instance.content()).parent();
const $cancelButton = $overlayContent.find(CANCEL_BUTTON_SELECTOR);

$cancelButton.trigger(tabKeyDownEvent);

assert.strictEqual($element.hasClass(FOCUSED_CLASS), !value);
assert.ok(instance.option('opened'), 'popup is opened');
});
});

[true, false].forEach(value => {
QUnit.test(`focus from last Popover element should not move to Lookup field while keeping Popup open when usePopover: false and dropDownCentered: ${value}`, function(assert) {
if(devices.real().deviceType !== 'desktop') {
assert.ok(true, 'test does not actual for mobile devices');
return;
}

const $element = $('#widget').dxLookup({
dropDownCentered: value,
items: [1, 2, 3],
opened: true,
usePopover: false,
focusStateEnabled: true,
_scrollToSelectedItemEnabled: true,
});
const instance = $element.dxLookup('instance');
const tabKeyDownEvent = $.Event('keydown', { key: 'Tab' });
const $overlayContent = $(instance.content()).parent();
const $cancelButton = $overlayContent.find(CANCEL_BUTTON_SELECTOR);

$cancelButton.trigger(tabKeyDownEvent);

assert.strictEqual($element.hasClass(FOCUSED_CLASS), false);
assert.ok(instance.option('opened'), 'popup is opened');
});
});

QUnit.test('focus from first Popover element should move back to Lookup field while keeping Popup open when usePopover: true and shift+Tab is pressed', function(assert) {
if(devices.real().deviceType !== 'desktop') {
assert.ok(true, 'test does not actual for mobile devices');
return;
}

const $element = $('#widget').dxLookup({
opened: true,
items: [1, 2, 3],
focusStateEnabled: true,
});
const instance = $element.dxLookup('instance');
const shiftTabKeyDownEvent = $.Event('keydown', { key: 'Tab', shiftKey: true });
const $overlayContent = $(instance.content()).parent();
const $searchInput = $overlayContent.find(`.${LOOKUP_SEARCH_CLASS} .${TEXTEDITOR_INPUT_CLASS}`);

$searchInput.trigger(shiftTabKeyDownEvent);

assert.ok($element.hasClass(FOCUSED_CLASS), 'lookup field is focused');
assert.ok(instance.option('opened'), 'popup is opened');
});

QUnit.testInActiveWindow('lookup item should be selected after \'enter\' key pressing', function(assert) {
if(devices.real().deviceType !== 'desktop') {
assert.ok(true, 'test does not actual for mobile devices');
Expand Down
Loading
Loading