diff --git a/packages/devextreme/js/__internal/ui/m_lookup.ts b/packages/devextreme/js/__internal/ui/m_lookup.ts index 7079b955b6a2..ba0968b0147e 100644 --- a/packages/devextreme/js/__internal/ui/m_lookup.ts +++ b/packages/devextreme/js/__internal/ui/m_lookup.ts @@ -108,7 +108,7 @@ const Lookup = DropDownList.inherit({ return getSize('height'); }, shading: true, - hideOnOutsideClick: false, + hideOnOutsideClick: true, position: undefined, animation: {}, title: '', @@ -192,7 +192,6 @@ const Lookup = DropDownList.inherit({ dropDownCentered: true, _scrollToSelectedItemEnabled: true, dropDownOptions: { - hideOnOutsideClick: true, _ignoreFunctionValueDeprecation: true, width: () => getElementWidth(this.$element()), @@ -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')) { @@ -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, @@ -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, }, - )); + ); + + this._popup = this._createComponent(this._$popup, Popover, options); this._popup.$overlayContent().attr('role', 'dialog'); @@ -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(); }, @@ -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; @@ -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; } diff --git a/packages/devextreme/js/__internal/ui/overlay/m_overlay.ts b/packages/devextreme/js/__internal/ui/overlay/m_overlay.ts index d806aa1bb7f8..5fdd101662d6 100644 --- a/packages/devextreme/js/__internal/ui/overlay/m_overlay.ts +++ b/packages/devextreme/js/__internal/ui/overlay/m_overlay.ts @@ -164,6 +164,7 @@ const Overlay: typeof OverlayInstance = Widget.inherit({ _checkParentVisibility: true, _hideOnParentScrollTarget: undefined, _fixWrapperPosition: false, + _loopFocus: false, }); }, @@ -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); @@ -1158,6 +1163,7 @@ const Overlay: typeof OverlayInstance = Widget.inherit({ switch (name) { case 'animation': break; + case '_loopFocus': case 'shading': this._toggleShading(this.option('visible')); this._toggleSafariScrolling(); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/lookup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/lookup.tests.js index 8cc3281d08a5..17f6fcaf6390 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/lookup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/lookup.tests.js @@ -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'; @@ -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) { @@ -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); } @@ -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] @@ -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'); @@ -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) { @@ -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'); @@ -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'); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js index c196d6d4e052..97dbd50e26ce 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js @@ -3276,6 +3276,61 @@ testModule('focus policy', { assert.strictEqual(getActiveElement(), $firstTabbable.get(0), 'first item focused on press tab on last item (does not go under overlay)'); }); + test('focus in Overlay should be looped if _loopFocus: true and shading: false', function(assert) { + const overlay = new Overlay($('
').appendTo('#qunit-fixture'), { + visible: true, + shading: false, + _loopFocus: true, + contentTemplate: $('#focusableTemplate') + }); + const $content = overlay.$content(); + + const firstFocusableElement = $content.find('.firstTabbable').get(0); + const lastFocusableElement = $content.find('.lastTabbable').get(0); + + $(lastFocusableElement).focus(); + $(lastFocusableElement).trigger(this.tabEvent); + + assert.strictEqual(getActiveElement(), firstFocusableElement, 'first item is focused'); + + $(firstFocusableElement).trigger(this.shiftTabEvent); + + assert.strictEqual(getActiveElement(), lastFocusableElement, 'last item is focused'); + }); + + test('focus in Overlay should be looped if shading: false, _loopFocus gets true in runtime', function(assert) { + const overlay = new Overlay($('
').appendTo('#qunit-fixture'), { + visible: true, + shading: false, + contentTemplate: $('#focusableTemplate') + }); + const $content = overlay.$content(); + + const firstFocusableElement = $content.find('.firstTabbable').get(0); + const lastFocusableElement = $content.find('.lastTabbable').get(0); + + $(lastFocusableElement).focus(); + $(lastFocusableElement).trigger(this.tabEvent); + + assert.strictEqual(getActiveElement() !== firstFocusableElement, true, 'first item is not focused'); + + $(firstFocusableElement).focus(); + $(firstFocusableElement).trigger(this.shiftTabEvent); + + assert.strictEqual(getActiveElement() !== lastFocusableElement, true, 'last item is not focused'); + + overlay.option('_loopFocus', true); + + $(lastFocusableElement).focus(); + $(lastFocusableElement).trigger(this.tabEvent); + + assert.strictEqual(getActiveElement(), firstFocusableElement, 'first item is focused'); + + $(firstFocusableElement).trigger(this.shiftTabEvent); + + assert.strictEqual(getActiveElement(), lastFocusableElement, 'last item is focused'); + }); + test('elements under overlay with shader have not to get focus by tab when top overlay has no tabbable elements', function(assert) { const overlay1 = new Overlay($('
').appendTo('#qunit-fixture'), { shading: true,