diff --git a/packages/devextreme/js/__internal/ui/drop_down_editor/m_drop_down_list.ts b/packages/devextreme/js/__internal/ui/drop_down_editor/m_drop_down_list.ts index 9c0032ededcd..454a27986a59 100644 --- a/packages/devextreme/js/__internal/ui/drop_down_editor/m_drop_down_list.ts +++ b/packages/devextreme/js/__internal/ui/drop_down_editor/m_drop_down_list.ts @@ -48,9 +48,9 @@ const SEARCH_MODES = ['startswith', 'contains', 'endwith', 'notcontains']; const useCompositionEvents = devices.real().platform !== 'android'; interface DropDownListProperties extends Omit, -'onOpened' | 'onClosed' -| 'onChange' | 'onCopy' | 'onCut' | 'onEnterKey' | 'onFocusIn' | 'onFocusOut' | 'onInput' | 'onKeyDown' | 'onKeyUp' | 'onPaste' -| 'onValueChanged' | 'validationMessagePosition' | 'onContentReady' | 'onDisposing' | 'onOptionChanged' | 'onInitialized'> { + 'onOpened' | 'onClosed' + | 'onChange' | 'onCopy' | 'onCut' | 'onEnterKey' | 'onFocusIn' | 'onFocusOut' | 'onInput' | 'onKeyDown' | 'onKeyUp' | 'onPaste' + | 'onValueChanged' | 'validationMessagePosition' | 'onContentReady' | 'onDisposing' | 'onOptionChanged' | 'onInitialized'> { encodeNoDataText?: boolean; } @@ -624,7 +624,7 @@ class DropDownList< dataSource: this._getDataSource(), _dataController: this._dataController, hoverStateEnabled: this._isDesktopDevice() ? hoverStateEnabled : false, - focusStateEnabled: this._isDesktopDevice() ? focusStateEnabled : false, + focusStateEnabled, _onItemsRendered: (): void => { // @ts-expect-error ts-error this._popup.repaint(); @@ -699,7 +699,7 @@ class DropDownList< } // eslint-disable-next-line @typescript-eslint/no-unused-vars - _listItemClickHandler(e?): void {} + _listItemClickHandler(e?): void { } _setListDataSource(): void { if (!this._list) { @@ -733,10 +733,10 @@ class DropDownList< _canKeepDataSource(): boolean { const isMinSearchLengthExceeded = this._isMinSearchLengthExceeded(); return this._dataController.isLoaded() - && this.option('showDataBeforeSearch') - && this.option('minSearchLength') - && !isMinSearchLengthExceeded - && !this._isLastMinSearchLengthExceeded; + && this.option('showDataBeforeSearch') + && this.option('minSearchLength') + && !isMinSearchLengthExceeded + && !this._isLastMinSearchLengthExceeded; } _searchValue() { @@ -992,10 +992,13 @@ class DropDownList< this._dataExpressionOptionChanged(args); switch (args.name) { case 'hoverStateEnabled': - case 'focusStateEnabled': this._isDesktopDevice() && this._setListOption(args.name, args.value); super._optionChanged(args); break; + case 'focusStateEnabled': + this._setListOption(args.name, args.value); + super._optionChanged(args); + break; case 'items': if (!this.option('dataSource')) { this._processDataSourceChanging(); diff --git a/packages/devextreme/js/__internal/ui/m_lookup.ts b/packages/devextreme/js/__internal/ui/m_lookup.ts index ac3b400977a3..251e8e7ccb97 100644 --- a/packages/devextreme/js/__internal/ui/m_lookup.ts +++ b/packages/devextreme/js/__internal/ui/m_lookup.ts @@ -294,7 +294,7 @@ class Lookup extends DropDownList { }); } - _fireContentReadyAction() {} + _fireContentReadyAction() { } _popupWrapperClass() { return ''; @@ -371,7 +371,7 @@ class Lookup extends DropDownList { } } - _renderButtonContainers(): void {} + _renderButtonContainers(): void { } _renderFieldTemplate(template) { this._$field.empty(); @@ -671,7 +671,7 @@ class Lookup extends DropDownList { } } - _preventFocusOnPopup(): void {} + _preventFocusOnPopup(): void { } _shouldLoopFocusInsidePopup(): boolean { const { @@ -765,32 +765,43 @@ class Lookup extends DropDownList { } _popupToolbarItemsConfig() { + const { focusStateEnabled, applyButtonText: text } = this.option(); + return [ { shortcut: 'done', options: { + text, + focusStateEnabled, onClick: this._applyButtonHandler.bind(this), - text: this.option('applyButtonText'), }, }, ]; } _getCancelButtonConfig() { - return this.option('showCancelButton') ? { + const { focusStateEnabled, cancelButtonText: text, showCancelButton } = this.option(); + + return showCancelButton ? { shortcut: 'cancel', - onClick: this._cancelButtonHandler.bind(this), options: { - text: this.option('cancelButtonText'), + text, + focusStateEnabled, }, + onClick: this._cancelButtonHandler.bind(this), } : null; } _getClearButtonConfig() { - return this.option('showClearButton') ? { + const { showClearButton, clearButtonText: text, focusStateEnabled } = this.option(); + + return showClearButton ? { shortcut: 'clear', + options: { + text, + focusStateEnabled, + }, onClick: this._resetValue.bind(this), - options: { text: this.option('clearButtonText') }, } : null; } @@ -832,7 +843,7 @@ class Lookup extends DropDownList { this._renderSearch(); } - _renderValueChangeEvent(): void {} + _renderValueChangeEvent(): void { } _renderSearch(): void { const isSearchEnabled = this.option('searchEnabled'); @@ -861,8 +872,10 @@ class Lookup extends DropDownList { onDisposing: () => isKeyboardListeningEnabled = false, // eslint-disable-next-line no-return-assign onFocusIn: () => isKeyboardListeningEnabled = true, - // eslint-disable-next-line no-return-assign - onFocusOut: () => isKeyboardListeningEnabled = false, + onFocusOut: () => { + isKeyboardListeningEnabled = false; + this._list?.option('focusedElement', null); + }, // @ts-expect-error ts-error onKeyboardHandled: (opts) => isKeyboardListeningEnabled && this._list._keyboardHandler(opts), onValueChanged: (e) => this._searchHandler(e), @@ -954,11 +967,11 @@ class Lookup extends DropDownList { this._searchBox?.option('placeholder', placeholder); } - _setAriaTargetForList(): void {} + _setAriaTargetForList(): void { } _listConfig() { return extend(super._listConfig(), { - tabIndex: 0, + tabIndex: this.option('searchEnabled') ? -1 : 0, grouped: this.option('grouped'), groupTemplate: this._getTemplateByOption('groupTemplate'), pullRefreshEnabled: this.option('pullRefreshEnabled'), @@ -1093,6 +1106,7 @@ class Lookup extends DropDownList { this._removeSearch(); this._renderSearch(); } + this._setListOption('tabIndex', value ? -1 : 0); break; case 'searchPlaceholder': this._setSearchPlaceholder(); @@ -1109,6 +1123,11 @@ class Lookup extends DropDownList { case 'placeholder': this._invalidate(); break; + case 'focusStateEnabled': + this._setPopupOption('toolbarItems', this._getPopupToolbarItems()); + // @ts-expect-error ts-error + super._optionChanged(...arguments); + break; case 'clearButtonText': case 'showClearButton': case 'showCancelButton': diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/dropDownList.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/dropDownList.tests.js index 8c98e824aa6e..56248de76e9f 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/dropDownList.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/dropDownList.tests.js @@ -101,7 +101,7 @@ QUnit.module('focus policy', { } }); - QUnit.test('hover and focus states for list should be initially disabled on mobile devices only', function(assert) { + QUnit.test('hover state for list should be initially disabled on mobile devices, focus state should follow parent', function(assert) { this.instance.option('opened', true); const list = $(`.${LIST_CLASS}`).dxList('instance'); @@ -111,11 +111,11 @@ QUnit.module('focus policy', { assert.ok(list.option('focusStateEnabled'), 'focus state should be enabled on desktop'); } else { assert.notOk(list.option('hoverStateEnabled'), 'hover state should be disabled on mobiles'); - assert.notOk(list.option('focusStateEnabled'), 'focus state should be disabled on mobiles'); + assert.ok(list.option('focusStateEnabled'), 'focus state should follow parent value on mobiles'); } }); - QUnit.test('changing hover and focus states for list should be enabled on desktop only', function(assert) { + QUnit.test('changing hover state for list should be enabled on desktop only, focus state should be enabled on both desktop and mobile', function(assert) { this.instance.option('opened', true); const list = $(`.${LIST_CLASS}`).dxList('instance'); @@ -126,10 +126,13 @@ QUnit.module('focus policy', { assert.notOk(list.option('hoverStateEnabled'), 'hover state should be changed to disabled on desktop'); assert.notOk(list.option('focusStateEnabled'), 'focus state should be changed to disabled on desktop'); } else { + assert.notOk(list.option('hoverStateEnabled'), 'hover state should not be changed on mobiles'); + assert.notOk(list.option('focusStateEnabled'), 'focus state should be changed on mobiles'); + this.instance.option({ hoverStateEnabled: true, focusStateEnabled: true }); assert.notOk(list.option('hoverStateEnabled'), 'hover state should not be changed on mobiles'); - assert.notOk(list.option('focusStateEnabled'), 'focus state should not be changed on mobiles'); + assert.ok(list.option('focusStateEnabled'), 'focus state should be changed on mobiles'); } }); 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 abf7ec47cc4a..8e4b9f16f974 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 @@ -92,6 +92,8 @@ const SCROLL_VIEW_CONTENT_CLASS = 'dx-scrollview-content'; const LIST_ITEMS_CLASS = 'dx-list-items'; const FOCUSED_CLASS = 'dx-state-focused'; +const APPLY_BUTTON_SELECTOR = `.${APPLY_BUTTON_CLASS}.dx-button`; +const CLEAR_BUTTON_SELECTOR = `.${CLEAR_BUTTON_CLASS}.dx-button`; const CANCEL_BUTTON_SELECTOR = '.dx-popup-cancel.dx-button'; const WINDOW_RATIO = 0.8; @@ -2654,6 +2656,195 @@ QUnit.module('list options', { }); }); +QUnit.module('keyboard navigation - focus management', { + beforeEach: function() { + fx.off = true; + this.clock = sinon.useFakeTimers(); + this.getList = (instance) => instance._list; + }, + afterEach: function() { + this.clock.restore(); + fx.off = false; + } +}, () => { + QUnit.test('list should have tabIndex=-1 when searchEnabled is true', function(assert) { + const instance = $('#lookup').dxLookup({ + items: [1, 2, 3], + focusStateEnabled: true, + searchEnabled: true, + opened: true, + }).dxLookup('instance'); + + assert.strictEqual(this.getList(instance).option('tabIndex'), -1, 'list tabIndex is -1 when searchEnabled is true'); + }); + + QUnit.test('list should have tabIndex=0 when searchEnabled is false', function(assert) { + const instance = $('#lookup').dxLookup({ + items: [1, 2, 3], + focusStateEnabled: true, + searchEnabled: false, + opened: true, + }).dxLookup('instance'); + + assert.strictEqual(this.getList(instance).option('tabIndex'), 0, 'list tabIndex is 0 when searchEnabled is false'); + }); + + QUnit.test('list tabIndex should update when searchEnabled changes at runtime', function(assert) { + const instance = $('#lookup').dxLookup({ + items: [1, 2, 3], + focusStateEnabled: true, + searchEnabled: true, + opened: true, + }).dxLookup('instance'); + + const list = this.getList(instance); + + assert.strictEqual(list.option('tabIndex'), -1, 'tabIndex is -1 initially with searchEnabled=true'); + + instance.option('searchEnabled', false); + assert.strictEqual(list.option('tabIndex'), 0, 'tabIndex changed to 0 after searchEnabled set to false'); + + instance.option('searchEnabled', true); + assert.strictEqual(list.option('tabIndex'), -1, 'tabIndex changed back to -1 after searchEnabled set to true'); + }); + + QUnit.test('list should receive focusStateEnabled from Lookup', function(assert) { + const instance = $('#lookup').dxLookup({ + items: [1, 2, 3], + focusStateEnabled: true, + opened: true, + }).dxLookup('instance'); + + assert.ok(this.getList(instance).option('focusStateEnabled'), 'list has focusStateEnabled=true when Lookup has focusStateEnabled=true'); + }); + + QUnit.test('list focusStateEnabled should update when parent focusStateEnabled changes at runtime', function(assert) { + const instance = $('#lookup').dxLookup({ + items: [1, 2, 3], + focusStateEnabled: true, + opened: true, + }).dxLookup('instance'); + + const list = this.getList(instance); + + instance.option('focusStateEnabled', false); + assert.notOk(list.option('focusStateEnabled'), 'list focusStateEnabled updated to false'); + + instance.option('focusStateEnabled', true); + assert.ok(list.option('focusStateEnabled'), 'list focusStateEnabled updated to true'); + }); + + QUnit.test('done button should receive focusStateEnabled from Lookup', function(assert) { + const instance = $('#lookup').dxLookup({ + items: [1, 2, 3], + focusStateEnabled: true, + applyValueMode: 'useButtons', + opened: true, + }).dxLookup('instance'); + + const $doneButton = $(instance.content()).parent().find(APPLY_BUTTON_SELECTOR); + assert.ok($doneButton.length, 'done button exists'); + + const doneButton = $doneButton.dxButton('instance'); + assert.ok(doneButton.option('focusStateEnabled'), 'done button has focusStateEnabled=true'); + }); + + QUnit.test('clear button should receive focusStateEnabled from Lookup', function(assert) { + const instance = $('#lookup').dxLookup({ + items: [1, 2, 3], + focusStateEnabled: true, + showClearButton: true, + opened: true, + }).dxLookup('instance'); + + const $clearButton = $(instance.content()).parent().find(CLEAR_BUTTON_SELECTOR); + assert.ok($clearButton.length, 'clear button exists'); + + const clearButton = $clearButton.dxButton('instance'); + assert.ok(clearButton.option('focusStateEnabled'), 'clear button has focusStateEnabled=true'); + }); + + QUnit.test('cancel button should receive focusStateEnabled from Lookup', function(assert) { + const instance = $('#lookup').dxLookup({ + items: [1, 2, 3], + focusStateEnabled: true, + showCancelButton: true, + opened: true, + }).dxLookup('instance'); + + const $cancelButton = $(instance.content()).parent().find(CANCEL_BUTTON_SELECTOR); + assert.ok($cancelButton.length, 'cancel button exists'); + + const cancelButton = $cancelButton.dxButton('instance'); + assert.ok(cancelButton.option('focusStateEnabled'), 'cancel button has focusStateEnabled=true'); + }); + + QUnit.test('toolbar buttons should not have focusStateEnabled when Lookup has focusStateEnabled=false', function(assert) { + const instance = $('#lookup').dxLookup({ + items: [1, 2, 3], + focusStateEnabled: false, + showClearButton: true, + showCancelButton: true, + applyValueMode: 'useButtons', + opened: true, + }).dxLookup('instance'); + + const doneButton = $(instance.content()).parent().find(APPLY_BUTTON_SELECTOR).dxButton('instance'); + const clearButton = $(instance.content()).parent().find(CLEAR_BUTTON_SELECTOR).dxButton('instance'); + const cancelButton = $(instance.content()).parent().find(CANCEL_BUTTON_SELECTOR).dxButton('instance'); + + assert.notOk(doneButton.option('focusStateEnabled'), 'done button has focusStateEnabled=false'); + assert.notOk(clearButton.option('focusStateEnabled'), 'clear button has focusStateEnabled=false'); + assert.notOk(cancelButton.option('focusStateEnabled'), 'cancel button has focusStateEnabled=false'); + }); + + QUnit.test('toolbar buttons should update focusStateEnabled when Lookup focusStateEnabled changes from true to false at runtime', function(assert) { + const instance = $('#lookup').dxLookup({ + items: [1, 2, 3], + focusStateEnabled: true, + showClearButton: true, + showCancelButton: true, + applyValueMode: 'useButtons', + opened: true, + }).dxLookup('instance'); + + const $overlayContent = $(instance.content()).parent(); + + assert.ok($overlayContent.find(APPLY_BUTTON_SELECTOR).dxButton('instance').option('focusStateEnabled'), 'done button initially has focusStateEnabled=true'); + assert.ok($overlayContent.find(CLEAR_BUTTON_SELECTOR).dxButton('instance').option('focusStateEnabled'), 'clear button initially has focusStateEnabled=true'); + assert.ok($overlayContent.find(CANCEL_BUTTON_SELECTOR).dxButton('instance').option('focusStateEnabled'), 'cancel button initially has focusStateEnabled=true'); + + instance.option('focusStateEnabled', false); + + assert.notOk($overlayContent.find(APPLY_BUTTON_SELECTOR).dxButton('instance').option('focusStateEnabled'), 'done button updated to focusStateEnabled=false'); + assert.notOk($overlayContent.find(CLEAR_BUTTON_SELECTOR).dxButton('instance').option('focusStateEnabled'), 'clear button updated to focusStateEnabled=false'); + assert.notOk($overlayContent.find(CANCEL_BUTTON_SELECTOR).dxButton('instance').option('focusStateEnabled'), 'cancel button updated to focusStateEnabled=false'); + }); + + QUnit.test('toolbar buttons should update focusStateEnabled when Lookup focusStateEnabled changes from false to true at runtime', function(assert) { + const instance = $('#lookup').dxLookup({ + items: [1, 2, 3], + focusStateEnabled: false, + showClearButton: true, + showCancelButton: true, + applyValueMode: 'useButtons', + opened: true, + }).dxLookup('instance'); + + const $overlayContent = $(instance.content()).parent(); + + assert.notOk($overlayContent.find(APPLY_BUTTON_SELECTOR).dxButton('instance').option('focusStateEnabled'), 'done button initially has focusStateEnabled=false'); + assert.notOk($overlayContent.find(CLEAR_BUTTON_SELECTOR).dxButton('instance').option('focusStateEnabled'), 'clear button initially has focusStateEnabled=false'); + assert.notOk($overlayContent.find(CANCEL_BUTTON_SELECTOR).dxButton('instance').option('focusStateEnabled'), 'cancel button initially has focusStateEnabled=false'); + + instance.option('focusStateEnabled', true); + + assert.ok($overlayContent.find(APPLY_BUTTON_SELECTOR).dxButton('instance').option('focusStateEnabled'), 'done button updated to focusStateEnabled=true'); + assert.ok($overlayContent.find(CLEAR_BUTTON_SELECTOR).dxButton('instance').option('focusStateEnabled'), 'clear button updated to focusStateEnabled=true'); + assert.ok($overlayContent.find(CANCEL_BUTTON_SELECTOR).dxButton('instance').option('focusStateEnabled'), 'cancel button updated to focusStateEnabled=true'); + }); +}); + QUnit.module('Native scrolling', () => { QUnit.test('After load new page scrollTop should not be changed', function(assert) { const data = []; @@ -2910,25 +3101,6 @@ QUnit.module('keyboard navigation', { assert.ok(instance._$list.find('.dx-list-item').first().hasClass(FOCUSED_CLASS), 'list-item is focused after down key pressing'); }); - QUnit.testInActiveWindow('lookup-list keyboard navigation should work after focusing on list', function(assert) { - const $element = $('#widget').dxLookup({ - opened: true, - items: [1, 2, 3], - focusStateEnabled: true, - searchEnabled: true - }); - const instance = $element.dxLookup('instance'); - - instance._$list.dxList('focus'); - assert.ok(instance._$list.find(`.${LIST_ITEM_CLASS}`).eq(0).hasClass(FOCUSED_CLASS), 'list-item is focused after focusing on list'); - - const $listItemContainer = instance._$list.find(`.${LIST_ITEMS_CLASS}`).parent(); - const keyboard = keyboardMock($listItemContainer); - keyboard.keyDown('down'); - - 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) { const $element = $('#widget').dxLookup({ @@ -3545,7 +3717,7 @@ if(devices.real().deviceType === 'desktop') { QUnit.test(`opened: true, searchEnabled: ${searchEnabled}`, function() { helper.createWidget({ opened: true, - searchEnabled + searchEnabled, }); const localizedRoleDescription = messageLocalization.format('dxList-ariaRoleDescription'); @@ -3561,7 +3733,7 @@ if(devices.real().deviceType === 'desktop') { }; const listItemContainerAttributes = { - tabindex: '0', + tabindex: searchEnabled ? '-1' : '0', role: 'application', }; @@ -3601,9 +3773,12 @@ if(devices.real().deviceType === 'desktop') { helper.checkAttributes($input, expectedAttributes, 'input'); } - helper.widget.option('searchEnabled', !searchEnabled); + const newSearchEnabled = !searchEnabled; + + helper.widget.option('searchEnabled', newSearchEnabled); listAttributes.id = helper.widget._listId; + listItemContainerAttributes.tabindex = newSearchEnabled ? '-1' : '0'; fieldAttributes = { role: 'combobox', @@ -3621,8 +3796,10 @@ if(devices.real().deviceType === 'desktop') { id: helper.widget._popupContentId, }; + const scrollView = $list.find(`.${SCROLL_VIEW_CONTENT_CLASS}`); + helper.checkAttributes($list, listAttributes, 'list'); - helper.checkAttributes($list.find(`.${SCROLL_VIEW_CONTENT_CLASS}`), listItemContainerAttributes, 'scrollview content'); + helper.checkAttributes(scrollView, listItemContainerAttributes, 'scrollview content'); helper.checkAttributes($field, fieldAttributes, 'field'); helper.checkAttributes(helper.$widget, widgetAttributes, 'widget'); helper.checkAttributes(helper.widget._popup.$content(), popupContentAttributes, 'popupContent'); @@ -3714,15 +3891,15 @@ if(devices.real().deviceType === 'desktop') { const $scrollView = $list.find(`.${SCROLL_VIEW_CONTENT_CLASS}`); const $itemsContainer = $list.find(`.${LIST_ITEMS_CLASS}`); - helper.checkAttributes($scrollView, { tabindex: '0', role: 'application' }); + helper.checkAttributes($scrollView, { tabindex: '-1', role: 'application' }); helper.checkAttributes($itemsContainer, { }); helper.widget.option(dataSourcePropertyName, [1, 2, 3]); - helper.checkAttributes($scrollView, { tabindex: '0', role: 'application' }); + helper.checkAttributes($scrollView, { tabindex: '-1', role: 'application' }); helper.checkAttributes($itemsContainer, { 'aria-label': 'Items', role: 'listbox' }); helper.widget.option(dataSourcePropertyName, []); - helper.checkAttributes($scrollView, { tabindex: '0', role: 'application' }); + helper.checkAttributes($scrollView, { tabindex: '-1', role: 'application' }); helper.checkAttributes($itemsContainer, { }); }); });